From 995347d255c282e2ff8cc0c4052d554b7c6cc11b Mon Sep 17 00:00:00 2001 From: robert Date: Mon, 6 Jul 2026 22:50:29 -0400 Subject: [PATCH] Fix white /logs page in firebase mode: emulate offset in adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- package-lock.json | 28 ++++++------------------ server/package.json | 2 +- src/storage/contract.js | 18 ++++++++++++++++ src/storage/firebase.js | 48 +++++++++++++++++++++++------------------ 4 files changed, 53 insertions(+), 43 deletions(-) diff --git a/package-lock.json b/package-lock.json index 891fd1e..7043c9f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7151,14 +7151,17 @@ "license": "MIT" }, "node_modules/better-sqlite3": { - "version": "11.10.0", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", - "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", + "version": "12.11.1", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz", + "integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==", "hasInstallScript": true, "license": "MIT", "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" } }, "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": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", @@ -23021,7 +23007,7 @@ "version": "0.1.0", "dependencies": { "@ttrpg/shared": "*", - "better-sqlite3": "^11.3.0", + "better-sqlite3": "^12.0.0", "cors": "^2.8.5", "express": "^4.19.2", "nanoid": "^5.0.7", diff --git a/server/package.json b/server/package.json index 787a390..c2b7968 100644 --- a/server/package.json +++ b/server/package.json @@ -11,7 +11,7 @@ }, "dependencies": { "@ttrpg/shared": "*", - "better-sqlite3": "^11.3.0", + "better-sqlite3": "^12.0.0", "cors": "^2.8.5", "express": "^4.19.2", "nanoid": "^5.0.7", diff --git a/src/storage/contract.js b/src/storage/contract.js index d320223..39d9c71 100644 --- a/src/storage/contract.js +++ b/src/storage/contract.js @@ -294,6 +294,7 @@ function runStorageContract(name, factory) { describe('subscribeCollection queryConstraints', () => { const orderByC = (field, dir) => ({ __type: 'orderBy', field, dir }); const limitC = (n) => ({ __type: 'limit', n }); + const offsetC = (n) => ({ __type: 'offset', offset: n }); beforeEach(async () => { // seed 5 log docs, timestamps out of order @@ -324,6 +325,23 @@ function runStorageContract(name, factory) { }); 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([]); + }); }); }); } diff --git a/src/storage/firebase.js b/src/storage/firebase.js index 939863b..2f3da24 100644 --- a/src/storage/firebase.js +++ b/src/storage/firebase.js @@ -71,6 +71,27 @@ export function getAuthInstance() { return authInstance; } // App.js can now import { storage } and call storage.setDoc(path, data). // 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() { const db = dbInstance; if (!db) throw new Error('Firestore not initialized. Call initFirebase() first.'); @@ -101,18 +122,10 @@ export function createFirebaseStorage() { }, async getCollection(collectionPath, queryConstraints = []) { - // Translate neutral {__type} constraints to firebase SDK builders. - 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 { fbConstraints, offsetN } = toSdkConstraints(queryConstraints); const q = fbConstraints.length ? query(collection(db, collectionPath), ...fbConstraints) : collection(db, collectionPath); 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) { @@ -174,19 +187,12 @@ export function createFirebaseStorage() { subscribeCollection(collectionPath, cb, queryConstraints = [], errCb) { recordAdapterCall({ fn: 'subscribeCollection', path: collectionPath }); - // queryConstraints = neutral {__type} builders (from index.js). - // Translate to SDK orderBy/limit. - const sdkConstraints = queryConstraints.map(c => { - 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) + const { fbConstraints, offsetN } = toSdkConstraints(queryConstraints); + const q = fbConstraints.length > 0 + ? query(collection(db, collectionPath), ...fbConstraints) : collection(db, collectionPath); 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) => { console.error(`subscribeCollection ${collectionPath}:`, err); if (typeof errCb === 'function') errCb(err);