239 lines
9.4 KiB
Markdown
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.
|