mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-13 08:05:23 +00:00
* 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>
108 lines
3.8 KiB
TypeScript
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()
|
|
})
|
|
})
|