From 2ee2bba93b46132ec42a890095dc42d3ded5048a Mon Sep 17 00:00:00 2001 From: david raistrick <1108844+keen99@users.noreply.github.com> Date: Sun, 28 Jun 2026 17:18:14 -0400 Subject: [PATCH] M2 (TDD): storage contract test + memory impl - src/storage/contract.test.js: storage interface spec (19 assertions) - src/storage/memory.js: in-process impl (Map + EventEmitter) - src/storage/storage.test.js: runner, memory first TDD: contract RED first, memory built to satisfy, 19/19 green. Next impls (ws, firebase) run same contract. --- src/storage/contract.test.js | 199 +++++++++++++++++++++++++++++++++++ src/storage/memory.js | 126 ++++++++++++++++++++++ src/storage/storage.test.js | 8 ++ 3 files changed, 333 insertions(+) create mode 100644 src/storage/contract.test.js create mode 100644 src/storage/memory.js create mode 100644 src/storage/storage.test.js diff --git a/src/storage/contract.test.js b/src/storage/contract.test.js new file mode 100644 index 0000000..b05ef48 --- /dev/null +++ b/src/storage/contract.test.js @@ -0,0 +1,199 @@ +// 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()); + +'use strict'; + +// Each impl factory returns a fresh storage instance (async-creatable is fine). +// Interface every impl MUST provide: +// getDoc(path) -> Promise +// setDoc(path, data) -> Promise (replace) +// updateDoc(path, patch) -> Promise (shallow merge) +// deleteDoc(path) -> Promise +// addDoc(collectionPath, data) -> Promise<{id, path}> (auto-gen id) +// getCollection(path) -> Promise (immediate child docs) +// batchWrite(ops) -> Promise ops: [{type, path, data?}] +// 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', async () => { + await storage.setDoc('campaigns/a', { name: 'Alpha' }); + const doc = await storage.getDoc('campaigns/a'); + expect(doc).toEqual({ 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({ name: 'Beta' }); + }); + }); + + 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({ 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' }); + }); + }); + + 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({ 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('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({ name: 'B' }); + }); + }); + + 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({ 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({ 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); + }); + }); + }); +} + +// microtask flush so async subscribers settle. +function flush() { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +module.exports = { runStorageContract, flush }; diff --git a/src/storage/memory.js b/src/storage/memory.js new file mode 100644 index 0000000..c3bd834 --- /dev/null +++ b/src/storage/memory.js @@ -0,0 +1,126 @@ +// memory.js — in-process storage impl. Test seed. +// Map. EventEmitter for subscribe. +// Mirrors firebase semantics: setDoc=replace, updateDoc=shallow merge, addDoc=auto-id. + +'use strict'; + +const { EventEmitter } = require('events'); + +function createMemoryStorage() { + const docs = new Map(); // path -> data obj + const bus = new EventEmitter(); + bus.setMaxListeners(1000); + + // ---- 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(path) { + return docs.has(path) ? deepClone(docs.get(path)) : null; + }, + + async setDoc(path, data) { + 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) { + 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(path) { + docs.delete(path); + emitDoc(path, null); + const segs = path.split('/'); + if (segs.length >= 2) emitCollection(segs.slice(0, -1).join('/')); + }, + + async addDoc(collectionPath, data) { + const id = genId(); + const path = `${collectionPath}/${id}`; + docs.set(path, deepClone(data)); + emitDoc(path, deepClone(data)); + emitCollection(collectionPath); + return { id, path }; + }, + + async getCollection(collPath) { + 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); + } + }, + + subscribeDoc(path, cb) { + // fire immediately with current value + 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(collPath, cb) { + 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)); +} + +module.exports = { createMemoryStorage }; diff --git a/src/storage/storage.test.js b/src/storage/storage.test.js new file mode 100644 index 0000000..9fe30bf --- /dev/null +++ b/src/storage/storage.test.js @@ -0,0 +1,8 @@ +// Runner: executes storage contract against each impl. +// TDD: contract = spec. Run against memory first. RED until memory.js built. +'use strict'; + +const { runStorageContract } = require('./contract.test'); +const { createMemoryStorage } = require('./memory'); + +runStorageContract('memory', () => createMemoryStorage());