From a2eb05370159d1f25460f465ed0b89c554ec20cc Mon Sep 17 00:00:00 2001 From: Contentrain Date: Fri, 14 Aug 2026 13:11:32 +0300 Subject: [PATCH] fix(brain): give search an index to search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported: the content search always says no results. No error in the console — the worker receives the message and answers, with an empty array. Two causes, both of which had to be fixed. ## The content store was never created `idb-keyval`'s `createStore` opens the database **without a version** and creates only its own object store in the upgrade. Called twice for one database — `brain-meta` and `brain-content` — only the first store ever exists: by the time the second opens, the database is already at version 1, no upgrade fires, and every transaction against it throws `NotFoundError`. `brain-meta` is touched first, on `init`. So `brain-content` was never created, and the IndexedDB content cache has never worked. The app did not visibly suffer because the sync response is also kept in an in-memory map, which is what `queryContent` reads. The search index is the one consumer that reads from IndexedDB — so it was built over zero keys, every time. Both stores are now created in one upgrade, at an explicit version. Version 2 rather than 1 on purpose: a browser that has already visited Studio holds `cr-brain` at version 1 with only `brain-meta`, and opening at 1 would find it current and fire no upgrade at all. With a version comes upgrade blocking, so: `onversionchange` closes the connection instead of blocking another tab's upgrade, `onblocked` rejects instead of leaving reads pending forever, and a failed open is not cached. ## The index was built in exactly one place `rebuildSearchIndex` ran only after the "nothing changed" early return in `sync`. 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. It is now ensured in the delta branch and, belt and braces, before any search: every path that can leave a worker without an index ends up at one of the two rather than silently answering nothing. Neither of these was introduced by #193. Nothing called `searchContent` before it, so nothing noticed. The test drives the worker's own message handler over fake-indexeddb, loading it twice — once to populate the database as a real session would, once as the reloaded worker that has never synced. It fails against either bug alone. --- app/workers/brain-idb-store.ts | 84 +++++++++++++ app/workers/content-brain.worker.ts | 37 +++++- tests/nuxt/brain-worker-search.nuxt.test.ts | 127 ++++++++++++++++++++ 3 files changed, 242 insertions(+), 6 deletions(-) create mode 100644 app/workers/brain-idb-store.ts create mode 100644 tests/nuxt/brain-worker-search.nuxt.test.ts 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) + }) +})