# PocketBase Migration Debugging Patterns Common pitfalls when converting a multi-page static web app from Firebase to a self-hosted PocketBase backend. ## 1. Relative Import Paths in `shared/` Modules **Problem:** Files in `shared/` (like `header-functionality.js`) use dynamic `import()` calls with paths relative to their own location. After migration, `pocketbase.js` lives in the project root, but `shared/header-functionality.js` imports `'./pocketbase.js'` which resolves to `shared/pocketbase.js` — a file that doesn't exist. **Fix:** All dynamic imports from `shared/` must use `'../pocketbase.js'` to reach the root file. **Checklist:** ```bash # Find all dynamic imports in shared files grep -rn "import.*pocketbase\|import.*firebase" shared/*.js # Expected: '../pocketbase.js' (NOT './pocketbase.js', NOT '../firebase.js') ``` ## 2. Stale Firebase Imports After Migration **Problem:** Some files still import from Firebase CDN URLs or reference `firebase.js` instead of `pocketbase.js`. The Firebase CDN imports will fail silently if Firebase isn't configured. **Example from `header-functionality.js` (BROKEN):** ```js const { signOut } = await import('./pocketbase.js'); // WRONG: shared/pocketbase.js const { auth } = await import('../firebase.js'); // WRONG: old Firebase file ``` **Fixed:** ```js const { signOut } = await import('../pocketbase.js'); const { auth } = await import('../pocketbase.js'); ``` **Batch fix all remaining `firebase.js` references at once:** ```bash cd /path/to/project grep -rln "firebase.js" --include="*.js" . | while read f; do sed -i "s|'./firebase.js'|'./pocketbase.js'|g; s|'../firebase.js'|'../pocketbase.js'|g" "$f" done # Verify no references remain (comments are OK) grep -rn "firebase.js" --include="*.js" . | grep -v "^.*://.*firebase" ``` **Additional stub needed:** If any file imports `handleFirestoreConnectionError` from the old Firebase file, add a no-op stub to `pocketbase.js`: ```js async function handleFirestoreConnectionError(error) { console.warn('Firestore connection error (using PocketBase, no retry needed):', error); } export { handleFirestoreConnectionError }; ``` ## 3. Missing UI Elements After Migration **Problem:** Not all pages have the same header/dropdown structure. Some pages may be missing the Sign Out button entirely. **Also: JS doesn't populate header elements even when HTML exists.** Each page's `onAuthStateChanged` callback must explicitly set `document.getElementById('user-email').textContent = user.email;`. If a page's JS file omits this line (common on `customers.js` and any page copied from a template), the email never appears in the header even though the `

` element exists in the HTML. **Check:** ```bash # Verify logout button exists on all pages grep -ln 'id="logoutBtn"' *.html # Verify every JS file populates user-email for js in *.js; do grep -q "user-email.*textContent.*email\|userEmailDisplay.*textContent" "$js" || echo "MISSING user-email populate: $js" done ``` ```bash # Verify logout button exists on all pages grep -ln 'id="logoutBtn"' *.html ``` If a page is missing the button, copy the full button + divider section from a working page: ```html

``` ## 4. PocketBase Auth Token Bypass **Problem:** PocketBase SDK sign-out functions fail due to broken import chains, stale cache, or async race conditions. The user clicks Sign Out and nothing happens. **Bulletproof fix:** Clear the PocketBase auth token directly from localStorage: ```html ``` PocketBase stores auth in `localStorage` under the key `pocketbase_auth`. Removing this key and redirecting to `login.html` is equivalent to calling `pb.authStore.clear()` — but without any SDK dependency. ## 5. Admin Token vs User Token Bypass **Problem:** After migration, an admin-level PocketBase token in localStorage auto-authenticates the user, skipping the login page entirely. **Fix in `pocketbase.js` adapter:** ```js // Clear any admin/superuser auth tokens — only user collection logins allowed if (pb.authStore.isValid && (!pb.authStore.model || pb.authStore.model.collectionName !== 'users')) { pb.authStore.clear(); } ``` This filters out admin/superuser tokens but preserves normal user tokens (from the `users` collection). Valid user tokens are NOT cleared — the user stays logged in across refreshes, which is correct behavior. ## 6. Adapter Bypass — Raw SDK Diagnostic Pattern **Problem:** The login form uses an adapter (`pocketbase.js`) that wraps the PocketBase SDK. When login silently fails (no error, no redirect, just nothing happens), it's unclear whether the adapter code, the SDK import, or the API connection is the culprit. Adding `console.log` to the adapter means editing a file used by the entire app, risking collateral breakage. **Solution:** Create a minimal standalone test page that imports the PocketBase SDK **directly** (no adapter) and performs a raw `authWithPassword()` call. This completely isolates the auth code path from the app's module graph. **Test page template** (`login-test.html`): ```html Login Test

