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

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:

  • 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:

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:

// 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:

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:

// 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.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.jspocketbase.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.