9.4 KiB
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:
iddata()exists()(function form for Firestore compatibility)- optional
ref
Required getDoc() return shape
getDoc(...) must return a DocumentSnapshot-like object:
iddata()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:
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:
getDocs(query(...))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:
// 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:
-
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 causevalidation_min_text_constraintorvalidation_invalid_formaterrors oncreate. Arbitrary strings with underscores or hyphens also fail format validation. -
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:
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.idviewRule:userId = @request.auth.idcreateRule:@request.auth.id != ""updateRule:userId = @request.auth.iddeleteRule: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:
// 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:
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:
// 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:
// 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.sizequerySnapshot.forEach(...)getDocs(collection((not just query)setDoc(..., { merge: true })— adapter ignores mergesetDoc(..., { ...spread, lastUpdated })— check if target collection has adatajson fieldgetDoc(doc(db, 'users', uid, 'settings', name))— check ifgetDochas name-based fallback
If these appear, adapter must provide full snapshot compatibility and query-based setDoc/getDoc.
Practical migration pattern
- First restore file parity vs known-good reference (HTML + page JS).
- Normalize backend import path (
firebase.js→pocketbase.js). - Fix adapter contract (
getDocs,onSnapshot, snapshot shape). - Fix
setDocandgetDocfor settings-style docs (query-based upsert + name fallback). - Update all
handleSaveAccountSettings()sites across every page's JS file to use{ data: {...}, userId, name }pattern. - Ensure the
settingscollection has adatajson field added to its schema. - 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.