16 KiB
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:
# 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):
const { signOut } = await import('./pocketbase.js'); // WRONG: shared/pocketbase.js
const { auth } = await import('../firebase.js'); // WRONG: old Firebase file
Fixed:
const { signOut } = await import('../pocketbase.js');
const { auth } = await import('../pocketbase.js');
Batch fix all remaining firebase.js references at once:
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:
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 <p id="user-email"> element exists in the HTML.
Check:
# 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
# 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:
<div class="border-t border-gray-200 dark:border-gray-700 py-1">
<button id="logoutBtn" class="..." onclick="localStorage.removeItem('pocketbase_auth');window.location.href='login.html';">
<svg>...</svg>
Sign Out
</button>
</div>
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:
<button onclick="localStorage.removeItem('pocketbase_auth');window.location.href='login.html';">
Sign Out
</button>
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:
// 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):
<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"><title>Login Test</title></head>
<body>
<h2>Login Test</h2>
<input id="email" type="email" placeholder="Email"><br><br>
<input id="password" type="password" placeholder="Password"><br><br>
<button id="loginBtn">Sign In</button>
<div id="result"></div>
<script type="module">
import PocketBase from "https://cdn.jsdelivr.net/npm/pocketbase@0.21.5/dist/pocketbase.es.mjs";
const pb = new PocketBase(window.location.origin);
document.getElementById('loginBtn').onclick = async () => {
const email = document.getElementById('email').value;
const password = document.getElementById('password').value;
document.getElementById('result').textContent = 'Trying...';
try {
const authData = await pb.collection('users').authWithPassword(email, password);
document.getElementById('result').textContent = 'OK! Token: ' + authData.token.substring(0, 20) + '...';
} catch(e) {
document.getElementById('result').textContent = 'ERROR: ' + (e.message || JSON.stringify(e));
}
};
</script>
</body>
</html>
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.htmlwith 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/nginxhealth. - 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:
// 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 bypb.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
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
idin create requests. Usename+userIdas the lookup key instead. - The
settingscollection must have listRule/viewRule set touserId = @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.*accountSettingsacross all JS files — 4+ files may need updating. - The
datafield must bejsontype, nottext. 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):
# 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 <body> on every protected page:
<body class="...">
<script>if(!localStorage.getItem('pocketbase_auth')){location.replace('login.html')}</script>
And the inverse on the login page (skip login if already authenticated):
<body class="...">
<script>if(localStorage.getItem('pocketbase_auth')){location.replace('index.html')}</script>
Apply to all pages:
for f in index.html repair-orders.html appointments.html customers.html; do
# Add after <body ...> tag
sed -i '/^<body/a<script>if(!localStorage.getItem('\''pocketbase_auth'\'')){location.replace('\''login.html'\'')}</script>' $f
done
# Inverse guard on login page
sed -i '/^<body/a<script>if(localStorage.getItem('\''pocketbase_auth'\'')){location.replace('\''index.html'\'')}</script>' login.html
Verify with curl:
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:
// 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 <script> auth guards and onclick handlers to fix post-migration auth flow, only the main page (index.html) doesn't work — the auth guard doesn't redirect, dropdowns don't open, sign-out does nothing. Other pages work fine. The inline code is in the served HTML (verified with curl | grep).
Root cause: index.html has a <meta> Content Security Policy (commonly from a Firebase Hosting template) that blocks inline scripts:
<meta http-equiv="Content-Security-Policy"
content="...script-src 'self' https://cdn.example.com...">
No 'unsafe-inline' in script-src. The browser silently refuses to execute all inline <script> tags and onclick handlers. Other pages (repair-orders.html, etc.) have no CSP and work fine.
Diagnosis — compare the working vs broken page:
curl -sk https://site.com/index.html | grep -o 'script-src[^;]*'
# Returns: script-src 'self' https://cdn.tailwindcss.com ... (no 'unsafe-inline')
curl -sk https://site.com/repair-orders.html | grep -o 'script-src[^;]*'
# Returns: <empty> (no CSP meta tag at all)
Fix — add 'unsafe-inline':
<!-- BEFORE -->
<meta http-equiv="Content-Security-Policy"
content="...script-src 'self' https://cdn.example.com...">
<!-- AFTER -->
<meta http-equiv="Content-Security-Policy"
content="...script-src 'self' 'unsafe-inline' https://cdn.example.com...">
This is a migration-specific pitfall because the Firebase Hosting template included a CSP for security, but the PocketBase migration adds inline scripts (auth guards, onclick handlers) that the CSP blocks.