Files
hermes-config/skills/software-development/web-ui-repair/references/pocketbase-adapter-firestore-compat.md
T
2026-07-12 10:17:17 -04:00

239 lines
9.4 KiB
Markdown

# 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.