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

20 KiB

name, description, triggers
name description triggers
firebase-to-pocketbase-migration Migrate a Firebase web app to self-hosted PocketBase — build a Firebase-compatible adapter, swap imports, and deploy with same-origin nginx proxy.
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:

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)

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)

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

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

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

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