The logs page white-screened when deployed against Firestore. LogsView
paginates with the neutral offset() constraint, which the server/sqlite
adapter pushes into SQL — but the firebase adapter's subscribeCollection
had no case for it and passed the raw {__type:'offset'} object through
to the SDK's query(), which throws on non-QueryConstraint values. The
throw fired inside useFirestoreCollection's effect on first render,
unmounting the whole tree. Dev never hit it because dev runs the server
adapter.
Fix: shared toSdkConstraints() translator in the firebase adapter,
used by getCollection and subscribeCollection. The client SDK has no
offset, so emulate it — widen limit to offset+limit and slice the
leading docs off the snapshot (same read cost as the admin SDK's
native offset, which also bills skipped docs). Unknown constraint
types are now dropped instead of passed through, so a future neutral
builder can't reintroduce the crash. This also fixes getCollection
silently skipping offset, which returned page 1 for every page.
Server/sqlite adapter unchanged; offset still runs in SQL. Two new
shared contract tests (offset pagination + offset past end) run
against both adapters and pass: firebase mock 33, live sqlite 40.
Also bump better-sqlite3 ^11.3.0 -> ^12.0.0: v11 fails to compile
against Node 26's V8 (GetPrototype removed), so server tests could
not run at all on Node 26. v12.11.1 builds clean; full server suite
passes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
356 lines
15 KiB
JavaScript
356 lines
15 KiB
JavaScript
// Storage interface contract.
|
|
// This is the SPEC. Runs against any storage impl (memory, ws, firebase).
|
|
// TDD: written first (RED), impl built to satisfy (GREEN).
|
|
//
|
|
// Usage:
|
|
// const { runStorageContract } = require('./contract.test');
|
|
// runStorageContract('memory', () => createMemoryStorage());
|
|
|
|
// Each impl factory returns a fresh storage instance (async-creatable is fine).
|
|
// Interface every impl MUST provide:
|
|
// getDoc(path) -> Promise<obj|null>
|
|
// setDoc(path, data) -> Promise<void> (replace)
|
|
// updateDoc(path, patch) -> Promise<void> (shallow merge)
|
|
// deleteDoc(path) -> Promise<void>
|
|
// addDoc(collectionPath, data) -> Promise<{id, path}> (auto-gen id)
|
|
// getCollection(path) -> Promise<arr> (immediate child docs)
|
|
// batchWrite(ops) -> Promise<void> ops: [{type, path, data?}]
|
|
// undo({logPath, undo, redo}) -> Promise<void> tx: apply updates + flip undone
|
|
// subscribeDoc(path, cb) -> unsubscribe fn cb(doc|null)
|
|
// subscribeCollection(path, cb) -> unsubscribe fn cb(arr)
|
|
|
|
function runStorageContract(name, factory) {
|
|
describe(`storage contract: ${name}`, () => {
|
|
let storage;
|
|
beforeEach(async () => { storage = await factory(); });
|
|
afterEach(async () => { if (storage && storage.dispose) await storage.dispose(); });
|
|
|
|
describe('getDoc / setDoc', () => {
|
|
test('setDoc then getDoc returns the doc (with id)', async () => {
|
|
await storage.setDoc('campaigns/a', { name: 'Alpha' });
|
|
const doc = await storage.getDoc('campaigns/a');
|
|
expect(doc).toEqual({ id: 'a', name: 'Alpha' });
|
|
});
|
|
|
|
test('getDoc on missing path returns null', async () => {
|
|
const doc = await storage.getDoc('campaigns/missing');
|
|
expect(doc).toBeNull();
|
|
});
|
|
|
|
test('setDoc overwrites entirely (not merge)', async () => {
|
|
await storage.setDoc('campaigns/a', { name: 'Alpha', players: [] });
|
|
await storage.setDoc('campaigns/a', { name: 'Beta' });
|
|
const doc = await storage.getDoc('campaigns/a');
|
|
expect(doc).toEqual({ id: 'a', name: 'Beta' });
|
|
});
|
|
|
|
// main L1624: setDoc(path, data, {merge:true}) — merge flag.
|
|
test('setDoc with {merge:true} merges into existing doc', async () => {
|
|
await storage.setDoc('campaigns/a', { name: 'Alpha', players: [] });
|
|
await storage.setDoc('campaigns/a', { players: [1] }, { merge: true });
|
|
const doc = await storage.getDoc('campaigns/a');
|
|
expect(doc).toEqual({ id: 'a', name: 'Alpha', players: [1] });
|
|
});
|
|
});
|
|
|
|
describe('updateDoc (shallow merge)', () => {
|
|
test('merges patch into existing doc', async () => {
|
|
await storage.setDoc('campaigns/a', { name: 'Alpha', players: [1] });
|
|
await storage.updateDoc('campaigns/a', { players: [1, 2] });
|
|
const doc = await storage.getDoc('campaigns/a');
|
|
expect(doc).toEqual({ id: 'a', name: 'Alpha', players: [1, 2] });
|
|
});
|
|
|
|
test('updateDoc on missing doc creates it', async () => {
|
|
await storage.updateDoc('campaigns/a', { name: 'Alpha' });
|
|
const doc = await storage.getDoc('campaigns/a');
|
|
expect(doc).toEqual({ id: 'a', name: 'Alpha' });
|
|
});
|
|
});
|
|
|
|
describe('deleteDoc', () => {
|
|
test('removes doc', async () => {
|
|
await storage.setDoc('campaigns/a', { name: 'Alpha' });
|
|
await storage.deleteDoc('campaigns/a');
|
|
expect(await storage.getDoc('campaigns/a')).toBeNull();
|
|
});
|
|
|
|
test('delete missing doc is no-op (no throw)', async () => {
|
|
await expect(storage.deleteDoc('campaigns/none')).resolves.toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('addDoc', () => {
|
|
test('auto-generates id and stores doc at collection/id', async () => {
|
|
const { id, path } = await storage.addDoc('campaigns/a/encounters', { name: 'E1' });
|
|
expect(id).toBeTruthy();
|
|
expect(path).toBe(`campaigns/a/encounters/${id}`);
|
|
const doc = await storage.getDoc(path);
|
|
expect(doc).toEqual({ id, name: 'E1' });
|
|
});
|
|
|
|
test('two addDocs produce distinct ids', async () => {
|
|
const r1 = await storage.addDoc('logs', { m: 'one' });
|
|
const r2 = await storage.addDoc('logs', { m: 'two' });
|
|
expect(r1.id).not.toBe(r2.id);
|
|
});
|
|
});
|
|
|
|
describe('firebase-prefixed path identity', () => {
|
|
// App passes firebase-prefixed paths (artifacts/{APP_ID}/public/data/...).
|
|
// main prod truth: ALWAYS prefixed. Write+read same prefixed round-trips.
|
|
// Cross bare<->prefixed lookup is NOT required by main (App never does it).
|
|
// Kept as same-prefix roundtrip only — matches prod.
|
|
const PREFIX = 'artifacts/test-app/public/data';
|
|
|
|
test('setDoc prefixed then getDoc same prefixed path returns it (id)', async () => {
|
|
await storage.setDoc(`${PREFIX}/campaigns/c2`, { name: 'P2' });
|
|
const doc = await storage.getDoc(`${PREFIX}/campaigns/c2`);
|
|
expect(doc).toEqual({ id: 'c2', name: 'P2' });
|
|
});
|
|
});
|
|
|
|
describe('getCollection', () => {
|
|
test('returns immediate child docs only (not nested)', async () => {
|
|
await storage.setDoc('campaigns/a', { name: 'A' });
|
|
await storage.setDoc('campaigns/b', { name: 'B' });
|
|
await storage.setDoc('campaigns/a/encounters/e1', { name: 'E1' });
|
|
const docs = await storage.getCollection('campaigns');
|
|
expect(docs).toHaveLength(2);
|
|
const names = docs.map(d => d.name).sort();
|
|
expect(names).toEqual(['A', 'B']);
|
|
});
|
|
|
|
test('empty collection returns []', async () => {
|
|
const docs = await storage.getCollection('campaigns');
|
|
expect(docs).toEqual([]);
|
|
});
|
|
|
|
test('subcollection returns only its direct children', async () => {
|
|
await storage.setDoc('campaigns/a/encounters/e1', { name: 'E1' });
|
|
await storage.setDoc('campaigns/a/encounters/e2', { name: 'E2' });
|
|
await storage.setDoc('campaigns/a/encounters/e1/participants/p1', { name: 'P1' });
|
|
const docs = await storage.getCollection('campaigns/a/encounters');
|
|
expect(docs).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
describe('batchWrite', () => {
|
|
test('applies multiple deletes atomically', async () => {
|
|
await storage.setDoc('campaigns/a', { name: 'A' });
|
|
await storage.setDoc('campaigns/b', { name: 'B' });
|
|
await storage.batchWrite([
|
|
{ type: 'delete', path: 'campaigns/a' },
|
|
{ type: 'delete', path: 'campaigns/b' },
|
|
]);
|
|
expect(await storage.getDoc('campaigns/a')).toBeNull();
|
|
expect(await storage.getDoc('campaigns/b')).toBeNull();
|
|
});
|
|
|
|
test('applies set + delete mixed', async () => {
|
|
await storage.setDoc('campaigns/a', { name: 'A' });
|
|
await storage.batchWrite([
|
|
{ type: 'set', path: 'campaigns/b', data: { name: 'B' } },
|
|
{ type: 'delete', path: 'campaigns/a' },
|
|
]);
|
|
expect(await storage.getDoc('campaigns/a')).toBeNull();
|
|
expect(await storage.getDoc('campaigns/b')).toEqual({ id: 'b', name: 'B' });
|
|
});
|
|
|
|
// set-only batch (not used in main currently, but interface allows).
|
|
test('applies set-only batch', async () => {
|
|
await storage.batchWrite([
|
|
{ type: 'set', path: 'campaigns/a', data: { name: 'A' } },
|
|
{ type: 'set', path: 'campaigns/b', data: { name: 'B' } },
|
|
]);
|
|
expect(await storage.getDoc('campaigns/a')).toEqual({ id: 'a', name: 'A' });
|
|
expect(await storage.getDoc('campaigns/b')).toEqual({ id: 'b', name: 'B' });
|
|
});
|
|
|
|
// update-only batch (interface allows).
|
|
test('applies update-only batch', async () => {
|
|
await storage.setDoc('campaigns/a', { name: 'A', hp: 10 });
|
|
await storage.setDoc('campaigns/b', { name: 'B', hp: 5 });
|
|
await storage.batchWrite([
|
|
{ type: 'update', path: 'campaigns/a', data: { hp: 8 } },
|
|
{ type: 'update', path: 'campaigns/b', data: { hp: 2 } },
|
|
]);
|
|
expect(await storage.getDoc('campaigns/a')).toEqual({ id: 'a', name: 'A', hp: 8 });
|
|
expect(await storage.getDoc('campaigns/b')).toEqual({ id: 'b', name: 'B', hp: 2 });
|
|
});
|
|
});
|
|
|
|
describe('undo', () => {
|
|
test('undo applies updates + flips undone true', async () => {
|
|
await storage.setDoc('encounters/e1', { round: 2, name: 'Enc' });
|
|
await storage.setDoc('logs/l1', { message: 'x', undone: false });
|
|
await storage.undo({
|
|
logPath: 'logs/l1',
|
|
undo: { encounterPath: 'encounters/e1', updates: { round: 1 }, redo: { round: 2 } },
|
|
});
|
|
const enc = await storage.getDoc('encounters/e1');
|
|
const log = await storage.getDoc('logs/l1');
|
|
expect(enc.round).toBe(1);
|
|
expect(log.undone).toBe(true);
|
|
});
|
|
|
|
test('redo applies redo patch + flips undone false', async () => {
|
|
await storage.setDoc('encounters/e1', { round: 1 });
|
|
await storage.setDoc('logs/l1', { message: 'x', undone: true });
|
|
await storage.undo({
|
|
logPath: 'logs/l1',
|
|
undo: { encounterPath: 'encounters/e1', updates: { round: 1 }, redo: { round: 2 } },
|
|
redo: true,
|
|
});
|
|
const enc = await storage.getDoc('encounters/e1');
|
|
const log = await storage.getDoc('logs/l1');
|
|
expect(enc.round).toBe(2);
|
|
expect(log.undone).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('deleteCollection', () => {
|
|
test('deletes all docs in collection', async () => {
|
|
await storage.addDoc('logs', { type: 'a' });
|
|
await storage.addDoc('logs', { type: 'b' });
|
|
const deleted = await storage.deleteCollection('logs');
|
|
expect(deleted).toBe(2);
|
|
const remaining = await storage.getCollection('logs');
|
|
expect(remaining).toHaveLength(0);
|
|
});
|
|
|
|
test('honors whereField filter', async () => {
|
|
await storage.addDoc('logs', { type: 'keep', n: 1 });
|
|
await storage.addDoc('logs', { type: 'drop', n: 2 });
|
|
await storage.addDoc('logs', { type: 'drop', n: 3 });
|
|
const deleted = await storage.deleteCollection('logs', 'type', 'drop');
|
|
expect(deleted).toBe(2);
|
|
const remaining = await storage.getCollection('logs');
|
|
expect(remaining).toHaveLength(1);
|
|
});
|
|
|
|
test('empty collection returns 0', async () => {
|
|
const deleted = await storage.deleteCollection('logs');
|
|
expect(deleted).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('subscribeDoc', () => {
|
|
test('fires cb immediately with current value', async () => {
|
|
await storage.setDoc('campaigns/a', { name: 'Alpha' });
|
|
const calls = [];
|
|
storage.subscribeDoc('campaigns/a', (doc) => calls.push(doc));
|
|
await flush();
|
|
expect(calls).toHaveLength(1);
|
|
expect(calls[0]).toEqual({ id: 'a', name: 'Alpha' });
|
|
});
|
|
|
|
test('fires cb on subsequent change', async () => {
|
|
const calls = [];
|
|
storage.subscribeDoc('campaigns/a', (doc) => calls.push(doc));
|
|
await flush();
|
|
await storage.setDoc('campaigns/a', { name: 'Alpha' });
|
|
await flush();
|
|
const last = calls[calls.length - 1];
|
|
expect(last).toEqual({ id: 'a', name: 'Alpha' });
|
|
});
|
|
|
|
test('unsubscribe stops callbacks', async () => {
|
|
const calls = [];
|
|
const unsub = storage.subscribeDoc('campaigns/a', (doc) => calls.push(doc));
|
|
await flush();
|
|
unsub();
|
|
await storage.setDoc('campaigns/a', { name: 'X' });
|
|
await flush();
|
|
expect(calls.filter(Boolean)).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('subscribeCollection', () => {
|
|
test('fires cb with current docs', async () => {
|
|
await storage.setDoc('campaigns/a', { name: 'A' });
|
|
const calls = [];
|
|
storage.subscribeCollection('campaigns', (docs) => calls.push(docs));
|
|
await flush();
|
|
expect(calls).toHaveLength(1);
|
|
expect(calls[0]).toHaveLength(1);
|
|
});
|
|
|
|
test('fires on add to collection', async () => {
|
|
const calls = [];
|
|
storage.subscribeCollection('campaigns', (docs) => calls.push(docs));
|
|
await flush();
|
|
await storage.setDoc('campaigns/a', { name: 'A' });
|
|
await flush();
|
|
const last = calls[calls.length - 1];
|
|
expect(last).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
// queryConstraints: orderBy + limit. App's combat log uses
|
|
// [orderBy('timestamp','desc'), limit(500)] — newest 500 entries.
|
|
// Adapter MUST honor these. Constraint shape = neutral {__type} objects
|
|
// (what firebase mock produces; what App passes via shared builders).
|
|
describe('subscribeCollection queryConstraints', () => {
|
|
const orderByC = (field, dir) => ({ __type: 'orderBy', field, dir });
|
|
const limitC = (n) => ({ __type: 'limit', n });
|
|
const offsetC = (n) => ({ __type: 'offset', offset: n });
|
|
|
|
beforeEach(async () => {
|
|
// seed 5 log docs, timestamps out of order
|
|
await storage.setDoc('logs/l1', { msg: 'one', timestamp: 1 });
|
|
await storage.setDoc('logs/l2', { msg: 'two', timestamp: 3 });
|
|
await storage.setDoc('logs/l3', { msg: 'three', timestamp: 5 });
|
|
await storage.setDoc('logs/l4', { msg: 'four', timestamp: 2 });
|
|
await storage.setDoc('logs/l5', { msg: 'five', timestamp: 4 });
|
|
});
|
|
|
|
test('orderBy desc sorts newest-first', async () => {
|
|
const result = await new Promise((resolve) => {
|
|
storage.subscribeCollection('logs', resolve, [orderByC('timestamp', 'desc')]);
|
|
});
|
|
expect(result.map(d => d.msg)).toEqual(['three', 'five', 'two', 'four', 'one']);
|
|
});
|
|
|
|
test('limit returns only first N after ordering', async () => {
|
|
const result = await new Promise((resolve) => {
|
|
storage.subscribeCollection('logs', resolve, [orderByC('timestamp', 'desc'), limitC(2)]);
|
|
});
|
|
expect(result.map(d => d.msg)).toEqual(['three', 'five']);
|
|
});
|
|
|
|
test('no constraints returns all (insertion/id order)', async () => {
|
|
const result = await new Promise((resolve) => {
|
|
storage.subscribeCollection('logs', resolve, []);
|
|
});
|
|
expect(result).toHaveLength(5);
|
|
});
|
|
|
|
// Log page pagination (LogsView pageQuery): orderBy + limit + offset.
|
|
// Server pushes offset to SQL; firebase adapter emulates (widen limit,
|
|
// slice). Both MUST return the same page.
|
|
test('offset skips first N after ordering (pagination)', async () => {
|
|
const result = await new Promise((resolve) => {
|
|
storage.subscribeCollection('logs', resolve, [orderByC('timestamp', 'desc'), limitC(2), offsetC(2)]);
|
|
});
|
|
expect(result.map(d => d.msg)).toEqual(['two', 'four']);
|
|
});
|
|
|
|
test('offset past end returns empty page', async () => {
|
|
const result = await new Promise((resolve) => {
|
|
storage.subscribeCollection('logs', resolve, [orderByC('timestamp', 'desc'), limitC(2), offsetC(10)]);
|
|
});
|
|
expect(result).toEqual([]);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
// flush so async subscribers settle. WS roundtrip needs real delay (network),
|
|
// memory fires near-instant. 50ms covers localhost WS comfortably.
|
|
function flush() {
|
|
return new Promise((resolve) => setTimeout(resolve, 50));
|
|
}
|
|
|
|
module.exports = { runStorageContract, flush };
|