39 KiB
name, description, version, metadata
| name | description | version | metadata | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| pocketbase-development | PocketBase patterns: collection schemas, API quirks, sort-field traps, useToast provider, auth flow. | 1.2.0 |
|
PocketBase Development
Overview
Common patterns, pitfalls, and fixes when building React apps backed by PocketBase. Covers collection creation, API query patterns, auth integration, SPQ legacy data model, and runtime debugging.
Reference:
references/customer-db-integration.mddocuments the SPQ Customer DB integration pattern: name splitting (firstName/middleName/lastName with backward-compat auto-compose), customer record auto-creation from quote/appointment/RO saves (ensureCustomerRecord), client-side duplicate detection, customerId pass-through across quotes → ROs → appointments, and multi-source data population (vehicles → quotes → ROs) when selecting a customer.Reference:
references/spq-legacy-data-model.mddocuments the actual PocketBase schema for the ShopProQuote project — collection field names, vehicle data storage (no separatevehiclescollection), the combine-and-deduplicate pattern across repairOrders/quotes,customerNamevscustomerIdlinkage (ALL existing ROs and quotes have emptycustomerId), quote services JSON display patterns (expandable rows with service line items), and migration patterns from legacy flat-text fields.See also:
pocketbase-react-apps/references/spq-pb-field-mapping.mdfor the critical frontend-to-PB field name translation table (estimatedDuration → estimatedTime, customerType → financial JSON). Saves to wrong field names are silently dropped by PocketBase.Reference:
references/glitchtip-sentry-dsn-path-prefix.md— Sentry SDK v10 DSN parser rejects multi-segment paths (/glitchtip/1). Fix: change DSN to bare project ID (/1) and add nginx regex location.Reference:
references/cross-origin-debugging.md— Debugging the same React+PB app behaving differently on two domains (e.g.,shopproquote.graj-media.comvsgrajmedia.duckdns.org). Nginx config check → identical code → same backend → client-side localStorage stale state. Covers Zod validation failure tracing and thespq-quotelocalStorage key.Reference:
references/quote-to-ro-approved-services-sync.md— Syncing approved quote services back to the originating repair order when saving a quote generated from an RO (?fromRO=RO_ID). Covers the merge logic (same-name update vs append), status preservation, and the two save paths (handleSaveandensureShareToken).
Pitfalls
Filter ~ (contains) operator can return 400 on some collections
PocketBase 0.39.1 may return 400 Bad Request for the ~ (contains) filter operator on text fields in certain collections — even when the field and syntax are valid. The error message is the generic "Something went wrong while processing your request." with no field-level detail. This was observed on the customers collection's name and phone fields while the same SDK version handles = on userId without issue.
Fix: For collections with small record counts (single-shop data: customers, settings, etc.), skip server-side filters entirely and filter client-side:
// ❌ Server-side filter — may return 400 for unknown reasons
const result = await pb.collection('customers').getList(1, 10, {
filter: `name ~ '${query}'`, // may 400 on some collections
fields: 'id,name,phone',
});
// ✅ Client-side filter — fetch all + filter in JS
const result = await pb.collection('customers').getList(1, 500, {
fields: 'id,name,phone',
batch: 500,
});
const matched = (result.items as any[]).filter((c: any) =>
(c.name || '').toLowerCase().includes(query.toLowerCase())
);
The batch: 500 parameter fetches all records in a single request (PocketBase's default max batch is 500). For single-shop deployments this is always fast. Client-side filtering also avoids issues with escaping, case sensitivity, and operator support across PB versions.
Key insight: PocketBase's access rules (userId = @request.auth.id) are applied server-side automatically even without a filter parameter, so fetching all records and filtering client-side is a safe and reliable pattern for user-scoped data.
sort -created fails with 400 on collections created via JS migrations
PocketBase v0.23+ does NOT auto-add system created/updated AutodateFields when a collection is created via new Collection() in a JS migration. Collections created through the admin UI DO get them. Using sort: '-created', sort: '-updated', or requesting fields: '...,created,...' in getList() queries on these collections returns a 400 error: "Something went wrong while processing your request."
Workaround: Use sort: '-id' instead. PocketBase IDs are time-sortable KSUIDs, so sorting by -id gives most-recent-first behavior.
Proper fix (migration): Add AutodateField instances to the affected collections:
col.fields.add(new AutodateField({
name: 'created',
system: true,
onCreate: true,
onUpdate: false,
}));
col.fields.add(new AutodateField({
name: 'updated',
system: true,
onCreate: true,
onUpdate: true,
}));
app.save(col);
Diagnosis steps when you see generic 400 errors on PB getList/getFullList:
When PocketBase returns the generic {"data":{},"message":"Something went wrong while processing your request.","status":400}, the cause can be any query parameter (filter, sort, fields) referencing a non-existent field. Isolate the culprit systematically:
-
Test without sort and fields first:
GET /api/collections/{name}/records?perPage=1If this works, the collection exists and is accessible.
-
Add filter only:
GET /api/collections/{name}/records?filter=customerId='test'&perPage=1If this fails, the filter references a non-existent field.
-
Add sort only:
GET /api/collections/{name}/records?sort=-created&perPage=1If this fails, the sort column doesn't exist on the collection.
-
Add fields only:
GET /api/collections/{name}/records?fields=year,make,model&perPage=1If this fails, one of the requested fields doesn't exist.
-
Check the actual SQLite schema:
# Host path — use docker inspect if unsure where the volume is sqlite3 /path/to/pb_data/data.db "PRAGMA table_info(collectionName);" # Or find the volume mount path via docker inspect (faster than find on large filesystems) docker inspect pocketbase | python3 -c " import json, sys data = json.load(sys.stdin) for mount in data[0].get('Mounts', []): if 'pb_data' in mount.get('Destination', ''): print(mount['Source']) break "Or check the
_collectionstable'sfieldsJSON:sqlite3 /path/to/pb_data/data.db "SELECT fields FROM _collections WHERE name='collectionName';" | python3 -c "import json, sys; [print(f['name']) for f in json.load(sys.stdin)]"Note: the
_collectionstable has afieldscolumn (notschema) that contains the full JSON array of field definitions. -
Verify with a
sort: '-id'test — PocketBase IDs are time-sortable KSUIDs, so-idsort is always available:GET /api/collections/{name}/records?sort=-id&perPage=1If this works but
sort=-createddoesn't, the collection is missing the systemcreated/updatedfields.
Real-world affected collections (SPQ v2 project — all confirmed missing created/updated via PRAGMA table_info):
| Collection | Created via | Missing fields? |
|---|---|---|
vehicles |
M16 JS migration (new Collection()) |
Missing created and updated |
technicianInvites |
JS migration | Missing created and updated |
reminders |
JS migration (M26) | Missing created and updated |
quotes |
Admin UI | Has createdAt/updatedAt user fields but NO system created/updated |
repairOrders |
Admin UI | Has createdAt/updatedAt user fields but NO system created/updated |
All five were fixed in M28 by adding AutodateField({ name: 'created', system: true, onCreate: true }) and AutodateField({ name: 'updated', system: true, onCreate: true, onUpdate: true }).
Defensive fallback pattern when created is missing: If the collection has a user-defined timestamp field like createdAt, sort by that instead:
// ✅ Works even before M28 — createdAt is a user-defined text field
sort: '-createdAt'
// ❌ Fails on collections missing system created field
sort: '-created'
This pattern is used in CustomerInfoPanel.tsx for the quotes and repairOrders fallback queries. The vehicles query keeps -created because its try/catch wrapper handles failures gracefully while the migration is pending.
Collection naming: camelCase not snake_case
PocketBase SDK auto-creates collections with camelCase names. A Firebase-based app may use snake_case (repair_orders) while the actual PB collection is repairOrders. Always verify collection names against the live PB instance before writing queries.
useToast requires ToastProvider
The useToast() hook from ../components/ui/Toast throws at runtime if <ToastProvider> is not wrapping the component tree. Always wrap <BrowserRouter> in <ToastProvider> in App.tsx:
<BrowserRouter>
<ToastProvider>
<Routes>...</Routes>
</ToastProvider>
</BrowserRouter>
Vite build exit code 1 is often a false positive
Rolldown can emit non-fatal chunk-size warnings that cause exit code 1 even when the build succeeded. Check dist/index.html exists instead of trusting the exit code:
ls -la dist/index.html # if it exists, build succeeded
Creating collections without admin access
Non-admin PocketBase users cannot create collections via the REST API (returns 403). Collections must be created by an admin through the PocketBase dashboard at http://host:8091/_/ or via admin-authenticated API calls.
Filtering by userId on non-relational fields
When using userId as a plain text field (not a relation), the filter must use exact match:
pb.collection('quotes').getList(1, 50, {
filter: `userId = '${userId}'`,
sort: '-id',
});
Restrictive fields parameter can break queries
Using a fields parameter on getList() that lists fields not present on the collection will cause a 400 error (not just omit them). When in doubt, omit fields entirely and let PB return all fields:
// ❌ Breaks if any field in the list doesn't exist
pb.collection('appointments').getList(1, 200, {
fields: 'id,customerName,date,time,status,created,updated',
});
// ✅ Safe — returns whatever fields exist
pb.collection('appointments').getList(1, 200, {
sort: '-id',
});
Same applies to sort: using sort: 'date,time' (two bare field names) may fail. Use the + or - prefix syntax: sort: '-date,+time' or just sort: '-id'.
getFullList — load ALL records in one call (no pagination)
Use getFullList() to bypass the paged getList() pattern when you always want every matching record:
// ❌ Paginated — requires "Load more" button + hasMore state
const records = await pb.collection('repairOrders').getList(page, perPage, {
filter: `userId = '${userId}'`,
sort: '-id',
});
// items in records.items, check records.totalPages for hasMore
// ✅ Un-paginated — returns ALL matching records in one array
const records = await pb.collection('repairOrders').getFullList({
filter: `userId = '${userId}'`,
sort: '-id',
});
// items returned directly as an array (no .items wrapper)
When to use each:
| Pattern | Use case | Return shape |
|---|---|---|
getList(page, perPage) |
Data you want paginated (big collections, server-side pages, "Load more" UX) | { items, page, perPage, totalItems, totalPages } — items in .items |
getFullList({...}) |
Small-to-medium collections you always want entirely in memory (typical single-shop data: ROs, customers, quotes) | Plain T[] — items returned directly from the SDK |
Teardown the usePagedList hook when going un-paginated:
If your app uses a usePagedList<T>(fetchPage, perPage) hook (like SPQ v2's src/hooks/usePagedList.ts), switching fetchPage from getList to getFullList means you can:
- Change
fetchPageto return a single-page result:const fetchAll = useCallback(async () => { const records = await pb.collection('repairOrders').getFullList({ filter: `userId = '${userId}'` }); return { items: records, page: 1, perPage: 999999, totalItems: records.length, totalPages: 1 }; }, [userId]); hasMorebecomes alwaysfalse(page 1 of 1 total pages)- The "Load more" button never renders
refreshre-fetches the full list — no accumulator state to manage
JSON text fields: PocketBase may auto-parse or return raw strings
PocketBase stores JSON data as TEXT in the SQLite database. When the API returns these fields, they may arrive as either a parsed JavaScript object/array OR as a raw JSON string, depending on how the SDK serializes them. Always normalize:
let items = raw;
if (typeof raw === 'string') {
try { items = JSON.parse(raw); } catch { items = []; }
}
if (!Array.isArray(items)) items = [];
This applies to the services field on both quotes and repairOrders collections. A parseQuoteServices function that only handles strings will silently return empty for pre-parsed arrays.
Row-level security blocks cross-user data access
All SPQ collections use the access rule:
listRule: userId = @request.auth.id
viewRule: userId = @request.auth.id
This means a user can only see records where userId matches their own auth ID. If demo@shop.com logs in, they see zero customers/quotes/ROs because the data belongs to mani8994@gmail.com. The query filter doesn't matter — PocketBase applies the access rule first.
Implications:
- You cannot query across users with a user-level auth token
- To work around this for admin-style views, you need an admin/superuser token that bypasses the rules
- Or modify the collection rules to allow broader access (e.g., remove the userId restriction, or add an OR condition)
- The legacy site worked because it was deployed in the same PocketBase instance with the data owner's credentials
To verify access: Always test API calls directly with the authenticated user's token before assuming data exists. A 404 "not found" or empty result set often means the userId rule is blocking access.
Detecting missing collections gracefully
try {
const records = await pb.collection('name').getList(1, 1);
} catch (err) {
const msg = err instanceof Error ? err.message : '';
if (msg.includes('not found') || msg.includes('Missing collection')) {
setCollectionMissing(true); // show setup banner
}
}
PocketBase SDK import keyword breaks Vite/esbuild
The PocketBase JS SDK (v0.27.0) uses import as a method name in CollectionService.import(). Both Vite's esbuild pre-bundler and the browser's native parser treat import( as a dynamic import expression and throw Uncaught SyntaxError: Unexpected token '('.
Fix: Patch node_modules/pocketbase/dist/pocketbase.es.mjs to rename the method:
sed -i 's/async import(/async _import(/g' node_modules/pocketbase/dist/pocketbase.es.mjs
Then add to vite.config.ts to serve the raw ESM file (skip pre-bundling):
export default defineConfig({
optimizeDeps: {
exclude: ['pocketbase'],
},
// ...
});
Creating NEW collections via JS migrations (v0.39+)
The new Collection({...}) constructor works in v0.39 JS migrations, but field-based API rules cannot be set at all via JS migrations — the rule validator runs against a schema snapshot that never catches up to include the new fields, even in a separate migration file with findCollectionByNameOrId().
✅ Working pattern — save with empty rules, skip field-based rules:
migrate(
(app) => {
var col = new Collection({
name: 'myNewCollection',
type: 'base',
schema: [
new TextField({ name: 'fieldName', required: true }),
new JSONField({ name: 'jsonField', required: false }),
new BoolField({ name: 'active', required: false }),
],
listRule: '', // empty string = unrestricted (no filter)
viewRule: '', // empty = unrestricted
createRule: null, // null = no public API creates
updateRule: null,
deleteRule: null,
});
app.save(col);
console.log('[MX] created collection');
},
function(app) {
var col = app.findCollectionByNameOrId('myNewCollection');
if (col) { app.deleteCollection(col); }
}
);
❌ Do NOT attempt to set field-referencing rules (shopUserId = @request.auth.id, roId = @request.query.roId) in any migration file — it fails with listRule: Invalid rule. Raw error: invalid left operand "fieldName" - unknown field "fieldName". This affects:
app.save(col)with rules set on the constructor objectapp.save(col)with.listRule = '...'on the same collection object after a prior saveapp.save(col)afterfindCollectionByNameOrId()in a completely separate migration file (M25+1)- Every technique tested on PB 0.39.1 with
app.save()on collections created via JS migration
Workaround for single-shop deployments: The collections are usable without field-based rules — the frontend API queries filter by userId/shopUserId client-side, and createRule: null / updateRule: null / deleteRule: null prevents public API writes on write-protected collections (like the audit log). Accept that the collection's list/view rules are unrestricted at the API level and scope in the frontend.
⚠️ REST API workaround: If field-based rules are genuinely required, create the collection AND set rules via the REST API (admin auth token), not JS migrations:
# Step 1: POST the collection with rules directly (works via REST API!)
result = api("POST", "/api/collections", {
"name": "myCollection",
"type": "base",
"listRule": "shopUserId = @request.auth.id",
"fields": [
{"name": "shopUserId", "type": "text", "required": True},
{"name": "customerName", "type": "text"},
],
}, token=admin_token)
The REST API's schema validator works correctly because it validates against the full collection body before committing. Only the Goja JS migration runtime has this schema-snapshot limitation.
Available field constructors (PB 0.39):
new TextField({ name, required })— text values, also used for ISO date stringsnew JSONField({ name, required })— JSON blobs (stored as TEXT in SQLite)new BoolField({ name, required })— booleannew NumberField({ name, required })— numericnew EmailField({ name, required })— emailnew SelectField({ name, values, required })— dropdownnew FileField({ name, maxSize, allowedMimeTypes })— file upload
DateTimeField does NOT exist — use new TextField({ name: 'at', required: true }) and store ISO 8601 strings.
Field names must be unique within a collection's schema. The find() check !col.fields.find(f => f.name === 'existingField') guards against duplicate field attempts during re-migration.
Creating records in migration hooks (v0.39+)
app.onRecordUpdateRequest(function(e) {
if (e.collection.name !== 'sourceCollection') return;
if (!e.oldRecord) return;
var col = app.findCollectionByNameOrId('targetCollection');
var data = {
sourceId: e.record.id,
value: e.record.get('someField'),
at: new Date().toISOString(),
};
try {
var rec = new Record(col, data);
e.dao.saveRecord(rec);
} catch (err) {
console.error('[MX] save error: ' + String(err));
}
});
The new Record(col, data) constructor accepts a collection object and a plain data object. The record is persisted via e.dao.saveRecord(rec) (not app.save() or rec.save()).
Hook API names (v0.39+)
| Hook name | Fires | Use case |
|---|---|---|
app.onRecordCreateRequest(e) |
Before create | Modify/validate new records |
app.onRecordUpdateRequest(e) |
Before update | Check changes, send emails, write audit logs. NOT onRecordBeforeUpdateRequest or onRecordAfterUpdateRequest — those names don't exist. |
app.onRecordDeleteRequest(e) |
Before delete | Prevent deletion, cleanup references |
onRecordUpdateRequest has access to e.oldRecord for diffing changes. This is the only hook needed for both pre-update validation and post-update notification — since it fires before the DB write, throw an ApiError(code, message) to reject the update, or add side-effects (email, audit records) that are best-effort.
The e.httpContext object exists on update hooks for accessing request data:
var shareToken = '';
try {
shareToken = String(e.httpContext.request.query.get('shareToken') || '');
} catch (_) { return; }
Throwing errors in hooks
if (new Date() > expiryDate) {
throw new ApiError(410, 'Quote expired');
}
throw new ApiError(statusCode, message) rejects the request with the given HTTP status code and message. Valid in onRecordCreateRequest, onRecordUpdateRequest, and onRecordDeleteRequest. The frontend receives the status code in err.status.
Sending emails from hooks (PB 0.39 mailer)
PB 0.39 SMTP must be configured via env vars (PB_SMTP_ENABLED, PB_SMTP_HOST, etc.). The mailer API:
try {
app.getMailer().send({
to: [{ address: 'user@example.com' }],
subject: 'Quote Approved',
html: '<h2>Quote approved</h2><p>Details...</p>',
});
} catch (err) {
console.error('[MX] email failed: ' + String(err));
}
The to field is an array of { address, name } objects. name is optional. The mailer is fire-and-forget in hook context — if it fails, log and proceed.
JS migration API: v0.22 vs v0.39+ incompatibility
PocketBase v0.23+ completely changed the embedded JS migration API. Old v0.22-style migration files silently skip on v0.39+.
❌ OLD syntax (v0.22, does NOT work on v0.39+):
migrate((db) => {
const col = db.findCollectionByNameOrId('users');
const f = new CollectionField({
name: 'role', type: 'select', values: ['advisor', 'technician', 'mobile']
});
col.fields.add(f);
db.save(col);
});
Error on v0.39+: ReferenceError: CollectionField is not defined
✅ REST API alternative (works on all versions >v0.23):
When a JS migration file fails due to API incompatibility, use the REST API to PATCH the collection schema directly. This avoids writing Go-compatible JS migrations altogether.
import subprocess, json
# 1. Auth as superuser
auth = subprocess.run([
"curl", "-s", "-X", "POST",
"http://127.0.0.1:8091/api/collections/_superusers/auth-with-password",
"-H", "Content-Type: application/json",
"-d", '{"identity":"admin@shop.com","password":"..."}'
], capture_output=True, text=True, timeout=10)
token = json.loads(auth.stdout)["token"]
# 2. GET current schema (must include ALL existing fields in PATCH payload)
get_req = subprocess.run([
"curl", "-s", "http://127.0.0.1:8091/api/collections/users",
"-H", f"Authorization: Bearer *** ], capture_output=True, text=True, timeout=10)
collection = json.loads(get_req.stdout)
new_fields = list(collection["fields"])
# 3. Append new fields
new_fields.append({"name": "role", "type": "select", "values": ["advisor","technician","mobile"]})
new_fields.append({"name": "shopUserId", "type": "text"})
new_fields.append({"name": "displayName", "type": "text"})
# 4. PATCH with full fields array (replace entire field list)
subprocess.run([
"curl", "-s", "-X", "PATCH", "http://127.0.0.1:8091/api/collections/users",
"-H", f"Authorization: Bearer *** "-H", "Content-Type: application/json",
"-d", json.dumps({"fields": new_fields})
], capture_output=True, text=True, timeout=10)
CRITICAL: The PATCH payload must include ALL existing fields, not just the new ones — the fields array replaces the entire schema. Omitting existing fields drops them.
Detection: Run the migration explicitly to surface errors:
docker exec pocketbase /usr/local/bin/pocketbase --dir=/pb_data --migrationsDir=/pb_data/migrations migrate
If this passes cleanly, the migration will also apply on container restart. If it errors, use the REST API fallback above.
Cleanup failed JS files after REST API workaround: After applying the schema changes via REST API (PATCH fields or POST new collections), you MUST remove the failing JS migration file from pb_data/migrations/. PocketBase keeps trying it on every restart and logs a silent error each time — it won't crash but it's noise and may mask real problems.
rm /home/ray/docker/pocketbase/pb_data/migrations/1740000000001_*.js
# Then restart so the container starts clean
docker restart pocketbase
REST API fallback also applies to creating NEW collections — not just adding fields. The same two-step pattern works for full collection creation:
# Step 1: POST the collection (with null rules to avoid constraint issues)
result = api("POST", "/api/collections", {
"name": "technicianAssignments",
"type": "base",
"fields": [
{"name": "shopUserId", "type": "text", "required": True},
{"name": "status", "type": "select",
"values": ["pending", "in_progress", "waiting_parts", "completed"]},
# ... more fields
]
})
cid = result["id"]
# Step 2: PATCH the rules separately (cleaner separation of concerns)
result = api("PATCH", f"/api/collections/{cid}", {
"listRule": "shopUserId = @request.auth.id || technicianUserId = @request.auth.id",
"createRule": "shopUserId = @request.auth.id",
})
PocketBase v0.39.x superuser auth
Superuser authentication is at the collections endpoint, NOT the legacy /api/admins/ path:
# ✅ Correct (v0.39.x)
POST /api/collections/_superusers/auth-with-password
body: {"identity": "admin@example.com", "password": "..."}
# ❌ Wrong (legacy — returns 404)
POST /api/admins/auth-with-password
Create a superuser via CLI if none exists:
docker exec pocketbase /usr/local/bin/pocketbase superuser upsert admin@shop.com PASSWORD --dir /pb_data
Creating collections via API (v0.39.x)
Use fields not schema in the payload. Match existing collection patterns — most SPQ collections use text for userId (not relation):
api("POST", "/api/collections", {
"name": "collection_name",
"type": "base",
"listRule": "userId = @request.auth.id",
"viewRule": "userId = @request.auth.id",
"createRule": "@request.auth.id != ''",
"updateRule": "userId = @request.auth.id",
"deleteRule": "userId = @request.auth.id",
"fields": [
{"name": "name", "type": "text", "required": True},
{"name": "userId", "type": "text", "required": True},
],
}, token=admin_token)
The API returns 400 with specific field-level errors if the schema is wrong — inspect resp["data"]["fields"] for detail.
Vite dev proxy for PocketBase needs explicit rewrite
When proxying /pb to PocketBase in Vite dev, add an explicit rewrite rule to strip the prefix:
server: {
proxy: {
'/pb': {
target: 'http://127.0.0.1:8091',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/pb/, ''),
},
},
}
Without rewrite, some PocketBase versions serve the admin UI HTML for proxied paths instead of the API JSON.
PocketBase API Quick Reference
- Auth:
POST /api/collections/users/auth-with-passwordwith{identity, password} - List:
GET /api/collections/{name}/records?page=1&perPage=50&filter=...&sort=-id - Create:
POST /api/collections/{name}/records - Update:
PATCH /api/collections/{name}/records/{id} - Delete:
DELETE /api/collections/{name}/records/{id} - Health:
GET /api/health - Admin dashboard:
http://host:8091/_/
SDK Patterns
import PocketBase from 'pocketbase';
export const pb = new PocketBase('/pb');
pb.autoCancellation(false);
// Auth
const auth = await pb.collection('users').authWithPassword(email, password);
const userId = pb.authStore.model?.id;
pb.authStore.clear(); // logout
// CRUD
const result = await pb.collection('name').getList(page, perPage, { filter, sort });
const record = await pb.collection('name').create({ field: value });
await pb.collection('name').update(id, { field: newValue });
await pb.collection('name').delete(id);
React Integration
import { pb } from '../lib/pocketbase';
import { useToast } from '../components/ui/Toast';
// Must be wrapped in <ToastProvider>
Settings merge: always use spread, never explicit field lists
When loading settings from a PocketBase JSON field into local state, use { ...DEFAULT_SETTINGS, ...data } spread pattern. An explicit field-by-field merge silently drops any fields that exist in DEFAULT_SETTINGS but were not added to the merge logic — a recurring bug when new settings are added later.
// ❌ Brittle — every new field must be manually added here
const merged: ShopSettings = {
...DEFAULT_SETTINGS,
businessName: (d.businessName as string) || '',
taxRate: Number(d.taxRate ?? 0),
// ... 10+ more fields — easy to miss new ones
};
// ✅ Future-proof — spread includes all fields automatically
const merged: ShopSettings = { ...DEFAULT_SETTINGS, ...d } as ShopSettings;
This also means DEFAULT_SETTINGS must be the single source of truth for all fields and their defaults. When adding new settings fields, add them to DEFAULT_SETTINGS first, then the spread handles the rest.
Nested component input focus loss
When an input inside a nested function component loses focus on every keystroke, the component is being recreated on every render. React treats a nested function as a new component type, destroying and recreating the DOM.
Fix: Extract the component to a file-level memo'd component so its identity is stable across renders. Never define components (functions that return JSX) inside other components — always at module scope.
const ServiceRow = memo(function ServiceRow({ service, onUpdate, ... }: Props) {
return <input value={service.price} onChange={...} />;
});
JSON fields from PocketBase
PB json type fields may arrive as serialized JSON strings instead of parsed objects/arrays, depending on how the record was created. Always normalize:
// Fetch time normalization (safe)
let services = item.services || [];
if (typeof services === 'string' && services.trim()) {
try { services = JSON.parse(services); } catch { services = []; }
}
if (!Array.isArray(services)) services = [];
And when consuming a possibly-string field:
function calcROTotals(services: ROService[]) {
const arr = Array.isArray(services) ? services : [];
const total = arr.reduce(...);
}
Date formatting crashes (Invalid time value)
new Date(undefined) produces Invalid Date, and calling .toLocaleDateString() or Intl.DateTimeFormat().format() on it throws RangeError: Invalid time value. Always guard:
function formatDate(iso: string | undefined | null) {
if (!iso) return '—';
const d = new Date(iso);
if (isNaN(d.getTime())) return '—';
return new Intl.DateTimeFormat('en-US', {...}).format(d);
}
Tesseract.js in Vite
Dynamic CDN imports like import('https://cdn.jsdelivr.net/...') don't work in Vite production builds. Use a static npm import instead:
// ✅ Correct — Vite bundles it
import Tesseract from 'tesseract.js';
// ❌ Wrong — fails in prod
const Tesseract = (await import('https://cdn.jsdelivr.net/...')).default;
PB sign-up (user registration)
PocketBase user creation requires passwordConfirm at the top level of the create payload. Without it, the API returns 400:
await pb.collection('users').create({
email: email.trim(),
password,
passwordConfirm: confirmPassword, // ← REQUIRED, not a real field
name: name.trim() || '',
emailVisibility: true,
});
Common failure causes:
- Email already exists → PB returns 400
"Failed to create record."withdata.email.code === 'validation_not_unique' - Password < 8 chars → PB returns
"Must be at least 8 character(s)." - Missing passwordConfirm → 400 with validation error
The SDK throws ClientResponseError (extends Error). .message gives "Failed to create record."; field details are in .response.data.
Custom service / dynamic item add: always wire onClick
When a button that creates a dynamic item (e.g., "Add as custom service") has no onClick handler, it silently does nothing. Subagents frequently leave buttons with label text but no action. Always verify that every "Add X" button in generated code has a wired click handler that creates the item and updates state:
// ❌ Silent — no action
<button>Add as custom service</button>
// ✅ Wired
<button onClick={() => {
const newItem = { id: `custom-${Date.now()}`, name: query.trim(), price: 0 };
addService(newItem);
}}>Add as custom service</button>
Appointments page: avoid restrictive PB query params
The fields and sort parameters in pb.collection().getList() will 400-error if any referenced field doesn't exist on the collection. Prefer minimal params:
// ❌ Breaks if fields or sort column doesn't exist
pb.collection('appointments').getList(1, 200, {
filter: `userId = '${userId}'`,
sort: 'date,time', // wrong syntax for PB
fields: 'id,name,date,status,created', // any missing = 400
});
// ✅ Safe
pb.collection('appointments').getList(1, 200, {
filter: `userId = '${userId}'`,
sort: '-id',
});
Repair Order Domain Patterns
Promised Completion Time (Due Date Display)
Auto repair management apps show a "promised completion time" on active repair orders. The display format adapts to how close the date is:
function formatDueDate(promisedTime: string | undefined | null) {
if (!promisedTime) return '—';
const d = new Date(promisedTime);
if (isNaN(d.getTime())) return '—';
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const tomorrow = new Date(today.getTime() + 86400000);
const nextWeek = new Date(today.getTime() + 7 * 86400000);
const dateOnly = new Date(d.getFullYear(), d.getMonth(), d.getDate());
const timeStr = d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
if (dateOnly.getTime() === today.getTime()) return `Today ${timeStr}`;
if (dateOnly.getTime() === tomorrow.getTime()) return `Tomorrow ${timeStr}`;
if (dateOnly.getTime() < nextWeek.getTime()) {
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
return `${days[d.getDay()]} ${timeStr}`;
}
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
return `${months[d.getMonth()]} ${d.getDate()} ${timeStr}`;
}
Outputs: "Today 3:00 PM", "Tomorrow 10:30 AM", "Wed 2:00 PM", "Jun 27 1:00 PM", "—".
Store as ISO datetime string in the PB collection (promisedTime field). Use <input type="datetime-local"> in forms.
Fetching Repair Orders by Customer (Two Patterns)
Repair orders link to customers in two ways, each used for different purposes:
1. Forward-link by customerId (detail view) — The RO has a customerId field referencing the customer record. Use this when viewing one customer's details:
⚠ SPQ data reality: In the current SPQ database, ALL 55 repair orders and ALL 47 quotes have an empty
customerIdfield. The only reliable link is bycustomerName. Use pattern #2 (customerNamefilter) for detail views in SPQ. IfcustomerIdis ever populated in the future this pattern will work, but for now it returns zero results.
const result = await pb.collection('repairOrders').getList(1, 100, {
filter: `customerId = '${customerId}'`,
sort: '-createdAt',
fields: 'id,roNumber,customerId,customerName,vehicleInfo,vin,mileage,status,workStatus,createdAt,completedTime,writeupTime,technician,notes,services',
});
2. Backward-link by customerName (vehicle discovery) — Used when the customer has no customerId on the RO (legacy data pattern). Search all ROs by customer name to discover associated vehicles:
const nameFilter = customerNames
.map((n) => `customerName ~ '${n.replace(/'/g, "\\'")}'`)
.join(' || ');
const result = await pb.collection('repairOrders').getList(1, 500, {
filter: nameFilter,
fields: 'vehicleInfo,vin,customerName',
});
The first pattern is for the Customer Detail view (that customer's ROs). The second is for the Customer List view (discovering vehicles across all customers).
RO Status Values and Display
The DB uses these workStatus values (stored in status or workStatus field):
| DB value | Display label | Badge color |
|---|---|---|
active / open |
Open | Blue |
in-progress / in_progress |
In Progress | Yellow/Amber |
waiter / waiting_parts |
Waiting Parts | Orange |
completed |
Completed | Green |
delivered |
Delivered | Green |
cancelled |
Cancelled | Red |
Status label/color helpers:
function getRoStatusLabel(ro: RepairOrderRecord): string {
const s = ro.workStatus || ro.status || '';
switch (s) {
case 'active': case 'open': return 'Open';
case 'in-progress': case 'in_progress': return 'In Progress';
case 'waiter': case 'waiting_parts': return 'Waiting Parts';
case 'completed': return 'Completed';
case 'delivered': return 'Delivered';
case 'cancelled': return 'Cancelled';
default: return s || 'Unknown';
}
}
function getRoStatusColor(ro: RepairOrderRecord): string {
const s = ro.workStatus || ro.status || '';
switch (s) {
case 'active': case 'open':
return 'bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400';
case 'in-progress': case 'in_progress':
return 'bg-yellow-50 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400';
case 'waiter': case 'waiting_parts':
return 'bg-orange-50 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400';
case 'completed': case 'delivered':
return 'bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-400';
case 'cancelled':
return 'bg-red-50 text-red-700 dark:bg-red-900/30 dark:text-red-400';
default:
return 'bg-gray-50 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400';
}
}
Status Transition Buttons (Reversible)
Repair orders need reversible status changes — once marked "Completed", the user must be able to "Reopen" back to "Active". Always show context-appropriate buttons:
Active → In Progress, Waiting Parts, Complete
In Progress → Active, Waiting Parts, Complete
Waiting Pts → Active, In Progress, Complete
Completed → Reopen (→Active), Mark Delivered
Delivered → Reopen (→Active)
Local Dev Server
For local development with PocketBase on a different port, use the SPA+PB proxy server in references/spa-pb-proxy.py. It serves the Vite build output and proxies /pb/* API calls to PocketBase, with CORS headers. Set SPQ_DIST and PB_URL env vars, or edit the defaults.