---
name: firebase-to-pocketbase-migration
description: Migrate a Firebase web app to self-hosted PocketBase — build a Firebase-compatible adapter, swap imports, and deploy with same-origin nginx proxy.
triggers:
  - "migrate from Firebase"
  - "replace Firebase with PocketBase"
  - "Firebase to PocketBase"
  - "remove Firebase dependency"
  - "self-host instead of Firebase"
---

# Firebase → PocketBase Migration

Replace Firebase (Auth + Firestore) with PocketBase without rewriting the app. Build a thin compatibility adapter, swap one import per file, and deploy behind same-origin nginx so the PocketBase JS SDK works without CORS.

## Strategy: Adapter, Don't Rewrite

Firebase apps import from `firebase.js` (or CDN) and call `signInWithEmailAndPassword(auth, ...)`, `collection(db, 'name')`, `addDoc(ref, data)`, `getDocs(query(...))`, etc. PocketBase has a different API surface.

**The adapter** (`pocketbase.js`) exports `auth` and `db` objects that mirror the Firebase surface, backed by PocketBase under the hood. All 13+ app files keep their existing `collection()`, `query()`, `where()`, `getDocs()`, `addDoc()`, etc. calls unchanged.

## Step 1: Map the Firestore Data Model

Search for all Firestore references:

```bash
grep -rn "collection(db," *.js shared/*.js
grep -rn "gstatic.com/firebasejs" *.js shared/*.js
```

Identify:
- **Top-level collections** (e.g., `repairOrders`, `appointments`)
- **Sub-collections** (e.g., `users/{uid}/services`, `users/{uid}/customers`)
- **Dynamic imports** (`await import("...firebase-firestore.js")`)
- **Auth functions used** (`signOut`, `onAuthStateChanged`, `updateProfile`, `sendPasswordResetEmail`)

## Step 2: Flatten Sub-Collections

PocketBase has no native sub-collections. Flatten them into top-level collections with a `userId` field:

| Firestore | PocketBase Collection | Key Field |
|---|---|---|
| `repairOrders` | `repairOrders` | `userId` |
| `users/{uid}/services` | `services` | `userId` |
| `users/{uid}/customers` | `customers` | `userId` |
| `users/{uid}/settings/{docName}` | `settings` | `userId` + `name` |

The adapter handles the path translation transparently — app code still writes `collection(db, 'users', uid, 'services')`.

## Step 3: Create PocketBase Collections

Use the Admin API to create collections before writing any data. **Create with empty rules first, then PATCH rules after the schema exists** — rules that reference fields fail validation if the field doesn't exist yet.

### v0.22 and earlier (uses `schema` key)

```python
api("POST", "/api/collections", {
    "name": "repairOrders", "type": "base",
    "schema": [{"name": "userId", "type": "text", "required": True}],
    "listRule": "", "viewRule": "", "createRule": "",
    "updateRule": "", "deleteRule": ""
}, token)
```

### v0.23+ (uses `fields` key)

```python
api("POST", "/api/collections", {
    "name": "repairOrders", "type": "base",
    "fields": [{"name": "userId", "type": "text", "required": True}],
    "listRule": "", "viewRule": "", "createRule": "",
    "updateRule": "", "deleteRule": ""
}, token)
```

To check your PocketBase version: `docker exec pocketbase /usr/local/bin/pocketbase --version`

### Two-step: schema first, rules second

```python
# Step 1: Set schema with empty rules
api("PATCH", f"/api/collections/{col_id}", {
    "fields": [...all fields including system id field...],
    "listRule": "", "viewRule": "", "createRule": "",
    "updateRule": "", "deleteRule": ""
}, token)

# Step 2: Apply rules now that fields exist
api("PATCH", f"/api/collections/{col_id}", {
    "listRule": "userId = @request.auth.id",
    "viewRule": "userId = @request.auth.id",
    "createRule": "@request.auth.id != ''",
    "updateRule": "userId = @request.auth.id",
    "deleteRule": "userId = @request.auth.id",
}, token)
```

