2026-06-28 17:18:14 -04:00
|
|
|
// 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);
|
|
|
|
|
|
2026-06-29 15:13:03 -04:00
|
|
|
// 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\//, '');
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-28 17:18:14 -04:00
|
|
|
// ---- 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 = {
|
2026-06-29 15:13:03 -04:00
|
|
|
async getDoc(rawPath) {
|
|
|
|
|
const path = norm(rawPath);
|
2026-06-28 17:18:14 -04:00
|
|
|
return docs.has(path) ? deepClone(docs.get(path)) : null;
|
|
|
|
|
},
|
|
|
|
|
|
2026-06-29 15:13:03 -04:00
|
|
|
async setDoc(rawPath, data) {
|
|
|
|
|
const path = norm(rawPath);
|
2026-06-28 17:18:14 -04:00
|
|
|
docs.set(path, deepClone(data));
|
|
|
|
|
emitDoc(path, deepClone(data));
|
|
|
|
|
const segs = path.split('/');
|
|
|
|
|
if (segs.length >= 2) emitCollection(segs.slice(0, -1).join('/'));
|
|
|
|
|
},
|
|
|
|
|
|
2026-06-29 15:13:03 -04:00
|
|
|
async updateDoc(rawPath, patch) {
|
|
|
|
|
const path = norm(rawPath);
|
2026-06-28 17:18:14 -04:00
|
|
|
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('/'));
|
|
|
|
|
},
|
|
|
|
|
|
2026-06-29 15:13:03 -04:00
|
|
|
async deleteDoc(rawPath) {
|
|
|
|
|
const path = norm(rawPath);
|
2026-06-28 17:18:14 -04:00
|
|
|
docs.delete(path);
|
|
|
|
|
emitDoc(path, null);
|
|
|
|
|
const segs = path.split('/');
|
|
|
|
|
if (segs.length >= 2) emitCollection(segs.slice(0, -1).join('/'));
|
|
|
|
|
},
|
|
|
|
|
|
2026-06-29 15:13:03 -04:00
|
|
|
async addDoc(rawCollectionPath, data) {
|
|
|
|
|
const collectionPath = norm(rawCollectionPath);
|
2026-06-28 17:18:14 -04:00
|
|
|
const id = genId();
|
|
|
|
|
const path = `${collectionPath}/${id}`;
|
|
|
|
|
docs.set(path, deepClone(data));
|
|
|
|
|
emitDoc(path, deepClone(data));
|
|
|
|
|
emitCollection(collectionPath);
|
|
|
|
|
return { id, path };
|
|
|
|
|
},
|
|
|
|
|
|
2026-06-29 15:13:03 -04:00
|
|
|
async getCollection(rawCollPath) {
|
|
|
|
|
const collPath = norm(rawCollPath);
|
2026-06-28 17:18:14 -04:00
|
|
|
return collectionDocs(collPath).map(deepClone);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
async batchWrite(ops) {
|
|
|
|
|
for (const op of ops) {
|
2026-06-29 15:13:03 -04:00
|
|
|
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);
|
2026-06-28 17:18:14 -04:00
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
2026-06-29 15:13:03 -04:00
|
|
|
subscribeDoc(rawPath, cb) {
|
|
|
|
|
const path = norm(rawPath);
|
2026-06-28 17:18:14 -04:00
|
|
|
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);
|
|
|
|
|
},
|
|
|
|
|
|
2026-06-29 15:13:03 -04:00
|
|
|
subscribeCollection(rawCollPath, cb) {
|
|
|
|
|
const collPath = norm(rawCollPath);
|
2026-06-28 17:18:14 -04:00
|
|
|
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 };
|