Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions app/workers/brain-idb-store.ts
Original file line number Diff line number Diff line change
@@ -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<const N extends readonly string[]>(
dbName: string,
storeNames: N,
): Record<N[number], UseStore> {
let dbp: Promise<IDBDatabase> | undefined

const getDB = () => {
if (dbp) return dbp

dbp = new Promise<IDBDatabase>((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<string, UseStore>
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<N[number], UseStore>
}
37 changes: 31 additions & 6 deletions app/workers/content-brain.worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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 }> = []

Expand Down Expand Up @@ -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: {
Expand Down
127 changes: 127 additions & 0 deletions tests/nuxt/brain-worker-search.nuxt.test.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>> = []
let handle: (msg: Record<string, unknown>) => Promise<void>

/** 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<string, unknown>) => 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<void> }).onmessage
return async (msg: Record<string, unknown>) => {
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)
})
})
Loading