diff --git a/app/workers/brain-idb-store.ts b/app/workers/brain-idb-store.ts new file mode 100644 index 0000000..fcffff9 --- /dev/null +++ b/app/workers/brain-idb-store.ts @@ -0,0 +1,84 @@ +import type { UseStore } from 'idb-keyval' + +/** + * Two idb-keyval stores in ONE database. + * + * `idb-keyval`'s own `createStore` opens the database **without a version** and + * creates only its own object store in the upgrade. Call it twice for the same + * database and only the first store is ever created: by the time the second one + * opens, the database already exists at version 1, no upgrade fires, and every + * transaction against it throws `NotFoundError`. + * + * The brain needs two — `brain-meta` and `brain-content` — so it needs this. + * The database is opened once, at an explicit version, and both stores are + * created in that single upgrade. + * + * The version is 2 rather than 1 on purpose: a browser that already visited + * Studio holds `cr-brain` at version 1 with whichever store happened to be + * touched first. Opening at 1 would find it current and fire no upgrade, so the + * missing store would stay missing. Bumping forces the upgrade that creates it. + */ +const DB_VERSION = 2 + +/** + * Returns one `UseStore` per name, all sharing a single database connection — + * the shape `idb-keyval`'s `get` / `set` / `del` / `keys` already accept, so + * call sites do not change. + */ +export function createSharedStores( + dbName: string, + storeNames: N, +): Record { + let dbp: Promise | undefined + + const getDB = () => { + if (dbp) return dbp + + dbp = new Promise((resolve, reject) => { + const request = indexedDB.open(dbName, DB_VERSION) + + request.onupgradeneeded = () => { + const db = request.result + for (const name of storeNames) { + if (!db.objectStoreNames.contains(name)) db.createObjectStore(name) + } + } + + // Another tab holding an older version blocks the upgrade. Reject rather + // than leave every read pending forever: an error is recoverable and + // visible, a promise that never settles is neither. `onversionchange` + // below means this should not happen, but "should not" is not "cannot". + request.onblocked = () => reject(new Error('brain database upgrade blocked by another tab')) + + request.onerror = () => reject(request.error) + request.onsuccess = () => { + const db = request.result + // Step aside when another tab wants to upgrade, instead of being the + // tab that blocks it. + db.onversionchange = () => { + db.close() + dbp = undefined + } + // Safari sometimes closes the connection on its own and says so here. + db.onclose = () => { + dbp = undefined + } + resolve(db) + } + }) + + // A failed open must not be cached, or one blocked upgrade would poison + // every later call for the lifetime of the worker. + dbp.catch(() => { + dbp = undefined + }) + return dbp + } + + const stores = {} as Record + for (const name of storeNames) { + stores[name] = ((txMode, callback) => + getDB().then(db => callback(db.transaction(name, txMode).objectStore(name)))) as UseStore + } + return stores as Record +} diff --git a/app/workers/content-brain.worker.ts b/app/workers/content-brain.worker.ts index 8f171a4..2cdb4ad 100644 --- a/app/workers/content-brain.worker.ts +++ b/app/workers/content-brain.worker.ts @@ -6,14 +6,18 @@ * Cross-tab sync via BroadcastChannel. */ -import { createStore, del, get, keys, set } from 'idb-keyval' +import { del, get, keys, set } from 'idb-keyval' import FlexSearch from 'flexsearch' -// A worker has no Nuxt auto-imports, so this is explicit. +// A worker has no Nuxt auto-imports, so these are explicit. import { collectSearchHits, indexFetchLimit } from '../utils/search-results' +import { createSharedStores } from './brain-idb-store' -// Custom IDB stores in 'cr-brain' database -const metaStore = createStore('cr-brain', 'brain-meta') -const contentStore = createStore('cr-brain', 'brain-content') +// Both stores live in one 'cr-brain' database, opened once. idb-keyval's own +// `createStore` cannot do that — see brain-idb-store.ts for why it matters. +const { 'brain-meta': metaStore, 'brain-content': contentStore } = createSharedStores( + 'cr-brain', + ['brain-meta', 'brain-content'], +) // FlexSearch index (no published types — use any) // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -54,7 +58,13 @@ self.onmessage = async (event: MessageEvent) => { const { payload, projectId } = msg if (payload.delta && !payload.config && !payload.models && !payload.content) { - // No changes — already up to date + // No changes on the server — but "no changes" is about the repo, not + // about this worker. A worker is created fresh on every page load and + // its FlexSearch index lives only in memory, while the content it is + // built from lives in IndexedDB and survives. So on the common path — + // reload a project nobody has edited — the index was never built and + // search answered nothing, forever, without an error. + await ensureSearchIndex(projectId) self.postMessage({ type: 'synced', treeSha: payload.treeSha, stats: null }) break } @@ -130,6 +140,10 @@ self.onmessage = async (event: MessageEvent) => { case 'search': { const { id, query, modelId: searchModelId, locale: searchLocale, limit } = msg + // Belt and braces: every path that can leave a worker without an index + // — a cross-tab sync, an invalidate, a cached load — ends up here + // rather than silently answering nothing. + await ensureSearchIndex(currentProjectId) const filters = { modelId: searchModelId, locale: searchLocale, limit: limit ?? 10 } let results: Array<{ modelId: string, entryId: string, locale: string, score: number }> = [] @@ -236,6 +250,17 @@ channel.onmessage = (event: MessageEvent) => { } } +/** + * Build the index if this worker does not have one yet. + * + * Cheap when it already does, which is what lets every entry point call it + * without thinking about whether some other one already has. + */ +async function ensureSearchIndex(projectId: string | null) { + if (searchIndex || !projectId) return + await rebuildSearchIndex(projectId) +} + async function rebuildSearchIndex(projectId: string) { searchIndex = new FlexSearch.Document({ document: { diff --git a/tests/nuxt/brain-worker-search.nuxt.test.ts b/tests/nuxt/brain-worker-search.nuxt.test.ts new file mode 100644 index 0000000..5cdc1cd --- /dev/null +++ b/tests/nuxt/brain-worker-search.nuxt.test.ts @@ -0,0 +1,127 @@ +import 'fake-indexeddb/auto' +import { beforeAll, describe, expect, it, vi } from 'vitest' + +/** + * The brain worker's search, driven through its own message handler. + * + * The bug this exists for: a worker is created fresh on every page load and its + * FlexSearch index lives only in memory, while the content it is built from + * lives in IndexedDB and survives. The index was built in exactly one place — + * after the "nothing changed" early return in `sync` — so reloading a project + * nobody had edited left the worker with no index at all, and search answered + * nothing, forever, with no error to show for it. + * + * So the test loads the worker twice: once to populate IndexedDB the way a real + * session would, and again to be the reloaded worker that has never synced. + */ + +const PROJECT = 'project-1' + +const SYNC_PAYLOAD = { + treeSha: 'sha-1', + delta: false, + config: { locales: { default: 'en', supported: ['en'] } }, + models: { + articles: { id: 'articles', kind: 'collection' }, + authors: { id: 'authors', kind: 'collection' }, + }, + content: { + 'articles:en': { + data: { + a1: { title: 'Ship it on Friday', body: 'A note about the creator economy' }, + a2: { title: 'Something else entirely', body: 'Unrelated' }, + }, + meta: null, + kind: 'collection', + }, + 'authors:en': { + data: { u1: { name: 'Ahmet', bio: 'creator' } }, + meta: null, + kind: 'collection', + }, + }, + vocabulary: null, + contentContext: null, + contentSummary: null, + schemaValidation: null, +} + +let posted: Array> = [] +let handle: (msg: Record) => Promise + +/** Load a fresh copy of the worker module and return a way to talk to it. */ +async function bootWorker() { + vi.resetModules() + posted = [] + const scope = { postMessage: (m: Record) => posted.push(m), onmessage: null as unknown } + vi.stubGlobal('self', scope) + await import('../../app/workers/content-brain.worker') + const onmessage = (scope as { onmessage: (e: { data: unknown }) => Promise }).onmessage + return async (msg: Record) => { + posted.length = 0 + await onmessage({ data: msg }) + await new Promise(r => setTimeout(r, 0)) + } +} + +function lastOfType(type: string) { + return posted.filter(m => m.type === type).at(-1) +} + +beforeAll(async () => { + vi.stubGlobal('BroadcastChannel', class { + postMessage() {} + close() {} + }) + + // A previous session: a real sync that stores the content and builds an index. + const first = await bootWorker() + await first({ type: 'init', projectId: PROJECT }) + await first({ type: 'sync', projectId: PROJECT, payload: SYNC_PAYLOAD }) + + // The reload: a new worker, empty in memory, over the same IndexedDB. + handle = await bootWorker() +}) + +describe('brain worker search after a reload', () => { + it('builds the index even when the sync reports nothing changed', async () => { + await handle({ type: 'init', projectId: PROJECT }) + expect(lastOfType('ready')).toMatchObject({ cached: true }) + + // The server has nothing new — the case that used to skip index building. + await handle({ type: 'sync', projectId: PROJECT, payload: { delta: true, treeSha: 'sha-1' } }) + expect(lastOfType('synced')).toBeDefined() + + await handle({ type: 'search', id: 's1', query: 'creator', limit: 10 }) + + const results = lastOfType('searchResult')?.results as Array<{ entryId: string }> + expect(results.length).toBeGreaterThan(0) + expect(results.map(r => r.entryId)).toContain('a1') + }) + + it('scopes to one model without losing its matches to another', async () => { + // `authors:u1` also matches "creator". Filtering after the limit is what + // let another model's hits eat the caller's slots. + await handle({ type: 'search', id: 's2', query: 'creator', modelId: 'articles', limit: 10 }) + + const results = lastOfType('searchResult')?.results as Array<{ modelId: string, entryId: string }> + expect(results.map(r => r.entryId)).toEqual(['a1']) + expect(results.every(r => r.modelId === 'articles')).toBe(true) + }) + + it('returns nothing for a locale that holds nothing', async () => { + await handle({ type: 'search', id: 's3', query: 'creator', locale: 'tr', limit: 10 }) + + expect(lastOfType('searchResult')?.results).toEqual([]) + }) + + it('answers a search made before any sync at all', async () => { + // Nothing guarantees the order of `sync` and the first keystroke. + const fresh = await bootWorker() + await fresh({ type: 'init', projectId: PROJECT }) + await fresh({ type: 'search', id: 's4', query: 'creator', modelId: 'articles', limit: 10 }) + + const results = lastOfType('searchResult')?.results as unknown[] + expect(results.length).toBeGreaterThan(0) + }) +})