**⚠️ You MUST include the system `id` field** (with its existing properties) when PATCHing fields in v0.23+, or the update may be rejected. Fetch the collection first to get the existing field definitions, then merge new fields into them.

### Define ALL fields, not just userId

Firebase/Firestore is schemaless — you can add any field to a document dynamically. PocketBase is **strictly typed** — any field not defined in the collection schema is **silently dropped on write** (HTTP 200 is returned but the data is not stored). You MUST define every field the app writes before any data operations.

Use `references/collection-schema-setup.py` as a template — it defines complete schemas for all common Firebase collection types and handles the two-step schema+rules process.

Create one collection per top-level name: `repairOrders`, `appointments`, `quotes`, `tasks`, `services`, `customers`, `settings`.

## Step 4: Build the Adapter

Use `templates/pocketbase-adapter.js` as a starting point. The adapter handles Auth + Firestore CRUD with PocketBase underneath. Also see `templates/same-origin-nginx.conf` for the nginx config, `references/create-collections.py` for the original collection creation script (v0.22), and **`references/collection-schema-setup.py`** for the complete field schema definitions with v0.23+ two-step process.

**⚠️ Critical compatibility requirements** for `getDoc`, `getDocs`, `addDoc`, and `onSnapshot` are documented in **`references/adapter-firestore-compat-patterns.md`**. Read this before writing or debugging the adapter — the return shapes must match the Firestore SDK exactly or every app file silently breaks.

### Auth surface (Firebase-compatible)
- `auth.currentUser` → `{ uid, email, displayName }`
- `auth.onAuthStateChanged(callback)` → fires on change + returns unsubscribe
- `signInWithEmailAndPassword(auth, email, password)` → PocketBase `authWithPassword()`
- `createUserWithEmailAndPassword(auth, email, password)` → PocketBase `create()` + `authWithPassword()`
- `signOut(auth)` → `pb.authStore.clear()`
- `sendPasswordResetEmail(auth, email)` → PocketBase `requestPasswordReset()`
- `updateProfile(user, {displayName})` → PocketBase `update()`

### Firestore surface
- `collection(db, name)` / `collection(db, 'users', uid, 'sub')` — top-level and sub-collection
- `doc(db, 'col', id)` / `doc(db, 'users', uid, 'settings', name)` — top-level and nested
- `query(ref, where(field, op, val), orderBy(field, dir))` — compiled to PocketBase filter strings
- `getDocs(query)` — must return Firestore-compatible **snapshot shape**: `{docs: [...], size: N, empty: bool, forEach(cb)}`. Not a raw array — every consumer calls `.docs.map()`, `.empty`, `.size`, or `.forEach()`.
- `getDoc(docRef)` → `pb.collection(name).getOne(id)`. **⚠️ Must return `exists` as a function** (`exists: () => true`/`exists: () => false`), not a boolean. Firestore API calls `.exists()` everywhere.
- `addDoc(ref, data)` → `pb.collection(name).create(data)`. **⚠️ Must inject `userId` from sub-collection ref.** When app code writes `collection(db, 'users', uid, 'services')`, the adapter tracks `_userId` on the ref but `create()` discards it. Always: `if (ref._userId && !data.userId) { data.userId = ref._userId; }` before calling `create()`.
- `updateDoc(docRef, data)` → `pb.collection(name).update(id, data)`
- `deleteDoc(docRef)` → `pb.collection(name).delete(id)`
- `onSnapshot(queryOrDoc, callback)` — **⚠️ Must pass snapshot shape to callback.** A bare `getDocs(query).then(docs => callback({docs}))` is wrong. Wrap in `createQuerySnapshot(docs)` that includes `size`, `empty`, `forEach`. Reference the same helper used by `getDocs`.
- `serverTimestamp()` → `new Date().toISOString()`
- `Timestamp` class — wraps Date for `.toDate()` calls on existing timestamp fields