Login Test





``` **Place in the same directory** as the app's other HTML files so it shares the same origin (`window.location.origin` resolves to the same PocketBase backend). **Interpretation:** - **Test page works, login page doesn't** → The adapter/wrapper code is broken. Fix: replace adapter imports in `login.html` with raw SDK calls (see Section 7). - **Test page also fails** → The issue is in the PocketBase SDK, CDN availability, API connection, CORS, or the PocketBase server itself. Check network tab, CDN accessibility, and `docker`/`nginx` health. - **403 Forbidden** → File permissions (`chmod 644`). The test file was created with restrictive permissions that nginx can't read. **Transition from test to fix:** Once the test page confirms the raw SDK works, port the same raw SDK calls into `login.html`: ```javascript // BEFORE (adapter — silently fails): import { auth } from './pocketbase.js'; import { signInWithEmailAndPassword } from "./pocketbase.js"; const userCredential = await signInWithEmailAndPassword(auth, email, password); // AFTER (raw SDK — works): import PocketBase from "https://cdn.jsdelivr.net/npm/pocketbase@0.21.5/dist/pocketbase.es.mjs"; const pb = new PocketBase(window.location.origin); await pb.collection('users').authWithPassword(email, password); ``` Also replace adapter-dependent functions: - `sendPasswordResetEmail(auth, email)` → `pb.collection('users').requestPasswordReset(email)` - `createUserWithEmailAndPassword(auth, email, password)` + `updateProfile(user, {displayName: name})` → `pb.collection('users').create({email, password, passwordConfirm: password, emailVisibility: true, name})` followed by `pb.collection('users').authWithPassword(email, password)` - Any `import { auth } from './pocketbase.js'` → remove (not needed with raw SDK) **Cleanup:** After fixing `login.html`, delete `login-test.html` to avoid leaving diagnostic pages on the production site. ## 9. Settings Persistence — `data` JSON Field Pattern **Problem:** The app stores settings via `doc(db, 'users', uid, 'settings', 'appSettings')` — a Firestore subcollection pattern. The adapter's `setDoc()` calls `pb.collection('settings').create({ ...appSettings, id: 'appSettings' })`. But the `settings` collection only has 3 schema fields (`id`, `userId`, `name`), and PocketBase silently drops unknown fields during create/update. The 20+ fields of the `appSettings` object (`darkMode`, `taxRate`, `shopChargeRate`, etc.) are lost. Additionally, PocketBase v0.23+ rejects custom system IDs (`'appSettings'` is only 11 chars, minimum is 15). **Root cause:** PocketBase doesn't have Firestore's nested subcollection/document structure. A doc ref like `doc(db, 'users', uid, 'settings', 'appSettings')` maps to `_collection='settings', _id='appSettings'` — the adapter tries to create a record in the `settings` collection with system ID `appSettings`, which fails on ID validation and unknown fields. **Fix — Three layers:** ### Layer 1: Schema — Add a `json` field to the settings collection Add a `json` type field called `data` to the `settings` collection. Keep the existing `userId` and `name` fields for querying. The `data` field holds the arbitrary settings object. ### Layer 2: Adapter — Query-based upsert in `setDoc()` Instead of `update(id).catch(() => create({...data, id}))`, use a find-by-name-then-update-or-create pattern. Do NOT set the system `id` field — let PocketBase auto-generate it. Identify records by `name` + `userId` fields instead. Also add name-based fallback to `getDoc()` — try direct ID lookup first (backward compat), then fall back to query by `name = docId && userId = userId`. ### Layer 3: App code — Wrap settings in `data` field Every `saveAllSettings()` call must wrap the settings object: ``` await setDoc(userSettingsRef, { data: { ...appSettings, lastUpdated: new Date().toISOString() }, userId: currentUser.uid, name: 'appSettings' }); ``` Every load must read from the `data` field: ``` const record = settingsDoc.data(); if (record && record.data) { const { lastUpdated, ...cleanSettings } = record.data; } ``` ### Multiple settings documents | Doc ref ID | Save pattern | |---|---| | `appSettings` | `{ data: { ...siteConfig }, userId, name: 'appSettings' }` | | `accountSettings` | `{ data: { displayName, email, ... }, userId, name: 'accountSettings' }` | ### OnSnapshot realtime sync — same pattern ```javascript onSnapshot(siteRef, (snap) => { if (!snap.exists()) return; const record = snap.data(); if (!record || !record.data) return; const { lastUpdated, ...clean } = record.data; appSettings = { ...appSettings, ...clean }; }); ``` ### Pitfalls - **PocketBase v0.23+ rejects system IDs < 15 chars.** Do not set `id` in create requests. Use `name` + `userId` as the lookup key instead. - **The `settings` collection must have listRule/viewRule set to `userId = @request.auth.id`.** Without this, users can read each other's settings. - **Each app JS file that saves settings needs the same fix.** Search `setDoc.*accountSettings` across all JS files — 4+ files may need updating. - **The `data` field must be `json` type, not `text`.** A text field would stringify the object and lose nested structure. - **Test the full round-trip before deploying.** Create → query by name → read back → update → read back again. ## 10. Raw API Round-Trip Test for Settings When debugging settings persistence, test the PocketBase API directly (bypassing the app) to isolate adapter bugs from schema bugs. **Test script pattern (Python):** ```python # 1. Auth as user # 2. Create record with { data: {...}, userId, name } # 3. Query by name + userId filter # 4. Verify data field values # 5. Update and re-read # 6. Delete test record ``` Run locally via `python3` against `http://127.0.0.1:PORT/api/`. Use the admin token for schema changes, user token for record CRUD. This quickly proves whether the schema + API interaction works before touching adapter code. ## 7. Sync Auth Guard on Protected Pages **Problem:** Module-based auth checks (`onAuthStateChanged` in `dashboard.js`) run asynchronously. The page content may flash before the redirect fires, or the check may fail silently due to broken imports (wrong relative path) or cached stale modules. The user can access protected pages without being logged in. **Fix:** Add a synchronous inline script right after `` on every protected page: ```html ``` And the inverse on the login page (skip login if already authenticated): ```html ``` **Apply to all pages:** ```bash for f in index.html repair-orders.html appointments.html customers.html; do # Add after tag sed -i '/^if(!localStorage.getItem('\''pocketbase_auth'\'')){location.replace('\''login.html'\'')}' $f done # Inverse guard on login page sed -i '/^if(localStorage.getItem('\''pocketbase_auth'\'')){location.replace('\''index.html'\'')}' login.html ``` **Verify with curl:** ```bash curl -sk https://site.com/index.html | grep "location.replace('login.html')" ``` **Why `location.replace()` not `location.href`:** `replace()` doesn't add to browser history, preventing back-button loops where the user gets bounced between the protected page and login infinitely. **Always add `?cb=` cache-busting to redirects:** ```javascript // EVERY redirect should include ?cb=Date.now() location.replace('login.html?cb='+Date.now()); // auth guard location.replace('index.html?cb='+Date.now()); // login success localStorage.removeItem('pocketbase_auth'); location.replace('login.html?cb='+Date.now()); // sign-out ``` If the user's browser cached an old version of the target page (before the auth guard was added), a plain `login.html` redirect loads the stale cached page — which has no guard, so it appears to "not work." The `?cb=` parameter forces the browser to fetch a fresh copy. ## 8. CSP Blocking Inline Scripts After Migration **Problem:** After adding inline `