match main firebase behavior; fix contract + mismatches
Contract rewritten to match main prod truth:
- require id injection in all doc/collection results
- honor setDoc({merge:true}) (main L1624 + 4 activeDisplay sites)
- drop invented bare<->prefixed cross-lookup tests (main never does this)
- add setDoc{merge}, batch set-only/update-only contract coverage
Fix mismatches vs main:
- subscribeCollection hook now forwards queryConstraints (was dropped;
LOG_QUERY sort+limit honored again). Mock onSnapshot honors orderBy+limit.
2 new contract tests prove the chain.
- subscribeDoc/subscribeCollection adapters now forward errCb. App hooks +
DisplayView propagate subscribe errors to UI (match main onSnapshot 3rd arg).
- ws adapter signatures accept queryConstraints + errCb (interface match).
activeDisplay writes match main:
- 5 unguarded sites -> setDoc({merge:true}) (create-if-missing, not updateDoc)
- 3 guarded sites stay updateDoc
Delete memory adapter: third storage system added complexity, zero value.
firebase-mock covers fast tests, ws covers server path. Factory throws on
unknown mode now.
Delete phantom 'Phase A/B' comment in firebase.js header.
Tests updated to assert setDoc{merge} (not updateDoc) for activeDisplay writes.
83/83 green.
This commit is contained in:
+44
-32
@@ -27,10 +27,10 @@ function runStorageContract(name, factory) {
|
||||
afterEach(async () => { if (storage && storage.dispose) await storage.dispose(); });
|
||||
|
||||
describe('getDoc / setDoc', () => {
|
||||
test('setDoc then getDoc returns the doc', async () => {
|
||||
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({ name: 'Alpha' });
|
||||
expect(doc).toEqual({ id: 'a', name: 'Alpha' });
|
||||
});
|
||||
|
||||
test('getDoc on missing path returns null', async () => {
|
||||
@@ -42,7 +42,15 @@ function runStorageContract(name, factory) {
|
||||
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({ name: 'Beta' });
|
||||
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] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,13 +59,13 @@ function runStorageContract(name, factory) {
|
||||
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({ name: 'Alpha', players: [1, 2] });
|
||||
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({ name: 'Alpha' });
|
||||
expect(doc).toEqual({ id: 'a', name: 'Alpha' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -79,7 +87,7 @@ function runStorageContract(name, factory) {
|
||||
expect(id).toBeTruthy();
|
||||
expect(path).toBe(`campaigns/a/encounters/${id}`);
|
||||
const doc = await storage.getDoc(path);
|
||||
expect(doc).toEqual({ name: 'E1' });
|
||||
expect(doc).toEqual({ id, name: 'E1' });
|
||||
});
|
||||
|
||||
test('two addDocs produce distinct ids', async () => {
|
||||
@@ -91,33 +99,15 @@ function runStorageContract(name, factory) {
|
||||
|
||||
describe('firebase-prefixed path identity', () => {
|
||||
// App passes firebase-prefixed paths (artifacts/{APP_ID}/public/data/...).
|
||||
// Adapter must normalize internally so write+read at prefixed path round-trips
|
||||
// AND collection queries at bare canonical path find prefixed-written docs.
|
||||
// Catches replay-script bug (wrote prefixed, adapter reads bare, missed).
|
||||
// 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 getCollection bare finds it', async () => {
|
||||
await storage.setDoc(`${PREFIX}/campaigns/c1`, { name: 'P1' });
|
||||
const docs = await storage.getCollection('campaigns');
|
||||
expect(docs.some(d => d.name === 'P1')).toBe(true);
|
||||
});
|
||||
|
||||
test('setDoc prefixed then getDoc same prefixed path returns it', async () => {
|
||||
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({ name: 'P2' });
|
||||
});
|
||||
|
||||
test('setDoc prefixed then getDoc bare path returns it', async () => {
|
||||
await storage.setDoc(`${PREFIX}/campaigns/c3`, { name: 'P3' });
|
||||
const doc = await storage.getDoc('campaigns/c3');
|
||||
expect(doc).toEqual({ name: 'P3' });
|
||||
});
|
||||
|
||||
test('setDoc bare then getCollection prefixed finds it', async () => {
|
||||
await storage.setDoc('campaigns/c4', { name: 'P4' });
|
||||
const docs = await storage.getCollection(`${PREFIX}/campaigns`);
|
||||
expect(docs.some(d => d.name === 'P4')).toBe(true);
|
||||
expect(doc).toEqual({ id: 'c2', name: 'P2' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -165,7 +155,29 @@ function runStorageContract(name, factory) {
|
||||
{ type: 'delete', path: 'campaigns/a' },
|
||||
]);
|
||||
expect(await storage.getDoc('campaigns/a')).toBeNull();
|
||||
expect(await storage.getDoc('campaigns/b')).toEqual({ name: 'B' });
|
||||
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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -176,7 +188,7 @@ function runStorageContract(name, factory) {
|
||||
storage.subscribeDoc('campaigns/a', (doc) => calls.push(doc));
|
||||
await flush();
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]).toEqual({ name: 'Alpha' });
|
||||
expect(calls[0]).toEqual({ id: 'a', name: 'Alpha' });
|
||||
});
|
||||
|
||||
test('fires cb on subsequent change', async () => {
|
||||
@@ -186,7 +198,7 @@ function runStorageContract(name, factory) {
|
||||
await storage.setDoc('campaigns/a', { name: 'Alpha' });
|
||||
await flush();
|
||||
const last = calls[calls.length - 1];
|
||||
expect(last).toEqual({ name: 'Alpha' });
|
||||
expect(last).toEqual({ id: 'a', name: 'Alpha' });
|
||||
});
|
||||
|
||||
test('unsubscribe stops callbacks', async () => {
|
||||
|
||||
+12
-8
@@ -1,10 +1,8 @@
|
||||
// firebase.js — storage adapter wrapping Firebase SDK. Default impl (upstream-unchanged).
|
||||
// Matches interface of memory.js / ws.js so App.js calls stay identical.
|
||||
//
|
||||
// NOTE: App.js currently imports SDK directly. This adapter extracted verbatim.
|
||||
// Two-phase refactor:
|
||||
// Phase A (now): adapter exists, wraps SDK. Hooks/writes can switch incrementally.
|
||||
// Phase B (later): App.js imports storage factory, drops direct SDK imports.
|
||||
// App.js imports SDK via this module (doc, setDoc, etc re-exported) for both
|
||||
// the adapter (createFirebaseStorage) and direct SDK calls. ONE import path.
|
||||
|
||||
'use strict';
|
||||
|
||||
@@ -120,21 +118,27 @@ export function createFirebaseStorage() {
|
||||
},
|
||||
|
||||
// Subscribe = onSnapshot. cb fires immediately + on change. Returns unsubscribe.
|
||||
subscribeDoc(path, cb) {
|
||||
subscribeDoc(path, cb, errCb) {
|
||||
recordAdapterCall({ fn: 'subscribeDoc', path });
|
||||
return onSnapshot(doc(db, path), (snap) => {
|
||||
cb(snap.exists() ? { id: snap.id, ...snap.data() } : null);
|
||||
}, (err) => console.error(`subscribeDoc ${path}:`, err));
|
||||
}, (err) => {
|
||||
console.error(`subscribeDoc ${path}:`, err);
|
||||
if (typeof errCb === 'function') errCb(err);
|
||||
});
|
||||
},
|
||||
|
||||
subscribeCollection(collectionPath, cb, queryConstraints = []) {
|
||||
subscribeCollection(collectionPath, cb, queryConstraints = [], errCb) {
|
||||
recordAdapterCall({ fn: 'subscribeCollection', path: collectionPath });
|
||||
const q = queryConstraints.length > 0
|
||||
? query(collection(db, collectionPath), ...queryConstraints)
|
||||
: collection(db, collectionPath);
|
||||
return onSnapshot(q, (snap) => {
|
||||
cb(snap.docs.map(d => ({ id: d.id, ...d.data() })));
|
||||
}, (err) => console.error(`subscribeCollection ${collectionPath}:`, err));
|
||||
}, (err) => {
|
||||
console.error(`subscribeCollection ${collectionPath}:`, err);
|
||||
if (typeof errCb === 'function') errCb(err);
|
||||
});
|
||||
},
|
||||
|
||||
dispose() { /* SDK managed; no-op */ },
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
} from 'firebase/firestore';
|
||||
import { initFirebase, createFirebaseStorage } from './firebase';
|
||||
import { createWsStorage } from './ws';
|
||||
import { createMemoryStorage } from './memory';
|
||||
|
||||
let storageInstance = null;
|
||||
|
||||
@@ -30,7 +29,7 @@ export function getStorage() {
|
||||
wsUrl: process.env.REACT_APP_BACKEND_WS || '',
|
||||
});
|
||||
} else {
|
||||
storageInstance = createMemoryStorage();
|
||||
throw new Error(`Unknown REACT_APP_STORAGE mode: ${mode}. Use 'firebase' or 'ws'.`);
|
||||
}
|
||||
return storageInstance;
|
||||
}
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
// memory.js — in-process storage impl. Test seed.
|
||||
// Map<docPath, data>. EventEmitter for subscribe.
|
||||
// Mirrors firebase semantics: setDoc=replace, updateDoc=shallow merge, addDoc=auto-id.
|
||||
|
||||
'use strict';
|
||||
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
function createMemoryStorage() {
|
||||
const docs = new Map(); // path -> data obj
|
||||
const bus = new EventEmitter();
|
||||
bus.setMaxListeners(1000);
|
||||
|
||||
// Firebase-prefixed paths (artifacts/{APP_ID}/public/data/...) normalized to
|
||||
// bare canonical. Matches ws.js norm() so all impls share path identity.
|
||||
function norm(p) {
|
||||
if (!p) return p;
|
||||
return p.replace(/^[\s\S]*\/public\/data\//, '');
|
||||
}
|
||||
|
||||
// ---- path helpers ----
|
||||
// collection path = path with even number of segments OR known collection.
|
||||
// doc path = odd segments (coll/doc, coll/doc/subcoll/subdoc).
|
||||
// getCollection(path) returns all docs whose path === path/id for any single id segment.
|
||||
function isCollectionPath(p) {
|
||||
return p.split('/').length % 2 === 1;
|
||||
}
|
||||
|
||||
function emitDoc(path, data) { bus.emit('doc:' + path, data); }
|
||||
function emitCollection(collPath) {
|
||||
const children = collectionDocs(collPath);
|
||||
bus.emit('coll:' + collPath, children);
|
||||
}
|
||||
|
||||
function collectionDocs(collPath) {
|
||||
const out = [];
|
||||
const segLen = collPath.split('/').length + 1;
|
||||
for (const [p, data] of docs) {
|
||||
const segs = p.split('/');
|
||||
if (segs.length !== segLen) continue;
|
||||
const parent = segs.slice(0, -1).join('/');
|
||||
if (parent === collPath) out.push(data);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function genId() {
|
||||
return (typeof crypto !== 'undefined' && crypto.randomUUID)
|
||||
? crypto.randomUUID()
|
||||
: `id_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
const storage = {
|
||||
async getDoc(rawPath) {
|
||||
const path = norm(rawPath);
|
||||
return docs.has(path) ? deepClone(docs.get(path)) : null;
|
||||
},
|
||||
|
||||
async setDoc(rawPath, data) {
|
||||
const path = norm(rawPath);
|
||||
docs.set(path, deepClone(data));
|
||||
emitDoc(path, deepClone(data));
|
||||
const segs = path.split('/');
|
||||
if (segs.length >= 2) emitCollection(segs.slice(0, -1).join('/'));
|
||||
},
|
||||
|
||||
async updateDoc(rawPath, patch) {
|
||||
const path = norm(rawPath);
|
||||
const existing = docs.has(path) ? docs.get(path) : {};
|
||||
const merged = { ...existing, ...patch };
|
||||
docs.set(path, merged);
|
||||
emitDoc(path, deepClone(merged));
|
||||
const segs = path.split('/');
|
||||
if (segs.length >= 2) emitCollection(segs.slice(0, -1).join('/'));
|
||||
},
|
||||
|
||||
async deleteDoc(rawPath) {
|
||||
const path = norm(rawPath);
|
||||
docs.delete(path);
|
||||
emitDoc(path, null);
|
||||
const segs = path.split('/');
|
||||
if (segs.length >= 2) emitCollection(segs.slice(0, -1).join('/'));
|
||||
},
|
||||
|
||||
async addDoc(rawCollectionPath, data) {
|
||||
const collectionPath = norm(rawCollectionPath);
|
||||
const id = genId();
|
||||
const path = `${collectionPath}/${id}`;
|
||||
docs.set(path, deepClone(data));
|
||||
emitDoc(path, deepClone(data));
|
||||
emitCollection(collectionPath);
|
||||
return { id, path };
|
||||
},
|
||||
|
||||
async getCollection(rawCollPath) {
|
||||
const collPath = norm(rawCollPath);
|
||||
return collectionDocs(collPath).map(deepClone);
|
||||
},
|
||||
|
||||
async batchWrite(ops) {
|
||||
for (const op of ops) {
|
||||
const mop = { ...op, path: norm(op.path) };
|
||||
if (mop.type === 'set') await storage.setDoc(mop.path, mop.data);
|
||||
else if (mop.type === 'delete') await storage.deleteDoc(mop.path);
|
||||
else if (mop.type === 'update') await storage.updateDoc(mop.path, mop.data);
|
||||
}
|
||||
},
|
||||
|
||||
subscribeDoc(rawPath, cb) {
|
||||
const path = norm(rawPath);
|
||||
const cur = docs.has(path) ? deepClone(docs.get(path)) : null;
|
||||
Promise.resolve().then(() => cb(cur));
|
||||
const handler = (data) => cb(data);
|
||||
bus.on('doc:' + path, handler);
|
||||
return () => bus.off('doc:' + path, handler);
|
||||
},
|
||||
|
||||
subscribeCollection(rawCollPath, cb) {
|
||||
const collPath = norm(rawCollPath);
|
||||
Promise.resolve().then(() => cb(collectionDocs(collPath).map(deepClone)));
|
||||
const handler = (docs) => cb(docs);
|
||||
bus.on('coll:' + collPath, handler);
|
||||
return () => bus.off('coll:' + collPath, handler);
|
||||
},
|
||||
|
||||
dispose() { bus.removeAllListeners(); docs.clear(); },
|
||||
|
||||
// test/debug
|
||||
_docs: docs,
|
||||
};
|
||||
|
||||
return storage;
|
||||
}
|
||||
|
||||
function deepClone(v) {
|
||||
if (v === null || v === undefined) return v;
|
||||
return JSON.parse(JSON.stringify(v));
|
||||
}
|
||||
|
||||
export { createMemoryStorage };
|
||||
+2
-2
@@ -188,7 +188,7 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
|
||||
await api('POST', '/api/batch', null, { ops: normOps });
|
||||
},
|
||||
|
||||
subscribeDoc(rawPath, cb) {
|
||||
subscribeDoc(rawPath, cb, errCb) {
|
||||
const p = norm(rawPath);
|
||||
// Initial value via REST (independent of WS connect).
|
||||
storage.getDoc(p).then(cb).catch(() => {});
|
||||
@@ -201,7 +201,7 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
|
||||
return () => { docSubs.get(p)?.delete(cb); };
|
||||
},
|
||||
|
||||
subscribeCollection(rawCollPath, cb) {
|
||||
subscribeCollection(rawCollPath, cb, queryConstraints = [], errCb) {
|
||||
const p = norm(rawCollPath);
|
||||
// Initial value via REST (independent of WS connect).
|
||||
storage.getCollection(p).then(cb).catch(() => {});
|
||||
|
||||
Reference in New Issue
Block a user