initial commit

This commit is contained in:
ray
2026-07-12 10:17:17 -04:00
commit dab5a4ebc6
1424 changed files with 330463 additions and 0 deletions
@@ -0,0 +1,139 @@
# PocketBase Adapter — Firestore Compatibility Patterns
Critical return-shape requirements that the PocketBase adapter MUST satisfy for multi-file Firebase apps to work without per-file changes.
## 1. `getDoc` — `exists()` MUST be a function
```javascript
// BUG: returns boolean — every consumer calls .exists() and gets TypeError
return { exists: true, data: () => ... };
// FIX: returns function — matches Firestore SDK API
return {
id: record.id,
data: () => convertFromPB(record),
exists: () => true, // <-- function, not boolean
ref: { id: record.id }
};
```
**Why this bites you:** The Firebase SDK documents `exists()` as a method. Every consumer in the migrated app does `if (snap.exists()) { ... }`. A boolean `true` is truthy so `if (snap.exists)` evaluates `if (true)` and the branch runs. But if the record is **not found**, the boolean `false` means `if (false)` skips the branch — which happens to pass in that case. The real crash is: `snap.exists()` returning `false` vs boolean `false` being the same — but a missing `exists` key entirely kills settings pages, customer details, and any code path that calls `exists()`.
## 2. `getDocs` — MUST return snapshot shape, not raw array
```javascript
// Firestore consumer code (ubiquitous):
const results = await getDocs(q);
results.docs.map(doc => ({ id: doc.id, ...doc.data() }));
results.forEach(doc => { ... });
if (results.empty) { ... }
console.log(results.size); // <-- also used
// PocketBase adapter BUG: returns a bare array
// → docs.map crashes (TypeError: .docs is undefined)
// → .empty, .size are undefined
// FIX: createQuerySnapshot() helper
function createQuerySnapshot(docs) {
const docList = Array.isArray(docs) ? docs : [];
return {
docs: docList,
size: docList.length,
empty: docList.length === 0,
forEach(callback) { docList.forEach(callback); }
};
}
async function getDocs(qOrCollectionRef) {
// ... do query, get items from PocketBase ...
const docs = items.map(rec => ({
id: rec.id,
data: () => convertFromPB(rec),
exists: true,
ref: { id: rec.id }
}));
return createQuerySnapshot(docs);
}
```
## 3. `addDoc` — MUST inject userId from sub-collection refs
```javascript
// App code (Firebase sub-collection pattern):
const servicesRef = collection(db, 'users', currentUser.uid, 'services');
await addDoc(servicesRef, { name: 'Brake Pads', price: 199.99 });
// PocketBase adapter — the ref has _userId tracked but addDoc ignores it
// BUG: record stored in 'services' collection WITHOUT userId field
// → queries with where('userId', '==', currentUser.uid) return nothing
// FIX:
async function addDoc(collRef, data) {
const pbData = convertToPB(data);
if (collRef._userId && !pbData.userId) {
pbData.userId = collRef._userId; // <-- inject from ref
}
const record = await pb.collection(collRef._name).create(pbData);
return { id: record.id };
}
```
## 4. `onSnapshot` — MUST pass proper snapshot shape
```javascript
// Consumer code expects:
onSnapshot(query, (snapshot) => {
snapshot.forEach(doc => { ... }); // <-- pulls from snapshot.docs
snapshot.docs.map(...); // <-- same
snapshot.empty; // <-- also used
});
// BUG: bare getDocs().then(docs => callback(snapshot from createQuerySnapshot))
// must pass the full snapshot, not raw docs
// FIX:
function onSnapshot(queryOrDocRef, callback) {
if (queryOrDocRef && (queryOrDocRef._type === 'query' || queryOrDocRef._type === 'collection')) {
getDocs(queryOrDocRef).then(snapshot => callback(snapshot));
} else {
getDoc(queryOrDocRef).then(doc => callback(doc));
}
return () => {};
}
```
## Diagnosis: How to find these bugs
```javascript
// In browser DevTools console:
// 1) Check getDocs return shape
const snap = await getDocs(query(collection(db, 'repairOrders'), where('userId', '==', '...')));
console.assert(Array.isArray(snap.docs), 'getDocs.docs must be array');
console.assert(typeof snap.empty === 'boolean', 'getDocs.empty must be boolean');
console.assert(typeof snap.size === 'number', 'getDocs.size must be number');
console.assert(typeof snap.forEach === 'function', 'getDocs.forEach must be function');
// 2) Check getDoc return shape
const doc = await getDoc(doc(db, 'repairOrders', 'some-id'));
console.assert(typeof doc.exists === 'function', 'getDoc.exists must be function, got ' + typeof doc.exists);
console.assert(typeof doc.data === 'function', 'getDoc.data must be function');
// 3) Check addDoc injects userId
const ref = collection(db, 'users', 'test-uid', 'services');
const result = await addDoc(ref, { name: 'test' });
const saved = await getDoc(doc(db, 'services', result.id));
console.assert(saved.data().userId === 'test-uid', 'addDoc must inject userId from ref');
```
## Common consumer patterns across app files
These patterns appear in `dashboard.js`, `appointments.js`, `customers.js`, `repair-orders.js`, `settings.js`, `quote-tab-manager.js`, `customer-lookup.js`, and `shared/data-manager.js`:
| Pattern | Found in | Breaks if... |
|---|---|---|
| `querySnapshot.docs.map(...)` | All files | `getDocs` returns raw array |
| `querySnapshot.empty` | `repair-orders.js`, `appointments.js`, `settings.js` | Snapshot has no `.empty` |
| `querySnapshot.size` | `dashboard.js`, `repair-orders.js`, `customers.js`, `customer-lookup.js` | Snapshot has no `.size` |
| `querySnapshot.forEach(doc => {})` | `customers.js`, `customer-lookup.js`, `dashboard.js`, `settings.js`, `shared/data-manager.js` | Snapshot has no `.forEach` |
| `if (docSnap.exists())` | `quote-tab-manager.js`, `repair-orders.js`, `settings.js`, `dashboard.js`, `invoice-manager.js`, `customers.js` | `exists` is boolean not function |
| `collection(db, 'users', uid, '...')` | All files (sub-collection pattern) | `addDoc` doesn't inject `userId` |
@@ -0,0 +1,236 @@
#!/usr/bin/env python3
"""
PocketBase v0.23+ Collection Schema Setup
Complete Firebase,PocketBase migration: define all fields for every collection,
then apply userId-based multi-tenancy rules.
The two-step process (schema first with empty rules, then rules) is required
because PocketBase validates rule expressions against existing fields.
IMPORTANT: This list MUST include EVERY field the web app writes.
PocketBase silently drops fields not in the schema (HTTP 200, no error).
Compare against: grep -rn '\.write\|formData\.\|newRO\.\|updateData\.\|financial\b' *.js
to find all written fields before deployment.
"""
import urllib.request, json, ssl, sys
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
BASE = "https://grajmedia.duckdns.org:3447" # change to your domain
ADMIN_EMAIL = "admin@example.com"
ADMIN_PASS = "your-password"
def api(method, path, data=None, token=None):
headers = {"Content-Type": "application/json"}
if token:
headers["Authorization"] = token
req = urllib.request.Request(
f"{BASE}{path}",
data=json.dumps(data).encode() if data else None,
headers=headers,
method=method
)
try:
resp = urllib.request.urlopen(req, context=ctx)
if resp.status == 204:
return {}
return json.loads(resp.read())
except urllib.error.HTTPError as e:
body = e.read().decode()
print(f" ERROR {e.code}: {body[:200]}")
return None
# --- Auth ---
auth = api("POST", "/api/collections/_superusers/auth-with-password",
{"identity": ADMIN_EMAIL, "password": ADMIN_PASS})
token = auth["token"]
# --- Collection Schemas ---
# Note: v0.23+ uses 'fields' key (not 'schema' from v0.22)
# Include the system 'id' field when PATCHing -- PocketBase requires it
ID_FIELD = {
"autogeneratePattern": "[a-z0-9]{15}",
"name": "id", "type": "text", "system": True, "required": True,
"max": 15, "min": 15, "pattern": "^[a-z0-9]+$",
"presentable": False, "primaryKey": True
}
# IMPORTANT: Every field the web app's JavaScript writes MUST be in this list.
# Missing fields are silently dropped by PocketBase on write.
NEW_FIELDS = {
"repairOrders": [
{"name": "userId", "type": "text", "required": True},
{"name": "customerName", "type": "text"},
{"name": "customerEmail", "type": "text"},
{"name": "customerPhone", "type": "text"},
{"name": "deviceModel", "type": "text"},
{"name": "deviceType", "type": "text"},
{"name": "issue", "type": "text"},
{"name": "status", "type": "text"}, # waiter / drop-off
{"name": "workStatus", "type": "text"}, # in-progress / waiting-for-parts / completed / waiting-for-pickup
{"name": "roNumber", "type": "text"}, # RO-XXXXXXX
{"name": "vehicleInfo", "type": "text"}, # "2020 Honda Accord"
{"name": "vin", "type": "text"}, # 17-char VIN
{"name": "mileage", "type": "text"},
{"name": "services", "type": "text"}, # comma-separated service names
{"name": "estimatedTime", "type": "text"}, # hours as string
{"name": "promisedTime", "type": "text"}, # ISO datetime string
{"name": "lastModified", "type": "text"}, # ISO datetime string
{"name": "completedTime", "type": "text"}, # ISO datetime string
{"name": "financial", "type": "json"}, # {cpTotal,cpCost,whTotal,whCost,grossProfit,...}
{"name": "priority", "type": "text"},
{"name": "createdAt", "type": "text"},
{"name": "updatedAt", "type": "text"},
{"name": "lastActivity", "type": "text"},
{"name": "technician", "type": "text"},
{"name": "notes", "type": "text"},
{"name": "price", "type": "number"},
{"name": "customerId", "type": "text"},
{"name": "quoteId", "type": "text"},
{"name": "invoiceId", "type": "text"},
{"name": "writeupTime", "type": "text"},
],
"appointments": [
{"name": "userId", "type": "text", "required": True},
{"name": "customerName", "type": "text"},
{"name": "customerEmail", "type": "text"},
{"name": "customerPhone", "type": "text"},
{"name": "appointmentDateTime", "type": "text"},
{"name": "vehicleInfo", "type": "text"},
{"name": "serviceType", "type": "text"},
{"name": "lastModified", "type": "text"},
{"name": "status", "type": "text"},
{"name": "type", "type": "text"},
{"name": "notes", "type": "text"},
{"name": "createdAt", "type": "text"},
{"name": "updatedAt", "type": "text"},
{"name": "repairOrderId", "type": "text"},
{"name": "duration", "type": "number"},
],
"quotes": [
{"name": "userId", "type": "text", "required": True},
{"name": "customerName", "type": "text"},
{"name": "customerEmail", "type": "text"},
{"name": "customerPhone", "type": "text"},
{"name": "deviceModel", "type": "text"},
{"name": "deviceType", "type": "text"},
{"name": "issue", "type": "text"},
{"name": "status", "type": "text"},
{"name": "createdAt", "type": "text"},
{"name": "updatedAt", "type": "text"},
{"name": "subtotal", "type": "number"},
{"name": "tax", "type": "number"},
{"name": "total", "type": "number"},
{"name": "notes", "type": "text"},
{"name": "customerId", "type": "text"},
{"name": "repairOrderId", "type": "text"},
],
"customers": [
{"name": "userId", "type": "text", "required": True},
{"name": "name", "type": "text"},
{"name": "email", "type": "text"},
{"name": "phone", "type": "text"},
{"name": "address", "type": "text"},
{"name": "vehicleInfo", "type": "text"},
{"name": "vin", "type": "text"},
{"name": "lastModified", "type": "text"},
{"name": "lastService", "type": "text"},
{"name": "preferredContact", "type": "text"}, # phone / email / text
{"name": "notes", "type": "text"},
{"name": "createdAt", "type": "text"},
{"name": "updatedAt", "type": "text"},
{"name": "lastVisit", "type": "text"},
{"name": "totalVisits", "type": "number"},
],
"services": [
{"name": "userId", "type": "text", "required": True},
{"name": "name", "type": "text", "required": True},
{"name": "description", "type": "text"},
{"name": "price", "type": "number"},
{"name": "category", "type": "text"},
{"name": "duration", "type": "number"},
{"name": "createdAt", "type": "text"},
{"name": "updatedAt", "type": "text"},
],
"tasks": [
{"name": "userId", "type": "text", "required": True},
{"name": "title", "type": "text", "required": True},
{"name": "description", "type": "text"},
{"name": "status", "type": "text"},
{"name": "priority", "type": "text"},
{"name": "dueDate", "type": "text"},
{"name": "createdAt", "type": "text"},
{"name": "updatedAt", "type": "text"},
{"name": "assignedTo", "type": "text"},
{"name": "repairOrderId", "type": "text"},
],
"settings": [
{"name": "userId", "type": "text", "required": True},
{"name": "name", "type": "text", "required": True},
],
}
RULES = {
"listRule": "userId = @request.auth.id",
"viewRule": "userId = @request.auth.id",
"createRule": "@request.auth.id != ''",
"updateRule": "userId = @request.auth.id",
"deleteRule": "userId = @request.auth.id",
}
# --- Apply schemas ---
cols = api("GET", "/api/collections", token=token)
col_map = {c["name"]: c["id"] for c in cols.get("items", []) if c["type"] == "base"}
for name, new_fields in NEW_FIELDS.items():
print(f"\n=== {name} ===")
col_id = col_map.get(name)
if not col_id:
print(f" Collection not found -- create it first")
continue
# Get existing fields (especially the system 'id' field)
full = api("GET", f"/api/collections/{col_id}", token=token)
if not full:
continue
existing = full.get("fields", [])
existing_names = {f["name"] for f in existing}
# Merge: keep all existing fields, add new ones
merged = existing.copy()
added = 0
for nf in new_fields:
if nf["name"] not in existing_names:
if "required" not in nf:
nf["required"] = False
merged.append(nf)
added += 1
if added == 0:
print(f" All fields already exist -- skipping")
continue
# Step 1: Set fields with empty rules
result = api("PATCH", f"/api/collections/{col_id}", {
"fields": merged,
"listRule": "", "viewRule": "", "createRule": "",
"updateRule": "", "deleteRule": ""
}, token=token)
if result:
fields_out = [f["name"] for f in result.get("fields", [])]
print(f" ✓ Added {added} fields , {fields_out}")
# Step 2: Apply rules now that fields exist
result2 = api("PATCH", f"/api/collections/{col_id}", RULES, token=token)
if result2:
print(f" ✓ Rules applied")
else:
print(f" ✗ Rules failed")
else:
print(f" ✗ Schema update failed")
print("\nDone!")
@@ -0,0 +1,57 @@
# PocketBase Collection Creation Script
# Create all collections needed for a Firebase migration.
# Run from the Docker host: python3 create_collections.py
import urllib.request
import json
BASE = "http://127.0.0.1:8091"
EMAIL = "admin@example.com"
PASS = "your-admin-password"
def api(method, path, data=None, token=None):
headers = {"Content-Type": "application/json"}
if token: headers["Authorization"] = token
req = urllib.request.Request(f"{BASE}{path}",
data=json.dumps(data).encode() if data else None,
headers=headers, method=method)
try:
resp = urllib.request.urlopen(req)
return json.loads(resp.read())
except urllib.error.HTTPError as e:
body = e.read().decode()
if "exists" in body.lower() or "unique" in body.lower():
return None # Already exists, OK
print(f" ERROR {method} {path}: HTTP {e.code}{body[:300]}")
return None
# Auth
auth = api("POST", "/api/collections/_superusers/auth-with-password",
{"identity": EMAIL, "password": PASS})
token = auth["token"]
# Define your collections — add our app's collections
COLLECTIONS = [
("repairOrders", [{"name": "userId", "type": "text", "required": True}]),
("appointments", [{"name": "userId", "type": "text", "required": True}]),
("quotes", [{"name": "userId", "type": "text", "required": True}]),
("tasks", [{"name": "userId", "type": "text", "required": True}]),
("services", [{"name": "userId", "type": "text", "required": True}]),
("customers", [{"name": "userId", "type": "text", "required": True}]),
("settings", [
{"name": "userId", "type": "text", "required": True},
{"name": "name", "type": "text", "required": True},
]),
]
# Create collections (empty rules first — rules fail if they reference fields before schema exists)
for name, schema in COLLECTIONS:
result = api("POST", "/api/collections", {
"name": name, "type": "base", "schema": schema,
"listRule": "", "viewRule": "", "createRule": "",
"updateRule": "", "deleteRule": ""
}, token)
if result: print(f"Created: {name}")
else: print(f"Skipped: {name} (exists or error)")
print("Done!")
@@ -0,0 +1,196 @@
# Debugging Firebase → PocketBase Migration Bugs
Common bug patterns discovered during migration validation, with root cause analysis and fix strategies.
## 1. Dual Render Path Inconsistency
After completing a repair order, it remains visible in the Active tab despite `workStatus` being set to `'completed'`.
### Root Cause
The app has **two render paths** that apply different filters:
```
loadRepairOrders() renderActiveRepairOrders()
│ │
│ filter: workStatus !== │ NO FILTER — passes entire
│ 'completed' && status !== │ repairOrders array to
│ 'completed' │ renderRepairOrders()
│ │
▼ ▼
renderRepairOrders(filtered) renderRepairOrders(all)
```
- **`loadRepairOrders()`** (called on page load) fetches from DB, then filters out completed orders at lines ~2276-2278
- **`renderActiveRepairOrders()`** (called after every UI mutation) renders the local `repairOrders` array **without any filter**
When `handleFinancialCompletion()` sets `workStatus = 'completed'` and calls `renderActiveRepairOrders()`, the completed RO stays visible because the re-render path doesn't filter.
### Fix
```javascript
function renderActiveRepairOrders() {
const activeOrders = repairOrders.filter(ro => {
return ro.workStatus !== 'completed' && ro.status !== 'completed';
});
renderRepairOrders(activeOrders);
}
```
This uses the same filter predicate as `loadRepairOrders()`.
### How to detect
Search for all functions that render the same list. The app typically has:
- A **load** function (fetches from DB, applies filters, assigns to global `repairOrders`, calls render)
- A **render** function (re-renders from the global array, used for reactive UI updates)
If the load function filters but the render function doesn't, this bug exists. To verify:
```
grep -n "function renderActiveRepairOrders\|function loadRepairOrders\|function renderRepairOrders" *.js
```
Then check:
1. Does `loadRepairOrders()` filter out completed items before calling `renderRepairOrders()`?
2. Does `renderActiveRepairOrders()` pass the raw array or a filtered one?
### Search pattern
```
grep -n "function.*render.*Orders\|function.*load.*Orders\|\.filter.*completed" repair-orders.js
```
---
## 2. Silent Errors in setTimeout / Asynchronous Callbacks
A DOM element is missing from the HTML, causing a `TypeError` that crashes silently. The UI appears to "do nothing" when a button is clicked, with no console error visible.
### Root Cause
```javascript
// Outer try/catch:
try {
// ... sync code ...
setTimeout(() => {
// This code runs AFTER the try/catch scope has exited
document.getElementById('missing-element').textContent = value;
// TypeError here is UNCAUGHT — it fires in the event loop tick
// after the try/catch already returned
}, 100);
return; // <-- try/catch scope exits
} catch (error) {
// Never reaches here for the setTimeout error
console.error('Error:', error);
}
```
The `try/catch` only protects the code inside its lexical scope. `setTimeout` (and `Promise.then`, `requestAnimationFrame`, `setInterval`) execute in a **new execution context** — the outer `try/catch` is already gone.
### How to find
1. Look for DOM operations inside `setTimeout()`, `requestAnimationFrame()`, or `.then()` callbacks
2. Check if they're inside a `try/catch` — if the `try/catch` wraps the `setTimeout` call (not the callback body), the callback is unprotected
3. The ONLY way to catch these is a `try/catch` **inside** the callback, or `window.onerror` / `window.addEventListener('unhandledrejection', ...)`
### Fix
```javascript
// Option A: try/catch INSIDE the callback
setTimeout(() => {
try {
document.getElementById('missing-element').textContent = value;
} catch (innerError) {
console.error('setTimeout error:', innerError);
}
}, 100);
// Option B: Replace setTimeout with synchronous code when possible
// (Only if the delay isn't needed for DOM readiness)
```
### Detection script
Run this in browser DevTools to detect pattern:
```javascript
// Check all JS files for setTimeout with DOM ops outside try/catch
document.querySelectorAll('script[src]').forEach(s => {
fetch(s.src).then(r => r.text()).then(code => {
const matches = code.match(/setTimeout\([^)]*getElementById[^)]*\)/g);
if (matches) console.log(`${s.src}: ${matches.length} vulnerable setTimeout(s)`);
});
});
```
---
## 3. "Looks like it works but data is missing" — PocketBase Silently Drops Undefined Fields
After a successful API call (HTTP 200), the saved record is missing fields the app wrote.
### Root Cause
PocketBase is **strictly typed** — fields not defined in the collection schema are **silently dropped** on write. Firestore is schemaless, so any field in the JavaScript object gets stored.
### How to find missing fields systematically
```bash
# 1. Get the PocketBase collection schema field names
PB_FIELDS=$(curl -s http://127.0.0.1:8091/api/collections/repairOrders | \
python3 -c "import sys,json; print('\n'.join(f['name'] for f in json.load(sys.stdin).get('fields',[])))")
# 2. Get all field names the JavaScript app writes (from CRUD operations)
JS_FIELDS=$(grep -ohP '\b(customerName|workStatus|roNumber|vehicleInfo|vin|mileage|services|estimatedTime|promisedTime|lastModified|completedTime|financial|writeupTime|status|userId|customerPhone|deviceModel|deviceType|issue|priority|technician|notes|price|customerId|quoteId|invoiceId|lastActivity|createdAt|updatedAt)\b' repair-orders.js | sort -u)
# 3. Compare — fields in JS that are NOT in PB schema
echo "Missing from PB schema:"
for f in $JS_FIELDS; do
echo "$PB_FIELDS" | grep -q "$f" || echo " - $f"
done
```
Commonly missed fields in a shop management app migration:
| Field | Type | Where It's Written |
|---|---|---|
| `workStatus` | text | `updateRepairOrderWorkStatus()`, `createNewRepairOrder()` |
| `financial` | json | `handleFinancialCompletion()` — nested object with cpTotal, cpCost, etc. |
| `roNumber` | text | `createNewRepairOrder()` — auto-generated RO-XXXXXXX |
| `vehicleInfo` | text | Repair order create form & customer data |
| `vin` | text | Repair order create form |
| `mileage` | text | Repair order create form |
| `services` | text | Repair order services field (comma-separated) |
| `estimatedTime` | text | Repair order promised time calculation |
| `promisedTime` | text | Created from estimatedTime + currentTime |
| `lastModified` | text | Updated on every mutation |
| `completedTime` | text | Set when workStatus becomes 'completed' |
### Symptom: "Completing an RO doesn't stick"
If the user reports: "I marked it complete, it showed completed, but after refresh it's back in Active" — this is ALWAYS a schema mismatch. The app updates the local JavaScript array (shows correct UI), but the PocketBase write silently drops `workStatus` and `financial` because those fields aren't in the collection schema. On page reload, `loadRepairOrders()` fetches from PB and the RO has its original `workStatus` (missing = undefined), so it passes the "not completed" filter.
This is subtly different from the Dual Render Path bug (which happens BEFORE refresh — RO stays in Active tab immediately). The schema mismatch bug: RO correctly disappears from Active tab after completion, but reappears on next page load.
### How to find
---
## 4. Reference Build Comparison Technique
When a Firebase build works but the PocketBase port doesn't, compare the same function in both:
```bash
# Find where functions live in both builds
grep -n "updateRepairOrderWorkStatus\|handleFinancialCompletion" \
/path/to/firebase/repair-orders.js \
/path/to/pocketbase/repair-orders.js
# Read and diff
diff <(sed -n '1747,1823p' /path/to/firebase/repair-orders.js) \
<(sed -n '1747,1823p' /path/to/pocketbase/repair-orders.js)
```
If the functions are **identical**, the bug is not in the PocketBase adapter — it's a pre-existing bug in the app code that was hidden by Firebase behavior (realtime listeners auto-fixing the UI, different execution timing, etc.). In this case, the Firebase build had the same `renderActiveRepairOrders` filter bug, but it may have appeared to work because:
- Users refreshed the page (which calls `loadRepairOrders()` with the filter)
- The issue existed but was never noticed because the financial completion was never tested end-to-end
@@ -0,0 +1,114 @@
# 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
```python
# 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:
```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
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
```javascript
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:**
```javascript
// 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:**
```javascript
// 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?