fix(storage): neutral queryConstraints honored by both adapters

Bug: server adapter accepted queryConstraints (orderBy/limit) but ignored them.
Combat log [orderBy('timestamp','desc'), limit(500)] returned ALL logs unordered
in server mode — unbounded growth. No test caught it: shared contract tested
subscribeCollection with 0 constraints; the firebase-only queryConstraint test
never touched the server adapter. Parity gap.

Fix (Plan A — neutral shape):
- index.js: orderBy()/limit() now NEUTRAL builders returning {__type}. Removed
  SDK orderBy/limit re-export.
- firebase.js: subscribeCollection translates neutral -> SDK query constraints.
- server.js: applyConstraints() helper sorts/limits client-side (backend returns
  all). Constraints stored per collection, applied on initial fetch + WS change.
- contract.js: added 3 queryConstraint tests (orderBy desc, limit, no-constraints)
  to SHARED contract — both adapters now run identical assertions.
- firebase.contract.test.js: removed now-redundant firebase-only constraint
  block (covered by shared contract).

Data impact: ZERO. Constraints are query-time only, never stored. Combat log
docs keep timestamp field. No Firebase data migration needed.

208 tests green.
This commit is contained in:
david raistrick
2026-07-04 16:06:28 -04:00
parent e94e1959ff
commit 27b2272ced
5 changed files with 98 additions and 50 deletions
+32 -5
View File
@@ -42,8 +42,30 @@ function createServerStorage({ baseUrl, realtimeUrl } = {}) {
return { id, ...data };
}
// Apply neutral {__type} query constraints client-side. Backend returns all
// docs; adapter sorts/limits. Mirrors firebase mock applyConstraints.
function applyConstraints(docs, constraints) {
let out = [...docs];
for (const c of constraints || []) {
if (!c || !c.__type) continue;
if (c.__type === 'orderBy') {
out.sort((a, b) => {
const av = a[c.field];
const bv = b[c.field];
if (av === bv) return 0;
const cmp = av > bv ? 1 : -1;
return c.dir === 'desc' ? -cmp : cmp;
});
} else if (c.__type === 'limit') {
out = out.slice(0, c.n);
}
}
return out;
}
const docSubs = new Map(); // path -> Set<cb>
const collSubs = new Map(); // collPath -> Set<cb>
const collConstraints = new Map(); // collPath -> constraints[] (per collection)
let ws = null;
let wsReady = null;
@@ -130,11 +152,12 @@ function createServerStorage({ baseUrl, realtimeUrl } = {}) {
const doc = await storage.getDoc(c.path);
docCbs.forEach(cb => cb(doc));
}
// collection subscribers at parent path (doc belongs to this collection)
// collection subscribers at parent path (apply their stored constraints)
if (c.parent) {
const collCbs = collSubs.get(c.parent);
if (collCbs) {
const docs = await storage.getCollection(c.parent);
const constraints = collConstraints.get(c.parent) || [];
const docs = applyConstraints(await storage.getCollection(c.parent), constraints);
collCbs.forEach(cb => cb(docs));
}
}
@@ -228,8 +251,12 @@ function createServerStorage({ baseUrl, realtimeUrl } = {}) {
subscribeCollection(rawCollPath, cb, queryConstraints = [], errCb) {
const p = norm(rawCollPath);
// Initial value via REST (independent of WS connect).
storage.getCollection(p).then(cb).catch(() => {});
collConstraints.set(p, queryConstraints || []);
// Initial value via REST (independent of WS connect). Apply constraints
// client-side — backend returns all docs, adapter sorts/limits.
storage.getCollection(p)
.then(docs => cb(applyConstraints(docs, queryConstraints || [])))
.catch(() => {});
// WS only for subsequent change notifications.
ensureWs().then(() => {
ws.send(JSON.stringify({ type: 'subscribe', kind: 'collection', path: p }));
@@ -243,7 +270,7 @@ function createServerStorage({ baseUrl, realtimeUrl } = {}) {
disposed = true;
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
if (ws) ws.close();
docSubs.clear(); collSubs.clear();
docSubs.clear(); collSubs.clear(); collConstraints.clear();
if (typeof cb === 'function') cb();
},