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);