### Query operator mapping
| Firebase | PocketBase Filter |
|---|---|
| `where('field', '==', val)` | `field = "val"` |
| `where('field', 'in', [a,b])` | `(field ?= "a" \|\| field ?= "b")` |
| `orderBy('field', 'desc')` | `-field` sort parameter |

### Data conversion
- **To PB**: Nested objects get `JSON.stringify()`'d. `undefined` values dropped. Dates → ISO strings.
- **From PB**: ISO strings on known timestamp fields → `Timestamp` objects. JSON strings in object-like fields → parsed back.

## Step 5: Swap All Imports

Replace Firebase CDN imports with the adapter in one sweep:

```bash
# Static imports
find . -name "*.js" -exec sed -i 's|from "./firebase.js"|from "./pocketbase.js"|g' {} +
find . -name "*.js" -exec sed -i 's|from "https://www\.gstatic\.com/firebasejs/[^"]*firebase-auth\.js"|from "./pocketbase.js"|g' {} +
find . -name "*.js" -exec sed -i 's|from "https://www\.gstatic\.com/firebasejs/[^"]*firebase-firestore\.js"|from "./pocketbase.js"|g' {} +

# Dynamic imports
find . -name "*.js" -exec sed -i 's|await import("https://www\.gstatic\.com/firebasejs/[^"]*firebase-auth\.js")|await import("./pocketbase.js")|g' {} +
find . -name "*.js" -exec sed -i 's|await import("https://www\.gstatic\.com/firebasejs/[^"]*firebase-firestore\.js")|await import("./pocketbase.js")|g' {} +

# Also check login.html for inline module scripts
# Also check any single-quoted import() calls
```

Verify zero Firebase references remain:
```bash
grep -rn 'gstatic.com/firebasejs' *.js shared/*.js *.html
```

## Step 6: Same-Origin nginx (No CORS)

PocketBase SDK uses `window.location.origin`. Serve the static app AND proxy `/api/` to PocketBase from the same nginx server block:

Use `templates/same-origin-nginx.conf` as the template. Key points:
- `root /path/to/app;` serves static files
- `location /api/ { proxy_pass http://127.0.0.1:8091; }` proxies PocketBase
- `location /_/ { proxy_pass ... }` for admin UI access
- SPA fallback: `try_files $uri $uri/ /index.html;`
- WebSocket headers for realtime: `Upgrade`, `Connection`, `proxy_read_timeout 86400`

This avoids CORS entirely — PocketBase calls go to `/api/collections/users/auth-with-password` on the same origin.

## Pitfalls

- **`onAuthStateChanged` must be exported as a standalone function.** Many files import `import { onAuthStateChanged } from "./pocketbase.js"` and call `onAuthStateChanged(auth, callback)`. If you only export it as `auth.onAuthStateChanged` (a method on the auth object), those imports resolve to `undefined` and the auth check silently does nothing — user sees the app without any auth redirect. Always add a standalone wrapper: `function onAuthStateChanged(authRef, callback) { return auth.onAuthStateChanged(callback); }` and export it alongside the other functions.
- **Settings pages need `updatePassword`, `reauthenticateWithCredential`, `EmailAuthProvider`.** If the app has a profile/settings page, these Firebase Auth functions are imported. PocketBase equivalents: `updatePassword` calls `pb.collection('users').update(uid, {password, passwordConfirm})`. `reauthenticateWithCredential` re-runs `authWithPassword`. `EmailAuthProvider.credential(email, pass)` returns a credential object for re-auth.
- **`setDoc`, `limit`, `onSnapshot` are commonly imported but easy to miss.** `setDoc` = upsert (try `update`, fallback to `create` with explicit ID). `limit(n)` returns a constraint object used in query building. `onSnapshot` can be a simple polling one-shot that calls `getDocs`/`getDoc` then fires the callback — most apps only need the initial snapshot, not real-time.
- **`limit` must be handled in `getDocs`.** When a query has a limit constraint, use `pb.collection(name).getList(1, limitVal, options)` instead of `getFullList()`. `getFullList` fetches all pages; `getList` respects the pagination cap.

