---
name: pocketbase-development
description: "PocketBase patterns: collection schemas, API quirks, sort-field traps, useToast provider, auth flow."
version: 1.2.0
metadata:
  hermes:
    tags: [pocketbase, backend, collections, debugging]
    related_skills: []
---

# 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.md` documents 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.md` documents the actual PocketBase schema for the ShopProQuote project — collection field names, vehicle data storage (no separate `vehicles` collection), the combine-and-deduplicate pattern across repairOrders/quotes, `customerName` vs `customerId` linkage (ALL existing ROs and quotes have empty `customerId`), 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.md` for 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.com` vs `grajmedia.duckdns.org`). Nginx config check → identical code → same backend → client-side localStorage stale state. Covers Zod validation failure tracing and the `spq-quote` localStorage 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 (`handleSave` and `ensureShareToken`).

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

```typescript
// ❌ 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:

```javascript
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:

1. **Test without sort and fields first:**
   ```
   GET /api/collections/{name}/records?perPage=1
   ```
   If this works, the collection exists and is accessible.

2. **Add filter only:**
   ```
   GET /api/collections/{name}/records?filter=customerId='test'&perPage=1
   ```
   If this fails, the filter references a non-existent field.

3. **Add sort only:**
   ```
   GET /api/collections/{name}/records?sort=-created&perPage=1
   ```
   If this fails, the sort column doesn't exist on the collection.

4. **Add fields only:**
   ```
   GET /api/collections/{name}/records?fields=year,make,model&perPage=1
   ```
   If this fails, one of the requested fields doesn't exist.

5. **Check the actual SQLite schema:**
   ```bash
   # 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 `_collections` table's `fields` JSON:
   ```bash
   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 `_collections` table has a `fields` column (not `schema`) that contains the full JSON array of field definitions.

6. **Verify with a `sort: '-id'` test** — PocketBase IDs are time-sortable KSUIDs, so `-id` sort is always available:
   ```
   GET /api/collections/{name}/records?sort=-id&perPage=1
   ```
   If this works but `sort=-created` doesn't, the collection is missing the system `created`/`updated` fields.

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

```typescript
// ✅ 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`:

```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:
```bash
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:
```typescript
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:

```typescript
// ❌ 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:

```typescript
// ❌ 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:

1. Change `fetchPage` to return a single-page result:
   ```typescript
   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]);
   ```
2. `hasMore` becomes always `false` (page 1 of 1 total pages)
3. The "Load more" button never renders
4. `refresh` re-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:

```typescript
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

```typescript
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:

```bash
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):

```typescript
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:**

```javascript
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 object
- `app.save(col)` with `.listRule = '...'` on the same collection object after a prior save
- `app.save(col)` after `findCollectionByNameOrId()` 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:

```python
# 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 strings
- `new JSONField({ name, required })` — JSON blobs (stored as TEXT in SQLite)
- `new BoolField({ name, required })` — boolean
- `new NumberField({ name, required })` — numeric
- `new EmailField({ name, required })` — email
- `new SelectField({ name, values, required })` — dropdown
- `new 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+)

```javascript
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:
```javascript
var shareToken = '';
try {
  shareToken = String(e.httpContext.request.query.get('shareToken') || '');
} catch (_) { return; }
```

### Throwing errors in hooks

```javascript
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:

```javascript
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+):**
```javascript
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.

```python
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:
```bash
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.

```bash
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:

```python
# 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:

```bash
# ✅ 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:
```bash
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`):

```python
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:

```typescript
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-password` with `{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

```typescript
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

```tsx
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.

```typescript
// ❌ 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.

```tsx
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:

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

```typescript
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:

```typescript
// ✅ 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:

```typescript
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."` with `data.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:

```tsx
// ❌ 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:

```typescript
// ❌ 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:

```typescript
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 `customerId` field**. The only reliable link is by `customerName`. Use pattern #2 (`customerName` filter) for detail views in SPQ. If `customerId` is ever populated in the future this pattern will work, but for now it returns zero results.

```typescript
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:

```typescript
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:

```typescript
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.
