Files
2026-07-12 10:17:17 -04:00

4.3 KiB

PocketBase Settings Save/Load with JSON Fields

Problem

PocketBase collections with a fixed schema (e.g., id, userId, name text fields) can't store arbitrary key-value settings like {darkMode: true, taxRate: 9.25, ...}. Every field written to a PocketBase record MUST be defined in the collection schema, or it's silently dropped (HTTP 200, data lost).

Additionally, PocketBase v0.23+ enforces a minimum 15-character ID with format validation (lowercase alphanumeric only). Trying to setDoc with an ID like appSettings (11 chars) fails silently — the adapter's update('appSettings', data) → 404, fallback create({...data, id:'appSettings'}) → 400 validation_invalid_format.

The Fix: Three Layers

1. Add a json field to the collection

# PATCH the collection to add a JSON field
api("PATCH", f"/api/collections/settings", {
    "fields": [
        ...existing fields...,
        {"id": "json_data", "name": "data", "type": "json", "required": False, "system": False}
    ],
    ...keep existing rules...
}, token)

The json field type accepts arbitrary objects — PocketBase stores and retrieves them natively.

2. Fix setDoc in the adapter — query-based upsert

Don't try to set PocketBase's system id field to custom values. Instead, query by name + userId to find the record, then update or create:

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
    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 }));
}

3. Fix getDoc — fallback to name-based lookup

async function getDoc(docRef) {
    const collName = docRef._collection;
    const docId = docRef._id;
    const userId = docRef._userId || '';

    try {
        // Try direct ID lookup first (for backward compat)
        let record = await pb.collection(collName).getOne(docId);
        return { id: record.id, data: () => convertFromPB(record), exists: () => true, ref: { id: record.id } };
    } catch (err) {
        // Fallback: query by name + userId
        try {
            const filter = `name = "${docId}"${userId ? ` && userId = "${userId}"` : ''}`;
            const records = await pb.collection(collName).getFullList({ filter: 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) {}
        return { exists: () => false, data: () => null, ref: { id: docId } };
    }
}

4. Fix save/load callers — wrap data in data json field

Save:

// BEFORE (fails — PocketBase drops unknown fields)
await setDoc(userSettingsRef, { ...appSettings, lastUpdated: new Date().toISOString() });

// AFTER (works — settings go into the 'data' json field)
await setDoc(userSettingsRef, {
    data: { ...appSettings, lastUpdated: new Date().toISOString() },
    userId: currentUser.uid,
    name: 'appSettings'
});

Load:

// BEFORE (reads top-level fields — gets nothing useful)
const firebaseSettings = settingsDoc.data();
appSettings = { ...appSettings, ...firebaseSettings };

// AFTER (reads from 'data' json field)
const record = settingsDoc.data();
if (record && record.data) {
    const { lastUpdated, ...cleanSettings } = record.data;
    appSettings = { ...appSettings, ...cleanSettings };
}

Full Pattern Checklist

When settings aren't persisting:

  • Does the collection have a json field for arbitrary data?
  • Is setDoc using query-based upsert (not trying to set system IDs)?
  • Are all save callers wrapping data in {data: {...}, userId, name}?
  • Are all load callers reading from record.data instead of top-level fields?
  • Does every save path include userId and name for collection rule compliance?