Files
windmill/frontend/src/lib/userScopedDb.test.ts
T
GuilhemandClaude Opus 4.8 aa26c4d9b2 feat: scope AI session storage per user, session list in IndexedDB (#9518)
* feat: scope AI session browser storage to the logged-in user

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor: address review nits in user-scoped session storage

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor: store AI session list in per-user IndexedDB via shared userScopedDb

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: cover legacy chat-history migration; address review nits

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: harden chat-DB writes and session-key cleanup per PR review

- route HistoryManager writes through whenReady() so they can't land in a
  previous user's DB after an in-place user switch (drop cached this.indexDB)
- dedup the legacy chat-DB claim to one session-wide promise so racing
  manager instances can't issue blocking concurrent deleteDB calls
- delete bare windmill_sessions keys unconditionally even when the user DB is
  already populated, closing a partial-migration leak window

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: trim package-lock to only the fake-indexeddb addition

npm install -D had also stripped "dev": true from ~34 unrelated optional
native-binding packages (npm graph recomputation). Restore main's lockfile and
graft in only the fake-indexeddb node, so the lock diff is exactly the intended
dev dependency and prod-install classification of those bindings is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 12:31:12 +02:00

108 lines
3.8 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from 'vitest'
import { IDBFactory } from 'fake-indexeddb'
import { openDB, type DBSchema, type IDBPDatabase } from 'idb'
// scopedKey resolves the email from userStore via a BROWSER-gated subscription.
vi.mock('esm-env', async (importOriginal) => ({
...(await importOriginal<typeof import('esm-env')>()),
BROWSER: true
}))
import { userStore, type UserExt } from '$lib/stores'
import { userScopedDb, type UserScopedDbMigrateDeps } from './userScopedDb'
interface TestSchema extends DBSchema {
items: { key: string; value: { id: string; v: number } }
}
function upgrade(db: IDBPDatabase<TestSchema>) {
if (!db.objectStoreNames.contains('items')) {
db.createObjectStore('items', { keyPath: 'id' })
}
}
function asUser(email: string): UserExt {
return { email, username: email.split('@')[0] } as unknown as UserExt
}
beforeEach(() => {
// Fresh in-memory IndexedDB per test for isolation.
;(globalThis as any).indexedDB = new IDBFactory()
userStore.set(undefined)
})
describe('userScopedDb', () => {
it('returns undefined while no user is logged in', async () => {
const dbh = userScopedDb<TestSchema>('t', { version: 1, upgrade })
expect(await dbh.whenReady()).toBeUndefined()
})
it('isolates data between users and restores it on return', async () => {
const dbh = userScopedDb<TestSchema>('t', { version: 1, upgrade })
userStore.set(asUser('a@x.com'))
const dbA = await dbh.whenReady()
await dbA!.put('items', { id: 'i1', v: 1 })
// Switch user: whenReady reopens the other user's (empty) DB — A's record
// is not visible.
userStore.set(asUser('b@y.com'))
const dbB = await dbh.whenReady()
expect(await dbB!.count('items')).toBe(0)
await dbB!.put('items', { id: 'i2', v: 2 })
// Back to A: their record is intact, B's is not present.
userStore.set(asUser('a@x.com'))
const dbA2 = await dbh.whenReady()
expect((await dbA2!.getAll('items')).map((x) => x.id)).toEqual(['i1'])
})
it('runs migrate once per scoped name and claims+deletes the legacy DB', async () => {
// Seed a legacy (un-namespaced) DB, mirroring the chat-history pattern.
const legacy = await openDB<TestSchema>('t', 1, { upgrade })
await legacy.put('items', { id: 'legacy1', v: 9 })
legacy.close()
const migrate = vi.fn(async (db: IDBPDatabase<TestSchema>, deps: UserScopedDbMigrateDeps) => {
if ((await db.count('items')) > 0) return
const src = await deps.openDB<TestSchema>('t', 1, { upgrade })
const all = await src.getAll('items')
const tx = db.transaction('items', 'readwrite')
await Promise.all([...all.map((x) => tx.store.put(x)), tx.done])
src.close()
await deps.deleteDB('t')
})
const dbh = userScopedDb<TestSchema>('t', { version: 1, upgrade, migrate })
userStore.set(asUser('a@x.com'))
const db = await dbh.whenReady()
expect((await db!.getAll('items')).map((x) => x.id)).toEqual(['legacy1'])
// Legacy bare DB was deleted.
const names = (await indexedDB.databases()).map((d) => d.name)
expect(names).not.toContain('t')
expect(names).toContain('t::a@x.com')
// migrate is gated to once per scoped name even across repeated whenReady.
await dbh.whenReady()
expect(migrate).toHaveBeenCalledTimes(1)
})
it('degrades to undefined (no throw) when the DB cannot be opened', async () => {
const failingOpen = vi.fn(async () => {
throw new Error('blocked')
}) as unknown as typeof openDB
const dbh = userScopedDb<TestSchema>('t', { version: 1, upgrade, openDB: failingOpen })
userStore.set(asUser('a@x.com'))
expect(await dbh.whenReady()).toBeUndefined()
})
it('clears the handle on logout', async () => {
const dbh = userScopedDb<TestSchema>('t', { version: 1, upgrade })
userStore.set(asUser('a@x.com'))
expect(await dbh.whenReady()).toBeDefined()
userStore.set(undefined)
expect(await dbh.whenReady()).toBeUndefined()
})
})