Single source of truth: combat logic, storage parity, slot ordering #3

Merged
robert merged 32 commits from single-source into main 2026-07-05 00:07:57 -04:00
2 changed files with 31 additions and 6 deletions
Showing only changes of commit 6baecd374e - Show all commits
+1 -1
View File
@@ -48,7 +48,7 @@ describe('BUG-8: ws adapter reconnect after drop', () => {
await flush(1000); await flush(1000);
const last = calls[calls.length - 1]; const last = calls[calls.length - 1];
expect(last).toEqual({ name: 'V2' }); expect(last).toEqual({ id: 'a', name: 'V2' });
} finally { } finally {
await new Promise(r => storage.dispose(r)); await new Promise(r => storage.dispose(r));
} }
+29 -4
View File
@@ -33,6 +33,15 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
return p.replace(/^[\s\S]*\/public\/data\//, ''); return p.replace(/^[\s\S]*\/public\/data\//, '');
} }
// Inject id (last path segment) into doc — matches main firebase truth:
// { id: docSnap.id, ...snap.data() }
// Backend stores docs WITHOUT id; client adds it so all adapters share shape.
function withId(rawPath, data) {
if (data === null || data === undefined) return data;
const id = norm(rawPath).split('/').pop();
return { id, ...data };
}
const docSubs = new Map(); // path -> Set<cb> const docSubs = new Map(); // path -> Set<cb>
const collSubs = new Map(); // collPath -> Set<cb> const collSubs = new Map(); // collPath -> Set<cb>
let ws = null; let ws = null;
@@ -154,12 +163,19 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
async getDoc(rawPath) { async getDoc(rawPath) {
const p = norm(rawPath); const p = norm(rawPath);
const res = await api('GET', '/api/doc', { path: p }); const res = await api('GET', '/api/doc', { path: p });
return res && res.data !== undefined ? res.data : null; const data = res && res.data !== undefined ? res.data : null;
return withId(rawPath, data);
}, },
async setDoc(rawPath, data) { async setDoc(rawPath, data, opts = {}) {
const p = norm(rawPath); const p = norm(rawPath);
// merge flag -> PATCH (shallow merge, create-on-miss). Matches firebase
// setDoc(path, data, {merge:true}) semantics.
if (opts.merge) {
await api('PATCH', '/api/doc', null, { path: p, patch: data });
} else {
await api('PUT', '/api/doc', null, { path: p, data }); await api('PUT', '/api/doc', null, { path: p, data });
}
}, },
async updateDoc(rawPath, patch) { async updateDoc(rawPath, patch) {
@@ -180,7 +196,16 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
async getCollection(rawCollPath) { async getCollection(rawCollPath) {
const p = norm(rawCollPath); const p = norm(rawCollPath);
return await api('GET', '/api/collection', { path: p }); const docs = await api('GET', '/api/collection', { path: p });
// Backend returns array of { id, data } OR bare data[]; normalize to
// { id, ...data } (firebase truth).
if (!Array.isArray(docs)) return [];
return docs.map(d => {
if (d && typeof d === 'object' && 'id' in d && 'data' in d) {
return { id: d.id, ...d.data };
}
return { ...d };
});
}, },
async batchWrite(ops) { async batchWrite(ops) {
@@ -190,7 +215,7 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
subscribeDoc(rawPath, cb, errCb) { subscribeDoc(rawPath, cb, errCb) {
const p = norm(rawPath); const p = norm(rawPath);
// Initial value via REST (independent of WS connect). // Initial value via REST (independent of WS connect). getDoc injects id.
storage.getDoc(p).then(cb).catch(() => {}); storage.getDoc(p).then(cb).catch(() => {});
// WS only for subsequent change notifications. // WS only for subsequent change notifications.
ensureWs().then(() => { ensureWs().then(() => {