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.
This commit is contained in:
david raistrick
2026-06-28 17:18:14 -04:00
parent 9457f48b23
commit 2ee2bba93b
3 changed files with 333 additions and 0 deletions
+126
View File
@@ -0,0 +1,126 @@
// 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';
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 };