Fix white /logs page in firebase mode: emulate offset in adapter

The logs page white-screened when deployed against Firestore. LogsView
paginates with the neutral offset() constraint, which the server/sqlite
adapter pushes into SQL — but the firebase adapter's subscribeCollection
had no case for it and passed the raw {__type:'offset'} object through
to the SDK's query(), which throws on non-QueryConstraint values. The
throw fired inside useFirestoreCollection's effect on first render,
unmounting the whole tree. Dev never hit it because dev runs the server
adapter.

Fix: shared toSdkConstraints() translator in the firebase adapter,
used by getCollection and subscribeCollection. The client SDK has no
offset, so emulate it — widen limit to offset+limit and slice the
leading docs off the snapshot (same read cost as the admin SDK's
native offset, which also bills skipped docs). Unknown constraint
types are now dropped instead of passed through, so a future neutral
builder can't reintroduce the crash. This also fixes getCollection
silently skipping offset, which returned page 1 for every page.

Server/sqlite adapter unchanged; offset still runs in SQL. Two new
shared contract tests (offset pagination + offset past end) run
against both adapters and pass: firebase mock 33, live sqlite 40.

Also bump better-sqlite3 ^11.3.0 -> ^12.0.0: v11 fails to compile
against Node 26's V8 (GetPrototype removed), so server tests could
not run at all on Node 26. v12.11.1 builds clean; full server suite
passes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 22:50:29 -04:00
co-authored by Claude Fable 5
parent 3646a9cf8e
commit 995347d255
4 changed files with 53 additions and 43 deletions
+7 -21
View File
@@ -7151,14 +7151,17 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/better-sqlite3": { "node_modules/better-sqlite3": {
"version": "11.10.0", "version": "12.11.1",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz",
"integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", "integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"bindings": "^1.5.0", "bindings": "^1.5.0",
"prebuild-install": "^7.1.1" "prebuild-install": "^7.1.1"
},
"engines": {
"node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x"
} }
}, },
"node_modules/bfj": { "node_modules/bfj": {
@@ -21316,23 +21319,6 @@
} }
} }
}, },
"node_modules/tailwindcss/node_modules/yaml": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
"license": "ISC",
"optional": true,
"peer": true,
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/tapable": { "node_modules/tapable": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
@@ -23021,7 +23007,7 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@ttrpg/shared": "*", "@ttrpg/shared": "*",
"better-sqlite3": "^11.3.0", "better-sqlite3": "^12.0.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.19.2", "express": "^4.19.2",
"nanoid": "^5.0.7", "nanoid": "^5.0.7",
+1 -1
View File
@@ -11,7 +11,7 @@
}, },
"dependencies": { "dependencies": {
"@ttrpg/shared": "*", "@ttrpg/shared": "*",
"better-sqlite3": "^11.3.0", "better-sqlite3": "^12.0.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.19.2", "express": "^4.19.2",
"nanoid": "^5.0.7", "nanoid": "^5.0.7",
+18
View File
@@ -294,6 +294,7 @@ function runStorageContract(name, factory) {
describe('subscribeCollection queryConstraints', () => { describe('subscribeCollection queryConstraints', () => {
const orderByC = (field, dir) => ({ __type: 'orderBy', field, dir }); const orderByC = (field, dir) => ({ __type: 'orderBy', field, dir });
const limitC = (n) => ({ __type: 'limit', n }); const limitC = (n) => ({ __type: 'limit', n });
const offsetC = (n) => ({ __type: 'offset', offset: n });
beforeEach(async () => { beforeEach(async () => {
// seed 5 log docs, timestamps out of order // seed 5 log docs, timestamps out of order
@@ -324,6 +325,23 @@ function runStorageContract(name, factory) {
}); });
expect(result).toHaveLength(5); expect(result).toHaveLength(5);
}); });
// Log page pagination (LogsView pageQuery): orderBy + limit + offset.
// Server pushes offset to SQL; firebase adapter emulates (widen limit,
// slice). Both MUST return the same page.
test('offset skips first N after ordering (pagination)', async () => {
const result = await new Promise((resolve) => {
storage.subscribeCollection('logs', resolve, [orderByC('timestamp', 'desc'), limitC(2), offsetC(2)]);
});
expect(result.map(d => d.msg)).toEqual(['two', 'four']);
});
test('offset past end returns empty page', async () => {
const result = await new Promise((resolve) => {
storage.subscribeCollection('logs', resolve, [orderByC('timestamp', 'desc'), limitC(2), offsetC(10)]);
});
expect(result).toEqual([]);
});
}); });
}); });
} }
+27 -21
View File
@@ -71,6 +71,27 @@ export function getAuthInstance() { return authInstance; }
// App.js can now import { storage } and call storage.setDoc(path, data). // App.js can now import { storage } and call storage.setDoc(path, data).
// Hooks (useFirestoreDocument etc) still use SDK directly for now. // Hooks (useFirestoreDocument etc) still use SDK directly for now.
// Translate neutral {__type} constraints (src/storage/index.js builders) to
// SDK builders. The client SDK has no offset, so emulate it: widen limit to
// offset+limit and report offsetN for the caller to slice off the leading
// docs. Read cost matches native offset (admin SDK also bills skipped docs);
// the server adapter pushes offset into SQL instead. Unknown constraint types
// are dropped — a raw object handed to query() throws and white-screens.
function toSdkConstraints(queryConstraints) {
let offsetN = 0;
let limitN = null;
const fbConstraints = [];
for (const c of queryConstraints || []) {
if (!c || !c.__type) continue;
if (c.__type === 'where') fbConstraints.push(where(c.field, c.op, c.value));
else if (c.__type === 'orderBy') fbConstraints.push(orderBy(c.field, c.dir || 'asc'));
else if (c.__type === 'limit') limitN = c.n;
else if (c.__type === 'offset') offsetN = c.offset || 0;
}
if (limitN !== null) fbConstraints.push(limit(limitN + offsetN));
return { fbConstraints, offsetN };
}
export function createFirebaseStorage() { export function createFirebaseStorage() {
const db = dbInstance; const db = dbInstance;
if (!db) throw new Error('Firestore not initialized. Call initFirebase() first.'); if (!db) throw new Error('Firestore not initialized. Call initFirebase() first.');
@@ -101,18 +122,10 @@ export function createFirebaseStorage() {
}, },
async getCollection(collectionPath, queryConstraints = []) { async getCollection(collectionPath, queryConstraints = []) {
// Translate neutral {__type} constraints to firebase SDK builders. const { fbConstraints, offsetN } = toSdkConstraints(queryConstraints);
const fbConstraints = [];
for (const c of queryConstraints || []) {
if (!c || !c.__type) continue;
if (c.__type === 'where') fbConstraints.push(where(c.field, c.op, c.value));
else if (c.__type === 'orderBy') fbConstraints.push(orderBy(c.field, c.dir || 'asc'));
else if (c.__type === 'limit') fbConstraints.push(limit(c.n));
// firebase SDK has no offset; skip (UI uses server adapter in dev).
}
const q = fbConstraints.length ? query(collection(db, collectionPath), ...fbConstraints) : collection(db, collectionPath); const q = fbConstraints.length ? query(collection(db, collectionPath), ...fbConstraints) : collection(db, collectionPath);
const snapshot = await getDocsReal(q); const snapshot = await getDocsReal(q);
return snapshot.docs.map(d => ({ id: d.id, ...d.data() })); return snapshot.docs.slice(offsetN).map(d => ({ id: d.id, ...d.data() }));
}, },
async countCollection(collectionPath) { async countCollection(collectionPath) {
@@ -174,19 +187,12 @@ export function createFirebaseStorage() {
subscribeCollection(collectionPath, cb, queryConstraints = [], errCb) { subscribeCollection(collectionPath, cb, queryConstraints = [], errCb) {
recordAdapterCall({ fn: 'subscribeCollection', path: collectionPath }); recordAdapterCall({ fn: 'subscribeCollection', path: collectionPath });
// queryConstraints = neutral {__type} builders (from index.js). const { fbConstraints, offsetN } = toSdkConstraints(queryConstraints);
// Translate to SDK orderBy/limit. const q = fbConstraints.length > 0
const sdkConstraints = queryConstraints.map(c => { ? query(collection(db, collectionPath), ...fbConstraints)
if (c.__type === 'where') return where(c.field, c.op, c.value);
if (c.__type === 'orderBy') return orderBy(c.field, c.dir);
if (c.__type === 'limit') return limit(c.n);
return c; // pass-through (forward compat)
});
const q = sdkConstraints.length > 0
? query(collection(db, collectionPath), ...sdkConstraints)
: collection(db, collectionPath); : collection(db, collectionPath);
return onSnapshot(q, (snap) => { return onSnapshot(q, (snap) => {
cb(snap.docs.map(d => ({ id: d.id, ...d.data() }))); cb(snap.docs.slice(offsetN).map(d => ({ id: d.id, ...d.data() })));
}, (err) => { }, (err) => {
console.error(`subscribeCollection ${collectionPath}:`, err); console.error(`subscribeCollection ${collectionPath}:`, err);
if (typeof errCb === 'function') errCb(err); if (typeof errCb === 'function') errCb(err);