perf: move PouchDB/IndexedDB operations to a Web Worker

All database reads and writes now run off the main thread via a
dedicated worker, eliminating IndexedDB overhead from the frame budget.

- Create data.worker.ts exposing the Data class via Comlink
- Refactor data.ts to export a Comlink-wrapped proxy and a standalone
  generateId() pure function (workers can't expose sync methods cleanly)
- Update all 10 call sites to import generateId directly instead of
  calling data.generateId()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Julien Calixte
2026-04-04 11:27:45 +02:00
parent 1b5e23e3d4
commit b003a3e008
14 changed files with 225 additions and 231 deletions

View File

@@ -1,166 +1,31 @@
import { wrap } from "comlink"
import { nanoid } from "nanoid"
import indexedDb from "pouchdb-adapter-indexeddb"
import PouchDb from "pouchdb-browser"
import { DataType } from "./DataType.enum"
import { Model } from "./models/Model"
PouchDb.plugin(indexedDb)
interface GetAllParams {
prefix?: string
includeDocs?: boolean
includeAttachments?: boolean
keys?: string[]
}
class Data {
// oxlint-disable-next-line typescript/ban-types
private readonly locale: PouchDB.Database<{}> | null = null
constructor() {
try {
this.locale = new PouchDb("remanso", {
adapter: "indexeddb"
})
} catch (error) {
console.warn("data error", error)
}
}
public async add<DT extends DataType>(model: Model<DT>): Promise<boolean> {
try {
const result = await this.locale?.put(model)
return result?.ok ?? false
} catch (error) {
console.warn(error)
return false
}
}
public async update<DT extends DataType, T extends Model<DT>>(
model: T
): Promise<boolean> {
try {
if (!model._id) {
const result = await this.locale?.put(model)
return result?.ok ?? false
}
const oldModel = await this.get(model._id)
if (oldModel) {
const result = await this.locale?.put({ ...oldModel, ...model })
return result?.ok ?? false
}
const result = await this.locale?.put(model)
return result?.ok ?? false
} catch (error) {
console.warn(error)
return false
}
}
public async remove(id: string): Promise<boolean> {
try {
const doc = await this.get(id)
if (!doc) {
return false
}
const result = await this.locale?.put({
...doc,
_deleted: true
})
return result?.ok ?? false
} catch {
return false
}
}
public async get<DT extends DataType, T extends Model<DT>>(
id: string
): Promise<T | null> {
try {
return ((await this.locale?.get(id)) as T) || null
} catch {
return null
}
}
public async getOrCreate<DT extends DataType, T extends Model<DT>>(
export interface DataApi {
add<DT extends DataType>(model: Model<DT>): Promise<boolean>
update<DT extends DataType, T extends Model<DT>>(model: T): Promise<boolean>
remove(id: string): Promise<boolean>
get<DT extends DataType, T extends Model<DT>>(id: string): Promise<T | null>
getOrCreate<DT extends DataType, T extends Model<DT>>(
id: string,
initialValue: T
): Promise<T> {
const element = await this.get<DT, T>(id)
if (element) {
return element
}
await data.add<DT>({ ...initialValue, _id: id })
return this.getOrCreate(id, initialValue)
}
public async getAll<DT extends DataType, T extends Model<DT>>({
prefix,
includeDocs = true,
includeAttachments = false,
keys = []
}: GetAllParams): Promise<T[]> {
if (!this.locale) {
return []
}
if (keys.length) {
const response = await this.locale.allDocs({
include_docs: includeDocs,
attachments: includeAttachments,
keys: keys.map((key) => this.generateId(prefix, key))
})
if (includeDocs) {
return response.rows
.map((row) => {
if ("error" in row) {
return null
}
return row.doc
})
.filter(Boolean) as T[]
} else {
return response.rows
.map((row) => {
if ("error" in row) {
return null
}
return { _id: row.id }
})
.filter(Boolean) as T[]
}
}
const response = await this.locale.allDocs({
include_docs: includeDocs,
attachments: includeAttachments,
startkey: prefix ? prefix : undefined,
endkey: prefix ? `${prefix}\ufff0` : undefined
})
return response.rows.map((row) => row.doc) as T[]
}
public generateId(type?: DataType | string, id?: string) {
if (!type) {
return id || nanoid()
}
return `${type}-${id || nanoid()}`
}
): Promise<T>
getAll<DT extends DataType, T extends Model<DT>>(params: {
prefix?: string
includeDocs?: boolean
includeAttachments?: boolean
keys?: string[]
}): Promise<T[]>
}
export const data = new Data()
export const generateId = (type?: DataType | string, id?: string): string => {
if (!type) return id || nanoid()
return `${type}-${id || nanoid()}`
}
import DataWorker from "./data.worker?worker"
export const data = wrap(new DataWorker()) as unknown as DataApi

156
src/data/data.worker.ts Normal file
View File

@@ -0,0 +1,156 @@
import { expose } from "comlink"
import { nanoid } from "nanoid"
import indexedDb from "pouchdb-adapter-indexeddb"
import PouchDb from "pouchdb-browser"
import { DataType } from "./DataType.enum"
import { Model } from "./models/Model"
PouchDb.plugin(indexedDb)
interface GetAllParams {
prefix?: string
includeDocs?: boolean
includeAttachments?: boolean
keys?: string[]
}
class Data {
// oxlint-disable-next-line typescript/ban-types
private readonly locale: PouchDB.Database<{}> | null = null
constructor() {
try {
this.locale = new PouchDb("remanso", {
adapter: "indexeddb"
})
} catch (error) {
console.warn("data error", error)
}
}
private buildId(type?: DataType | string, id?: string): string {
if (!type) return id || nanoid()
return `${type}-${id || nanoid()}`
}
public async add<DT extends DataType>(model: Model<DT>): Promise<boolean> {
try {
const result = await this.locale?.put(model)
return result?.ok ?? false
} catch (error) {
console.warn(error)
return false
}
}
public async update<DT extends DataType, T extends Model<DT>>(
model: T
): Promise<boolean> {
try {
if (!model._id) {
const result = await this.locale?.put(model)
return result?.ok ?? false
}
const oldModel = await this.get(model._id)
if (oldModel) {
const result = await this.locale?.put({ ...oldModel, ...model })
return result?.ok ?? false
}
const result = await this.locale?.put(model)
return result?.ok ?? false
} catch (error) {
console.warn(error)
return false
}
}
public async remove(id: string): Promise<boolean> {
try {
const doc = await this.get(id)
if (!doc) {
return false
}
const result = await this.locale?.put({
...doc,
_deleted: true
})
return result?.ok ?? false
} catch {
return false
}
}
public async get<DT extends DataType, T extends Model<DT>>(
id: string
): Promise<T | null> {
try {
return ((await this.locale?.get(id)) as T) || null
} catch {
return null
}
}
public async getOrCreate<DT extends DataType, T extends Model<DT>>(
id: string,
initialValue: T
): Promise<T> {
const element = await this.get<DT, T>(id)
if (element) {
return element
}
await this.add<DT>({ ...initialValue, _id: id })
return this.getOrCreate(id, initialValue)
}
public async getAll<DT extends DataType, T extends Model<DT>>({
prefix,
includeDocs = true,
includeAttachments = false,
keys = []
}: GetAllParams): Promise<T[]> {
if (!this.locale) {
return []
}
if (keys.length) {
const response = await this.locale.allDocs({
include_docs: includeDocs,
attachments: includeAttachments,
keys: keys.map((key) => this.buildId(prefix, key))
})
if (includeDocs) {
return response.rows
.map((row) => {
if ("error" in row) return null
return row.doc
})
.filter(Boolean) as T[]
} else {
return response.rows
.map((row) => {
if ("error" in row) return null
return { _id: row.id }
})
.filter(Boolean) as T[]
}
}
const response = await this.locale.allDocs({
include_docs: includeDocs,
attachments: includeAttachments,
startkey: prefix ? prefix : undefined,
endkey: prefix ? `${prefix}\ufff0` : undefined
})
return response.rows.map((row) => row.doc) as T[]
}
}
expose(new Data())

View File

@@ -2,7 +2,7 @@ import { useAsyncState } from "@vueuse/core"
import { ComputedRef, onUnmounted, toValue } from "vue"
import { backlinkEventBus } from "@/bus/backlinkEventBus"
import { data } from "@/data/data"
import { data, generateId } from "@/data/data"
import { DataType } from "@/data/DataType.enum"
import { BacklinkNote } from "@/modules/note/models/BacklinkNote"
@@ -11,7 +11,7 @@ export const useBacklinks = (sha: string | ComputedRef<string>) => {
const { state: backlink, execute } = useAsyncState(
data.get<DataType.BacklinkNote, BacklinkNote>(
data.generateId(DataType.BacklinkNote, sha)
generateId(DataType.BacklinkNote, sha)
),
null,
{

View File

@@ -1,7 +1,7 @@
import { watch } from "vue"
import { backlinkEventBus } from "@/bus/backlinkEventBus"
import { data } from "@/data/data"
import { data, generateId } from "@/data/data"
import { DataType } from "@/data/DataType.enum"
import { useFile } from "@/hooks/useFile.hook"
import { Backlink } from "@/modules/note/models/Backlink"
@@ -42,7 +42,7 @@ export const useComputeBacklinks = () => {
continue
}
const fileBacklinkId = data.generateId(DataType.BacklinkNote, file.sha)
const fileBacklinkId = generateId(DataType.BacklinkNote, file.sha)
const fileBacklink = await data.get<DataType.BacklinkNote, BacklinkNote>(
fileBacklinkId
)
@@ -102,7 +102,7 @@ export const useComputeBacklinks = () => {
}
for (const [sha, fileBacklinks] of backlinks) {
const fileBacklinkId = data.generateId(DataType.BacklinkNote, sha)
const fileBacklinkId = generateId(DataType.BacklinkNote, sha)
const backlinkNote: BacklinkNote = {
_id: fileBacklinkId,
$type: DataType.BacklinkNote,

View File

@@ -1,7 +1,7 @@
import { useAsyncState } from "@vueuse/core"
import { computed, ref } from "vue"
import { data } from "@/data/data"
import { data, generateId } from "@/data/data"
import { DataType } from "@/data/DataType.enum"
import { prepareNoteCache } from "@/modules/note/cache/prepareNoteCache"
import { Note } from "@/modules/note/models/Note"
@@ -36,7 +36,7 @@ export const useOfflineNotes = () => {
if (
!file.sha ||
cachedNotesSet.has(data.generateId(DataType.Note, file.sha))
cachedNotesSet.has(generateId(DataType.Note, file.sha))
) {
continue
}

View File

@@ -4,7 +4,7 @@ import { useAsyncState } from "@vueuse/core"
import { addDays, isAfter } from "date-fns"
import { computed, nextTick, watch } from "vue"
import { data } from "@/data/data"
import { data, generateId } from "@/data/data"
import { DataType } from "@/data/DataType.enum"
import { useFile } from "@/hooks/useFile.hook"
import { useLinks } from "@/hooks/useLinks.hook"
@@ -51,7 +51,7 @@ export const useSpacedRepetitionCards = () => {
const repetition = await data.getOrCreate<
DataType.RepetitionCard,
RepetitionCard
>(data.generateId(DataType.RepetitionCard, cardFile.path), {
>(generateId(DataType.RepetitionCard, cardFile.path), {
$type: DataType.RepetitionCard,
level: 1,
repeatDate: new Date(),

View File

@@ -1,17 +1,17 @@
import { useAsyncState } from "@vueuse/core"
import { computed } from "vue"
import { data } from "@/data/data"
import { data, generateId } from "@/data/data"
import { DataType } from "@/data/DataType.enum"
import { History } from "@/data/models/History"
const HISTORY_ID = data.generateId(DataType.History, "history")
const HISTORY_ID = generateId(DataType.History, "history")
export const useLastVisitedRepos = () => {
const history = useAsyncState(
() =>
data.get<DataType.History, History>(
data.generateId(DataType.History, "history")
generateId(DataType.History, "history")
),
null
)

View File

@@ -1,10 +1,10 @@
import { Ref, toValue } from "vue"
import { data } from "@/data/data"
import { data, generateId } from "@/data/data"
import { DataType } from "@/data/DataType.enum"
import { History } from "@/data/models/History"
const HISTORY_ID = data.generateId(DataType.History, "history")
const HISTORY_ID = generateId(DataType.History, "history")
const MAX_REPO_HISTORY = 10
export const useVisitRepo = (newRepo: {

View File

@@ -1,4 +1,4 @@
import { data } from "@/data/data"
import { data, generateId } from "@/data/data"
import { DataType } from "@/data/DataType.enum"
import { Note } from "@/modules/note/models/Note"
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
@@ -14,8 +14,8 @@ type NoteCacheResult =
export const prepareNoteCache = (sha: string, path?: string) => {
const store = useUserRepoStore()
const noteId = data.generateId(DataType.Note, sha)
const notePath = path ? data.generateId(DataType.Note, path) : null
const noteId = generateId(DataType.Note, sha)
const notePath = path ? generateId(DataType.Note, path) : null
const getCachedNote = async (): Promise<NoteCacheResult> => {
const note = await data.get<DataType.Note, Note>(noteId)

View File

@@ -1,6 +1,6 @@
import { computed, onMounted, ref } from "vue"
import { data } from "@/data/data"
import { data, generateId } from "@/data/data"
import { DataType } from "@/data/DataType.enum"
import { useRepos } from "@/hooks/useRepos.hook"
import { RepoBase } from "@/modules/repo/interfaces/RepoBase"
@@ -27,7 +27,7 @@ export const useFavoriteRepos = () => {
const toggleFavorite = async (repo: RepoBase, isFavorite: boolean) => {
const favorite: FavoriteRepo = {
_id: data.generateId(DataType.FavoriteRepo, repo.id),
_id: generateId(DataType.FavoriteRepo, repo.id),
$type: DataType.FavoriteRepo,
isFavorite,
name: repo.name,

View File

@@ -1,6 +1,6 @@
import { defineStore } from "pinia"
import { data } from "@/data/data"
import { data, generateId } from "@/data/data"
import { DataType } from "@/data/DataType.enum"
import { RepoFile } from "@/modules/repo/interfaces/RepoFile"
import { UserSettings } from "@/modules/repo/interfaces/UserSettings"
@@ -39,7 +39,7 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
this.user = user
this.repo = repo
const savedRepoId = data.generateId(DataType.SavedRepo, `${user}-${repo}`)
const savedRepoId = generateId(DataType.SavedRepo, `${user}-${repo}`)
const userSettingsId = `UserSetting-${user}-${repo}`
const [cachedSavedRepo, cachedUserSettings] = await Promise.all([
@@ -131,7 +131,7 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
return
}
const savedRepoId = data.generateId(
const savedRepoId = generateId(
DataType.SavedRepo,
`${this.user}-${this.repo}`
)

View File

@@ -1,7 +1,7 @@
import { Octokit } from "@octokit/rest"
import { addMinutes, addSeconds, isBefore } from "date-fns"
import { data } from "@/data/data"
import { data, generateId } from "@/data/data"
import { DataType } from "@/data/DataType.enum"
import { GithubAccessToken } from "@/data/models/GithubAccessToken"
import { GithubToken } from "@/modules/user/interfaces/GithubToken"
@@ -26,7 +26,7 @@ export const needToRefreshToken = async () => {
const accessToken = await data.get<
DataType.GithubAccessToken,
GithubAccessToken
>(data.generateId(DataType.GithubAccessToken, personalTokenId))
>(generateId(DataType.GithubAccessToken, personalTokenId))
if (!accessToken) {
return false
@@ -42,7 +42,7 @@ export const refreshToken = async () => {
const accessToken = await data.get<
DataType.GithubAccessToken,
GithubAccessToken
>(data.generateId(DataType.GithubAccessToken, personalTokenId))
>(generateId(DataType.GithubAccessToken, personalTokenId))
if (!accessToken) {
return null
@@ -74,7 +74,7 @@ export const getAccessToken = async () => {
const response = await data.get<
DataType.GithubAccessToken,
GithubAccessToken
>(data.generateId(DataType.GithubAccessToken, personalTokenId))
>(generateId(DataType.GithubAccessToken, personalTokenId))
return response
}
@@ -94,7 +94,7 @@ export const saveAccessToken = async (githubToken: GithubToken) => {
const accessToken: GithubAccessToken = {
...actualPAT,
_id: data.generateId(DataType.GithubAccessToken, personalTokenId),
_id: generateId(DataType.GithubAccessToken, personalTokenId),
$type: DataType.GithubAccessToken,
token: githubToken.access_token,
expiresIn: githubToken.expires_in,