M3: fix path-shape drift via adapter contract + identity tests

Root cause (HAR-diagnosed): replay script wrote firebase-prefixed paths via
raw REST, bypassing adapter norm(). Two path roots coexisted in db:
  bare 'campaigns/X' (adapter writes, from App)
  prefixed 'artifacts/.../campaigns/X' (replay raw writes)
Adapter read bare, missed prefixed. UI showed stale test1 (legit manual UI
write, not wiped) but replay campaigns invisible.

A. replay-combat.js: use createWsStorage adapter instead of raw fetch. Same
   contract boundary as App. norm() runs on all paths. Can't drift.
   Mirror App.js getPath locally for path construction.
B. contract.js: 4 new identity tests (setDoc prefixed -> getCollection bare,
   setDoc prefixed -> getDoc bare, setDoc prefixed -> getDoc prefixed,
   setDoc bare -> getCollection prefixed). Run against every impl (memory,
   ws). memory.js lacked norm() -> RED first, now GREEN after adding norm.
C. db moved out of /tmp to ./data/tracker.sqlite (gitignored). Never tmp.

Tests: 124 green (39 shared + 23 ws-contract + 62 FE).
This commit is contained in:
david raistrick
2026-06-29 15:13:03 -04:00
parent 6630fd9158
commit 9fd0f3ec38
4 changed files with 99 additions and 57 deletions
+32
View File
@@ -89,6 +89,38 @@ 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).
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 () => {
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);
});
});
describe('getCollection', () => {
test('returns immediate child docs only (not nested)', async () => {
await storage.setDoc('campaigns/a', { name: 'A' });
+27 -13
View File
@@ -11,6 +11,13 @@ function createMemoryStorage() {
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).
@@ -44,19 +51,21 @@ function createMemoryStorage() {
}
const storage = {
async getDoc(path) {
async getDoc(rawPath) {
const path = norm(rawPath);
return docs.has(path) ? deepClone(docs.get(path)) : null;
},
async setDoc(path, data) {
async setDoc(rawPath, data) {
const path = norm(rawPath);
docs.set(path, deepClone(data));
emitDoc(path, deepClone(data));
// notify parent collection
const segs = path.split('/');
if (segs.length >= 2) emitCollection(segs.slice(0, -1).join('/'));
},
async updateDoc(path, patch) {
async updateDoc(rawPath, patch) {
const path = norm(rawPath);
const existing = docs.has(path) ? docs.get(path) : {};
const merged = { ...existing, ...patch };
docs.set(path, merged);
@@ -65,14 +74,16 @@ function createMemoryStorage() {
if (segs.length >= 2) emitCollection(segs.slice(0, -1).join('/'));
},
async deleteDoc(path) {
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(collectionPath, data) {
async addDoc(rawCollectionPath, data) {
const collectionPath = norm(rawCollectionPath);
const id = genId();
const path = `${collectionPath}/${id}`;
docs.set(path, deepClone(data));
@@ -81,20 +92,22 @@ function createMemoryStorage() {
return { id, path };
},
async getCollection(collPath) {
async getCollection(rawCollPath) {
const collPath = norm(rawCollPath);
return collectionDocs(collPath).map(deepClone);
},
async batchWrite(ops) {
for (const op of ops) {
if (op.type === 'set') await storage.setDoc(op.path, op.data);
else if (op.type === 'delete') await storage.deleteDoc(op.path);
else if (op.type === 'update') await storage.updateDoc(op.path, op.data);
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(path, cb) {
// fire immediately with current value
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);
@@ -102,7 +115,8 @@ function createMemoryStorage() {
return () => bus.off('doc:' + path, handler);
},
subscribeCollection(collPath, cb) {
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);