- **PocketBase SDK auto-cancellation (`requestKey: null`).** The PocketBase JS SDK auto-cancels in-flight requests that share the same `requestKey` (derived from method + URL by default). All `create` calls to the same collection share a key, so a double-submit cancels the first request with "auto-cancelled" error. Fix: pass `{ requestKey: null }` to ALL mutating calls — `pb.collection(name).create(data, { requestKey: null })`, `pb.collection(name).update(id, data, { requestKey: null })`, and the same inside `setDoc`'s upsert paths. Also add a client-side guard: disable the submit button on first click to prevent double submissions.
- **`index.html` may load `firebase.js` via `<script>` tag.** `<script type="module" src="firebase.js"></script>` needs to become `src="pocketbase.js"`. HTML files are easy to miss when swapping imports in `*.js` files only.
- **Mount paths must match entrypoint flags** (see `pocketbase-setup` skill). The image uses `--dir=/pb_data --publicDir=/pb_public`.
- **Docker restart ignores compose changes.** After editing volumes/ports, use `down && up -d`.
- **Admin `upsert` needs `--dir=/pb_data`** flag when running inside container.
- **Auth API uses `_superusers`, not `admins`** in PocketBase v0.22+.
- **Users collection system ID is `_pb_users_auth_`** — use PATCH on that ID, not POST to `/api/collections/users`.
- **Rules referencing fields fail if created with the collection.** Create with empty rules, then PATCH rules after schema exists.
- **Dynamic `await import()` calls** are easy to miss — check for both `"..."` and `'...'` quoting.
- **`login.html` may have inline `<script type="module">`** with Firebase imports — check HTML files too, not just JS.
- **`sendPasswordResetEmail` and `updateProfile`** aren't in the base adapter — add them if the login page uses them.
- **Nested objects** get JSON.stringified by the adapter — Firestore stores them natively. This is lossy but reversible for most data shapes.
- **Timestamp fields** are auto-detected by field name (writeupTime, createdAt, etc.) — add to `TIMESTAMP_FIELDS` Set if the app has differently-named timestamp fields.
- **Admin auth leaks into app pages.** If the user logged into PocketBase Admin UI (`/_/`) on the same origin, the superuser auth token persists in localStorage. On next visit to the app, `pb.authStore.isValid` returns true and `currentUser` is a superuser — the dashboard loads without login. Fix: in the adapter's `auth.currentUser` getter and `onAuthStateChanged` listener, check `model.collectionName === 'users'`. Return null for `_superusers`. This prevents admin sessions from auto-authenticating the app.

- **Cached user tokens also bypass login.** After fixing the admin auth leak (above), the user may still see the dashboard instead of the login page. This happens because the PocketBase SDK auto-restores **any** valid token from localStorage at initialization — including tokens from a previous `test@...` user login. The adapter's admin filtering only rejects `_superusers`; a valid `users` collection token passes right through. Fix: either (a) instruct the user to clear site data (DevTools → Application → Clear storage, or hard refresh with `Ctrl+Shift+Delete` → clear cache), or (b) add a manual sign-out button that calls `pb.authStore.clear()`. Without one of these, the user is permanently auto-authenticated until the token expires.

- **Dynamic imports in `shared/` resolve to the wrong directory.** After swapping Firebase CDN imports to `./pocketbase.js` in Step 5, files inside `shared/` (like `shared/header-functionality.js`) will have broken paths. `import('./pocketbase.js')` from `shared/` resolves to `shared/pocketbase.js` — but `pocketbase.js` lives in the project **root**. Fix: change to `import('../pocketbase.js')` in all `shared/` files. Also check for stale Firebase paths: `import('../firebase.js')` must become `import('../pocketbase.js')`. Search with: `grep -rn "pocketbase.js\|firebase.js" shared/`.

