# PocketBase Adapter: Firestore Compatibility Contract Use this when the app UI looks correct but core functions (dashboard counts, appointments load, customer lists, quote generation flows) still fail after a Firebase→PocketBase migration. ## Why this matters In this class of app, page modules are written against Firestore semantics. If `pocketbase.js` does not emulate that contract, you get broad functional breakage even when HTML/JS files appear aligned. ## Required `getDocs()` return shape `getDocs(...)` must return a QuerySnapshot-like object, not a raw array: - `docs` (array of doc snapshots) - `size` (number) - `empty` (boolean) - `forEach(callback)` Each doc in `docs` must expose: - `id` - `data()` - `exists()` (function form for Firestore compatibility) - optional `ref` ## Required `getDoc()` return shape `getDoc(...)` must return a DocumentSnapshot-like object: - `id` - `data()` - `exists()` **as a function** (not boolean property) - `ref` (at minimum `{ id }`) Why this matters: many legacy code paths call `if (snap.exists())` or check `snap.ref.id` in details/edit flows. Returning `exists: true` (boolean) breaks with runtime errors such as `snap.exists is not a function`. ## Required `addDoc()` behavior for sub-collection patterns Legacy code often writes records via sub-collection syntax: `collection(db, 'users', uid, 'services')`. The adapter must inject the userId into the stored record so filtered queries (`where("userId", "==", uid)`) find it: ```javascript async function addDoc(collRef, data) { const collName = collRef._name; const pbData = convertToPB(data); if (collRef._userId && !pbData.userId) { pbData.userId = collRef._userId; } const record = await pb.collection(collName).create(pbData); return { id: record.id }; } ``` Without this injection, records created via sub-collection refs appear to "save" (no error thrown) but never appear in subsequent filtered queries — users see "nothing saved" despite no error feedback. This matters for code paths creating records under `users/{uid}/services`, `users/{uid}/customers`, `users/{uid}/appointments`, `users/{uid}/repairOrders`. ## Required input compatibility `getDocs(...)` must accept both: 1. `getDocs(query(...))` 2. `getDocs(collection(...))` Many legacy modules mix both patterns. Supporting only query objects silently breaks dashboard/customer/appointment code paths. ## `onSnapshot(...)` minimum compatibility If realtime is shimmed, callback shape must still match caller expectations: - For query/collection refs: callback receives snapshot object with `docs/size/empty/forEach` - For doc refs: callback receives doc snapshot-like object A one-shot shim is acceptable for non-realtime pages, but document that limitation and avoid claiming full realtime parity. ## ⚠️ `setDoc()` must use query-based upsert (PocketBase v0.23+) **The old pattern is broken:** ```javascript // BROKEN on PocketBase v0.23+ function setDoc(docRef, data) { const collName = docRef._collection; const docId = docRef._id; const pbData = convertToPB(data); return pb.collection(collName).update(docId, pbData).catch(() => pb.collection(collName).create({ ...pbData, id: docId }) ).then(rec => ({ id: rec.id })); } ``` Two failures: 1. **Custom system IDs rejected.** PocketBase v0.23+ requires system IDs ≥ 15 characters. `'appSettings'` (11 chars), `'accountSettings'` (15 chars, barely valid), and similar short custom IDs cause `validation_min_text_constraint` or `validation_invalid_format` errors on `create`. Arbitrary strings with underscores or hyphens also fail format validation. 2. **Unknown fields dropped on create.** The `update().catch(() => create())` pattern tries to create with raw settings fields (`darkMode`, `taxRate`, ...) that aren't in the collection schema. PocketBase silently drops them or rejects the create. **The correct pattern — query-based upsert by `name + userId`:** ```javascript function setDoc(docRef, data) { const collName = docRef._collection; const docId = docRef._id; const pbData = convertToPB(data); const userId = docRef._userId || ''; // Find existing record by name + userId, then update-or-create return pb.collection(collName).getFullList({ filter: `name = "${docId}"${userId ? ` && userId = "${userId}"` : ''}`, requestKey: null }).then(records => { if (records.length > 0) { return pb.collection(collName).update(records[0].id, pbData); } else { return pb.collection(collName).create(pbData); } }).then(rec => ({ id: rec.id })); } ``` This avoids setting system IDs entirely (lets PocketBase auto-generate them) and uses the `name` + `userId` fields for reliable lookup. ### Required schema for settings-style collections When using this pattern, the target collection must have these fields: | Field | Type | Required | Purpose | |----------|--------|----------|---------| | `userId` | text | yes | Owner ID for collection rules (`userId = @request.auth.id`) | | `name` | text | yes | Document key — matches the 4th argument of `doc(db, 'users', uid, 'settings', 'name')` | | `data` | json | no | Arbitrary settings payload (entire `appSettings` object) | Collection rules should be: - `listRule`: `userId = @request.auth.id` - `viewRule`: `userId = @request.auth.id` - `createRule`: `@request.auth.id != ""` - `updateRule`: `userId = @request.auth.id` - `deleteRule`: `userId = @request.auth.id` ## ⚠️ `getDoc()` needs name-based fallback When records are created without predictable system IDs (auto-generated by PocketBase), a direct `getOne(docId)` call uses the doc ID from the Firestore ref path, which won't match the system ID. Example: ```javascript // Firestore ref: doc(db, 'users', uid, 'settings', 'appSettings') // Adapter creates: { _collection: 'settings', _id: 'appSettings', _userId: uid } // PocketBase getOne('appSettings') fails — no record with that system ID exists ``` **Fix:** Add a name-based fallback query when `getOne(id)` returns 404: ```javascript async function getDoc(docRef) { const collName = docRef._collection; const docId = docRef._id; const userId = docRef._userId || ''; try { // Try direct lookup by system ID (for legacy records) let record = await pb.collection(collName).getOne(docId); return { id: record.id, data: () => convertFromPB(record), exists: () => true, ref: { id: record.id } }; } catch (err) { // Fallback: look up by name (for query-based-created records) try { const filter = `name = "${docId}"${userId ? ` && userId = "${userId}"` : ''}`; const records = await pb.collection(collName).getFullList({ filter, requestKey: null }); if (records.length > 0) { const record = records[0]; return { id: record.id, data: () => convertFromPB(record), exists: () => true, ref: { id: record.id } }; } } catch (e2) { /* not found */ } return { exists: () => false, data: () => null, ref: { id: docId } }; } } ``` ## Settings storage pattern for PocketBase The app's `settings.js` uses `saveAllSettings()` and `loadSettings()` to persist a complex settings object. After migrating to PocketBase: **Save:** ```javascript // Instead of spreading fields: // setDoc(ref, { ...appSettings, lastUpdated: ... }); // FAILS: unknown fields // Use the 'data' json field: setDoc(ref, { data: { ...appSettings, lastUpdated: new Date().toISOString() }, userId: currentUser.uid, name: 'appSettings' }); ``` **Load:** ```javascript // Instead of reading raw fields: // const { lastUpdated, ...clean } = settingsDoc.data(); // appSettings = { ...appSettings, ...clean }; // Use the 'data' json field: if (record && record.data) { const { lastUpdated, ...cleanSettings } = record.data; appSettings = { ...appSettings, ...cleanSettings }; } ``` Apply the same pattern for account settings (`name: 'accountSettings'`) and any settings-type document. ## Fast verification checklist Search target code for these usage patterns: - `querySnapshot.docs.map(...)` - `if (querySnapshot.empty) ...` - `querySnapshot.size` - `querySnapshot.forEach(...)` - `getDocs(collection(` (not just query) - `setDoc(..., { merge: true })` — adapter ignores merge - `setDoc(..., { ...spread, lastUpdated })` — check if target collection has a `data` json field - `getDoc(doc(db, 'users', uid, 'settings', name))` — check if `getDoc` has name-based fallback If these appear, adapter must provide full snapshot compatibility and query-based setDoc/getDoc. ## Practical migration pattern 1. First restore file parity vs known-good reference (HTML + page JS). 2. Normalize backend import path (`firebase.js` → `pocketbase.js`). 3. Fix adapter contract (`getDocs`, `onSnapshot`, snapshot shape). 4. **Fix `setDoc` and `getDoc` for settings-style docs** (query-based upsert + name fallback). 5. **Update all `handleSaveAccountSettings()` sites** across every page's JS file to use `{ data: {...}, userId, name }` pattern. 6. **Ensure the `settings` collection has a `data` json field** added to its schema. 7. Re-check the four core flows: - quote generation - appointment setting - dashboard actions/cards/modals - customer page search/edit/actions This avoids wasting time patching dozens of per-page handlers when the real fault is the adapter contract.