test(web): fake-indexeddb harness for the localIndex persistence layer (PLAN-2636 unit 1) (#1156)

The localIndex IndexedDB persistence layer ran as a no-op under vitest —
there is no IndexedDB in the node test environment, so `isSupported()`
returned false and every persist/hydrate call short-circuited. The entire
layer, and BUG-2609's seq-guard fix, shipped on live-browser evidence runs
only (the #1148 finding). This adds the harness that makes it testable, the
prerequisite for unit 2's order-and-merge regression matrix.

- fake-indexeddb dev dependency (exact-pinned).
- A dedicated `idb` vitest project (glob `*.idb.test.ts`, node env) whose
  setup installs fake-indexeddb's globals and a fresh IDBFactory per test.
  It self-disables when the dep can't be resolved — mirroring the jsdom
  project — so a symlinked worktree without it keeps `npm run test` green,
  and CI activates it once installed. The idb glob is excluded from the node
  project so the persistence layer can't no-op there and pass vacuously.
- Harness helpers unit 2 builds on: a second cross-tab connection to the
  same database (2635), a v1-database seed + higher-format-version reopen +
  downgrade VersionError (the v1→v2 migration exercise), a fresh-module
  loader that clears the connection cache, and raw ground-truth reads.
  `harnessDbName` mirrors the module's `dbName` exactly, pinned by a test so
  a drift can't make assertions read an empty sibling database.
- BUG-2609's evidence run is ported as a deterministic sequential regression:
  a newer delta commits its atomic rows+cursor transaction, then a stale
  older-seq snapshot lands last and is refused (IDB serializes overlapping
  transactions, so no interleaving control is needed). Plus a raw
  serialization characterization pinning that platform guarantee.

No production code changes.

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V
This commit is contained in:
xarmian
2026-08-18 15:20:13 -04:00
committed by GitHub
parent 7680919dbd
commit b759898d5b
7 changed files with 480 additions and 2 deletions
+11
View File
@@ -50,6 +50,7 @@
"@testing-library/svelte": "^5.2.8",
"@types/d3-scale": "4.0.9",
"@types/qrcode": "^1.5.6",
"fake-indexeddb": "6.2.5",
"jsdom": "^26.1.0",
"marked": "^18.0.9",
"svelte": "^5.56.8",
@@ -2886,6 +2887,16 @@
"node": ">=12.0.0"
}
},
"node_modules/fake-indexeddb": {
"version": "6.2.5",
"resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz",
"integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+1
View File
@@ -28,6 +28,7 @@
"@testing-library/svelte": "^5.2.8",
"@types/d3-scale": "4.0.9",
"@types/qrcode": "^1.5.6",
"fake-indexeddb": "6.2.5",
"jsdom": "^26.1.0",
"marked": "^18.0.9",
"svelte": "^5.56.8",
@@ -0,0 +1,143 @@
import { describe, it, expect } from 'vitest';
import type { ItemIndexRow } from '$lib/types';
import { loadPersistence, rawItem, openSecondConnection, harnessDbName } from '../../test/idbHarness';
/**
* PLAN-2636 unit 1 — end-to-end IDB coverage for the localIndex persistence
* layer, which ran as a no-op under vitest until this project existed (no
* IndexedDB in the node env — Wren's #1148 finding). The sibling
* `localIndexPersistence.test.ts` pins the PURE decision (`shouldWriteRow`)
* and explicitly says it does NOT cover the interleaving outcome. This file
* covers exactly that outcome through a real (fake-indexeddb) database.
*
* The ruling on the PLAN-2636 trail: the family's races are SNAPSHOT-
* staleness, not read-staleness — the stale thing is the RAM snapshot handed
* to persistUpserts, never its `get`, which reads committed state. IDB
* serializes overlapping readwrite transactions, so the race BUG-2609 fixed
* reproduces with plain SEQUENTIAL calls: a newer delta commits its atomic
* rows+cursor transaction, then the older fire-and-forget snapshot lands last.
* No interleaving hook — none is needed, and an awaited barrier between a get
* and a put would fail on a real browser (TransactionInactiveError) anyway.
*/
function row(id: string, seq: number | undefined, extra: Record<string, unknown> = {}): ItemIndexRow {
return { id, seq, ...extra } as unknown as ItemIndexRow;
}
describe('BUG-2609 end-to-end: stale snapshot does not clobber a newer persisted row', () => {
it('a delayed older-seq upsert landing after a newer delta is refused, and hydrate agrees', async () => {
const { persistDelta, persistUpserts, hydrate } = await loadPersistence();
const U = null;
const WS = 'ws-2609';
// 1) The SSE delta lands first — newer-seq row + cursor advance, atomic.
await persistDelta(U, WS, [row('x', 7)], 'cursor-7', false);
// 2) The stale RAM snapshot (older seq) lands LAST — the fire-and-forget
// upsert that BUG-2609's evidence run captured mid-flight.
await persistUpserts(U, WS, [row('x', 5)]);
// The seq guard refused the stale write: IDB still holds seq 7...
expect((await rawItem(U, WS, 'x'))?.seq).toBe(7);
// ...and a warm hydrate reflects that, with the cursor still past the delta.
const h = await hydrate(U, WS);
expect(h.items.find((i) => i.id === 'x')?.seq).toBe(7);
expect(h.cursor).toBe('cursor-7');
});
it('a seq-less optimistic snapshot does not overwrite a stamped persisted row', async () => {
// The asymmetry leg, end-to-end: the optimistic-reorder path clears seq
// (TASK-1357); persisting it must not strand the cache with an
// unorderable row while the cursor sits past the correcting delta.
const { persistDelta, persistUpserts, hydrate } = await loadPersistence();
const U = null;
const WS = 'ws-2609-asym';
await persistDelta(U, WS, [row('x', 7)], 'cursor-7', false);
await persistUpserts(U, WS, [row('x', undefined)]); // seq-less snapshot lands last
expect((await rawItem(U, WS, 'x'))?.seq).toBe(7);
expect((await hydrate(U, WS)).items.find((i) => i.id === 'x')?.seq).toBe(7);
});
it('a genuinely newer upsert still wins (the guard refuses only STALE writes)', async () => {
const { persistUpserts, hydrate } = await loadPersistence();
const U = null;
const WS = 'ws-2609-fwd';
await persistUpserts(U, WS, [row('x', 5)]);
await persistUpserts(U, WS, [row('x', 8)]); // newer — must land
expect((await rawItem(U, WS, 'x'))?.seq).toBe(8);
expect((await hydrate(U, WS)).items.find((i) => i.id === 'x')?.seq).toBe(8);
});
});
describe('IDB transaction serialization (platform contract this layer leans on)', () => {
// Optional characterization the lead invited: the persistence write path's
// correctness rests on IDB serializing overlapping readwrite transactions,
// so the get inside persistUpserts always reads committed state. This pins
// the guarantee at the raw-store level — a fake-indexeddb regression that
// broke it would surface here rather than as a mysterious flake upstream.
it('two overlapping readwrite transactions on the same store do not interleave', async () => {
const db = await openSecondConnection(null, 'ws-serialize');
try {
const order: string[] = [];
// Tx A: read, then (after a microtask) write — started first.
const txA = db.transaction('items', 'readwrite');
const pA = (async () => {
await txA.objectStore('items').get('k');
order.push('A:read');
txA.objectStore('items').put({ id: 'k', seq: 1, who: 'A' });
order.push('A:write');
await txA.done;
order.push('A:done');
})();
// Tx B: started while A is open; must not commit before A finishes.
const txB = db.transaction('items', 'readwrite');
const pB = (async () => {
const seen = (await txB.objectStore('items').get('k')) as { who?: string } | undefined;
order.push(`B:read(${seen?.who ?? 'none'})`);
txB.objectStore('items').put({ id: 'k', seq: 2, who: 'B' });
await txB.done;
order.push('B:done');
})();
await Promise.all([pA, pB]);
// A's transaction commits entirely before B's read runs — B sees A's
// committed write, never an interleaved half-state. (The exact order
// of the two read/write log lines within A is not asserted; the
// contract is that B:read observes A's value and B:done follows
// A:done.)
expect(order.indexOf('A:done')).toBeLessThan(order.indexOf('B:done'));
expect(order).toContain('B:read(A)');
const final = (await rawViaDb(db)) ?? {};
expect((final as { who?: string }).who).toBe('B');
} finally {
db.close();
}
});
});
async function rawViaDb(db: Awaited<ReturnType<typeof openSecondConnection>>): Promise<unknown> {
return db.get('items', 'k');
}
describe('database naming', () => {
it('the harness addresses the exact database the module writes to', async () => {
// Guards the vacuous-test failure mode: if harnessDbName drifted from
// the module's dbName, rawItem would read an empty sibling DB and every
// assertion above would pass for the wrong reason. Persist through the
// module, then read the raw store under the harness name directly.
const { persistUpserts } = await loadPersistence();
await persistUpserts('user-42', 'My WS', [row('y', 1)]);
const dbNameShouldBe = harnessDbName('user-42', 'My WS');
expect(dbNameShouldBe).toBe(
`pad-local-index-${encodeURIComponent('user-42')}-${encodeURIComponent('My WS')}`,
);
expect((await rawItem('user-42', 'My WS', 'y'))?.seq).toBe(1);
});
});
+92
View File
@@ -0,0 +1,92 @@
import { describe, it, expect } from 'vitest';
import type { ItemIndexRow } from '$lib/types';
import {
openSecondConnection,
seedV1Database,
openWithMigration,
versionErrorOnDowngradeOpen,
rawItem,
} from './idbHarness';
/**
* PLAN-2636 unit 1 — capability pins for the two things the lead named as
* blocking a harness shape (unit-2 test matrix prerequisites). These are
* living characterizations, not just a one-off spike: if a fake-indexeddb
* bump ever regressed either capability, unit 2's matrix would fail
* mysteriously; here it fails at the harness with a clear message.
*/
function row(id: string, seq: number): ItemIndexRow {
return { id, seq } as unknown as ItemIndexRow;
}
describe('cross-tab: two connections share one in-memory database (2635 prerequisite)', () => {
it('a write through one connection is visible through a second connection', async () => {
const U = null;
const WS = 'ws-crosstab';
const tabA = await openSecondConnection(U, WS);
const tabB = await openSecondConnection(U, WS);
try {
await tabA.put('items', row('shared', 1));
// tabB is a distinct handle; it must see tabA's committed write, the
// way two browser tabs on one origin share the database.
expect(((await tabB.get('items', 'shared')) as ItemIndexRow | undefined)?.seq).toBe(1);
await tabB.put('items', row('shared', 2));
expect(((await tabA.get('items', 'shared')) as ItemIndexRow | undefined)?.seq).toBe(2);
} finally {
tabA.close();
tabB.close();
}
});
});
describe('format-version migration (unit-2 v1→v2 prerequisite)', () => {
it('a v1 database reopens under a higher format version with items retained + new store created', async () => {
const U = null;
const WS = 'ws-migrate';
await seedV1Database(U, WS, [row('keep', 3)], {
cursor: 'c1',
schemaVersion: 3,
includesUnparentedMetadata: true,
});
const { oldVersion, db } = await openWithMigration(U, WS, 2, 'tombstones');
try {
// The upgrade actually ran from v1 (not a fresh create).
expect(oldVersion).toBe(1);
// Pre-existing items survived the format bump.
expect(((await db.get('items', 'keep')) as ItemIndexRow | undefined)?.seq).toBe(3);
// The new store exists.
expect(db.objectStoreNames.contains('tombstones')).toBe(true);
// And the old meta row is intact.
expect((await db.get('meta', 'sync')) as { cursor?: string }).toMatchObject({ cursor: 'c1' });
} finally {
db.close();
}
});
it('an OLD build opening a since-upgraded database gets a VersionError (fail-safe degrade)', async () => {
const U = null;
const WS = 'ws-downgrade';
await seedV1Database(U, WS, [row('x', 1)]);
// Newer build upgrades it to v2.
const { db } = await openWithMigration(U, WS, 2, 'tombstones');
db.close();
// The old build still calls openDB(name, 1) → VersionError, which
// localIndexPersistence.open() catches and returns null for, degrading
// that tab to memory-only. Transient and acceptable during a deploy.
const errName = await versionErrorOnDowngradeOpen(U, WS, 1);
expect(errName).toBe('VersionError');
});
});
describe('per-test isolation', () => {
it('databases do not leak across tests (fresh IDBFactory per test)', async () => {
// If setup-idb's per-test factory reset regressed, a row written by an
// earlier test in this file would still be readable here. It must not be.
expect(await rawItem(null, 'ws-crosstab', 'shared')).toBeUndefined();
expect(await rawItem(null, 'ws-migrate', 'keep')).toBeUndefined();
});
});
+173
View File
@@ -0,0 +1,173 @@
// idb test harness (PLAN-2636 unit 1). Helpers the localIndex persistence
// regression tests build on. All of these run under the `idb` vitest project,
// whose setup (`setup-idb.ts`) installs a fresh fake-indexeddb per test.
//
// Design note (lead ruling on the PLAN-2636 trail): the localIndex cache
// races are SNAPSHOT-staleness, not read-staleness — the stale thing is the
// RAM snapshot handed to persistUpserts, never its own `get`, which always
// reads committed state. IDB serializes overlapping readwrite transactions,
// so those races reproduce with plain SEQUENTIAL calls; there is deliberately
// NO interleaving hook, and none is needed. (An awaited barrier between a
// get and a put would also fail on a real browser with
// TransactionInactiveError — a fake-indexeddb-only seam pinning behavior
// production can't have.) These helpers therefore give cross-tab CONNECTIONS
// and version-MIGRATION control, not scheduling control.
import { openDB, type IDBPDatabase } from 'idb';
import { vi } from 'vitest';
import type { ItemIndexRow } from '$lib/types';
/**
* Mirrors localIndexPersistence.dbName EXACTLY so the harness addresses the
* same database the module writes to — `pad-local-index-{ns}-{ws}`, where the
* user namespace and workspace are `encodeURIComponent`-encoded and a null
* user is the literal `anon` namespace. A drift here would silently point the
* raw readers / second connection at a different database and make every
* assertion vacuous, so it is kept byte-identical to the source.
*/
export function harnessDbName(userId: string | null, ws: string): string {
const ns = userId ? encodeURIComponent(userId) : 'anon';
return `pad-local-index-${ns}-${encodeURIComponent(ws)}`;
}
/**
* Load a FRESH instance of the persistence module — `vi.resetModules()`
* clears its module-level connection cache (`dbs`) so a previous test's
* cached handle (pointing at the prior test's now-discarded IDBFactory)
* can't leak in. Pair with the per-test fresh factory in setup-idb.ts for
* full isolation. Returns the module's public surface.
*/
export async function loadPersistence(): Promise<
typeof import('$lib/stores/localIndexPersistence')
> {
vi.resetModules();
return import('$lib/stores/localIndexPersistence');
}
/**
* A SECOND raw connection to the same (user, workspace) database — the
* "other tab". Used by the 2635 cross-tab pins: one connection persists an
* unmerged snapshot while another holds the merged row. fake-indexeddb backs
* both handles with one in-memory store within a single test context
* (verified in the unit-1 spike), so writes through one are visible to the
* other, exactly like two browser tabs.
*
* Opens at IDB format version 1 with the same stores localIndexPersistence
* creates, so it never triggers an unexpected upgrade.
*/
export async function openSecondConnection(
userId: string | null,
ws: string,
): Promise<IDBPDatabase> {
return openDB(harnessDbName(userId, ws), 1, {
upgrade(db) {
if (!db.objectStoreNames.contains('items')) {
db.createObjectStore('items', { keyPath: 'id' });
}
if (!db.objectStoreNames.contains('meta')) {
db.createObjectStore('meta', { keyPath: 'key' });
}
},
});
}
/**
* Create a v1-SHAPED database (the pre-unit-2 layout: `items` + `meta`, IDB
* format version 1) and seed it, so unit 2's v1→v2 migration exercise can
* reopen it under the new upgrade path and assert the old data survives. The
* connection is closed before returning so the migration open isn't blocked.
*/
export async function seedV1Database(
userId: string | null,
ws: string,
items: ItemIndexRow[] = [],
meta?: { cursor: string; schemaVersion: number; includesUnparentedMetadata: boolean },
): Promise<void> {
const db = await openDB(harnessDbName(userId, ws), 1, {
upgrade(db) {
db.createObjectStore('items', { keyPath: 'id' });
db.createObjectStore('meta', { keyPath: 'key' });
},
});
try {
const tx = db.transaction(['items', 'meta'], 'readwrite');
for (const row of items) tx.objectStore('items').put(row);
if (meta) {
tx.objectStore('meta').put({ key: 'sync', ...meta });
}
await tx.done;
} finally {
db.close();
}
}
/**
* Read the raw `items` store for a database, bypassing localIndexPersistence
* entirely — the ground-truth assertion for what is actually persisted.
*/
export async function rawItems(userId: string | null, ws: string): Promise<ItemIndexRow[]> {
const db = await openSecondConnection(userId, ws);
try {
return (await db.getAll('items')) as ItemIndexRow[];
} finally {
db.close();
}
}
/** Raw read of a single persisted item by id (or undefined). */
export async function rawItem(
userId: string | null,
ws: string,
id: string,
): Promise<ItemIndexRow | undefined> {
const db = await openSecondConnection(userId, ws);
try {
return (await db.get('items', id)) as ItemIndexRow | undefined;
} finally {
db.close();
}
}
/**
* Open a database at a HIGHER IDB format version to create a new object
* store, mirroring what unit 2's v1→v2 bump will do, and assert the pre-
* existing data survived the upgrade. Returns the observed `oldVersion` so a
* test can pin that the upgrade actually ran (vs a clean create).
*/
export async function openWithMigration(
userId: string | null,
ws: string,
toVersion: number,
newStore: string,
): Promise<{ oldVersion: number; db: IDBPDatabase }> {
let oldVersion = -1;
const db = await openDB(harnessDbName(userId, ws), toVersion, {
upgrade(db, from) {
oldVersion = from;
if (!db.objectStoreNames.contains(newStore)) {
db.createObjectStore(newStore, { keyPath: 'id' });
}
},
});
return { oldVersion, db };
}
/**
* Assert the fail-safe degrade: an OLD build opening a database at format
* version `oldVersion` after a newer build already upgraded it to a higher
* version gets a VersionError (which localIndexPersistence catches → returns
* null → that tab degrades to memory-only). Returns the caught error name.
*/
export async function versionErrorOnDowngradeOpen(
userId: string | null,
ws: string,
oldVersion: number,
): Promise<string | null> {
try {
const db = await openDB(harnessDbName(userId, ws), oldVersion);
db.close();
return null;
} catch (e) {
return (e as Error)?.name ?? String(e);
}
}
+26
View File
@@ -0,0 +1,26 @@
// Setup for the `idb` vitest project (PLAN-2636 unit 1). Installs
// fake-indexeddb's in-memory IndexedDB as the global `indexedDB` (and the full
// set of `IDB*` interface globals — `IDBRequest`, `IDBKeyRange`, `IDBDatabase`
// … — which the `idb` wrapper references) so `localIndexPersistence` — which
// no-ops whenever `typeof indexedDB === 'undefined'` — actually exercises its
// write/read path under test. Before this project existed the entire
// persistence layer shipped on live-browser evidence runs only (Wren's #1148
// finding).
//
// `fake-indexeddb/auto` installs every IDB global once at import. A FRESH
// `IDBFactory` per test is the isolation boundary: every test starts with zero
// databases, so no cross-test state leaks through the shared in-memory store.
// The interface classes are stateless and stay put; only the factory (which
// owns the databases) is swapped. Tests that also need localIndexPersistence's
// own module-level connection cache reset pair this with `loadPersistence()`
// (vi.resetModules) from the idb harness.
import 'fake-indexeddb/auto';
import { IDBFactory } from 'fake-indexeddb';
import { beforeEach } from 'vitest';
beforeEach(() => {
// A brand-new factory drops every database created by the previous test.
// The IDB* interface globals installed by `/auto` above are unaffected.
globalThis.indexedDB = new IDBFactory();
});
+34 -2
View File
@@ -69,7 +69,19 @@ const browserTestDepsInstalled =
canResolve('@testing-library/jest-dom') &&
canResolve('@sveltejs/vite-plugin-svelte');
// The `idb` project needs fake-indexeddb (PLAN-2636 unit 1). Like the jsdom
// deps it's declared in package.json but absent in a symlinked worktree until
// npm install runs; when it can't be resolved we register only the other
// projects so `npm run test` stays green, and the idb suite activates
// automatically once the dep is present (mirrors the jsdom self-disable).
const idbTestDepInstalled = canResolve('fake-indexeddb');
const BROWSER_TEST_GLOB = 'src/**/*.svelte.test.ts';
// IDB-backed persistence tests. The `.idb.test.ts` suffix routes them to the
// dedicated `idb` project; they must be excluded from the node project (which
// has no indexedDB — the persistence layer would silently no-op there and the
// test would pass vacuously) the same way the svelte glob is.
const IDB_TEST_GLOB = 'src/**/*.idb.test.ts';
const nodeProject = {
resolve: { alias: { $lib } },
@@ -77,14 +89,34 @@ const nodeProject = {
name: 'node',
environment: 'node',
include: ['src/**/*.test.ts'],
// The jsdom project owns these; they'd blow up in the plain node env.
exclude: [BROWSER_TEST_GLOB],
// The jsdom / idb projects own these; they'd blow up or no-op in the
// plain node env.
exclude: [BROWSER_TEST_GLOB, IDB_TEST_GLOB],
},
};
const idbProject = {
resolve: { alias: { $lib } },
server: nodeModulesRealPath
? { fs: { allow: [projectRoot, nodeModulesRealPath] } }
: undefined,
test: {
name: 'idb',
// fake-indexeddb runs in plain node — no DOM needed. Its setup installs
// a fresh in-memory IndexedDB per test.
environment: 'node',
include: [IDB_TEST_GLOB],
setupFiles: ['./src/test/setup-idb.ts'],
},
};
export default defineConfig(async () => {
const projects: Record<string, unknown>[] = [nodeProject];
if (idbTestDepInstalled) {
projects.push(idbProject);
}
if (browserTestDepsInstalled) {
// Dynamic import so a missing plugin can never crash config loading.
const { svelte } = await import('@sveltejs/vite-plugin-svelte');