- **Nginx `Cache-Control: immutable` breaks all JS updates.** Many static-site nginx configs set `expires 30d; add_header Cache-Control "public, immutable"` on JS/CSS files. After swapping to PocketBase, browsers serve stale `${OLD}` JS files forever — even after the fix above. Change to `expires 1h; add_header Cache-Control "public, must-revalidate"` and reload nginx. Also add `?cb=` cache-busting to all redirects as a defense-in-depth measure (see `web-ui-repair` skill, section 2).

- **CSP on index.html silently blocks all inline fixes after migration.** Post-migration inline script guards and onclick handlers may be blocked by a Content Security Policy meta tag that lacks `'unsafe-inline'`. Diagnostic signal: inline fixes work on other pages (no CSP) but fail on index.html. Fix: add `'unsafe-inline'` to script-src. Full pattern in `web-ui-repair` skill, section 13.

- **Direct SQLite field manipulation breaks PocketBase schema validation.** If you modify the `_collections` table's `fields` column directly (instead of via the API), each field object in the JSON array MUST include a unique `id` property (a string like `"text3208210256"` that PocketBase generated at creation time). Without valid `id` properties, PocketBase cannot parse the field definitions and ALL create/update operations fail with a generic `"Failed to create record."` error — regardless of user permissions or data validity.\n\n  **Fix:** Always use `PATCH /api/collections/{id}` via the API to add fields. This generates proper `id` values automatically. Fetch the existing collection first (`GET /api/collections/{id}`), merge new fields into the returned `fields` array, then PATCH back.\n\n- **Define ALL fields before CRUD, not just userId.**** Unlike Firestore (schemaless), PocketBase has a strict typed schema. Creating/updating a record with a field not defined in the collection schema returns **HTTP 200** but the field value is discarded — no error, no warning, just lost data. This is the #1 cause of "everything looks like it works but there's no data." **Define every field the app writes before any CRUD.** See Step 3 for the two-step schema+rules process and the `references/collection-schema-setup.py` template.

- **Import consolidation can accidentally drop non-Firebase imports.** When manually consolidating Firebase CDN imports into `./pocketbase.js`, adjacent non-Firebase imports (like `import { escapeHtml } from './shared/sanitize.js'`) may be deleted. After consolidation, grep each file for undeclared function references: `grep -n "escapeHtml\|showNotification\|formatCurrency" *.js shared/*.js | grep -v "^.*:import"`. If a function is used but not imported, restore the import.

- **Restrictive file permissions break module loading.** When copying migrated files to the deployment directory, files may have owner-only permissions (600). nginx runs as `www-data` and returns HTTP 403 for unreadable files — which causes **silent ES module chain failure** (no console error visible but no handlers attach). Fix: `find . -name "*.js" -exec chmod 644 {} +` after deployment. Verify with `curl -skI https://site.com/key-file.js | head -1` — should return 200, not 403.

- **Settings save/load breaks silently on PocketBase schema mismatch.** If a collection (e.g., `settings`) only has `id`, `userId`, `name` text fields but the app tries to save arbitrary key-value settings directly, PocketBase silently drops unknown fields. Fix: add a `json` field to the collection, wrap data in `{data: {...}, userId, name}`, and use query-based upsert in `setDoc`/`getDoc` (PocketBase v0.23+ requires system IDs ≥ 15 chars). Full pattern in **`references/settings-json-field-pattern.md`**.

## Debugging

See **`references/debugging-migration-bugs.md`** for common bug patterns discovered during migration validation, including:

| Pattern | Symptom | Fix |
|---|---|---|
| Dual render path filter mismatch | Completed items stay visible after status change | Sync filter between load-from-DB and re-render-local paths |
| Silent setTimeout errors | Button click "does nothing", no console error | Try/catch inside the callback, not outside |
| PocketBase drops undefined fields | API returns 200 but data is missing | Define every field in schema before CRUD |
| Reference build comparison | "But the Firebase version works!" — same code bug | Use `diff` on identical functions; the Firebase version had the same bug, just unnoticed |
