Files
hermes-config/skills/software-development/web-ui-repair/references/pocketbase-migration-pitfalls.md
T
2026-07-12 10:17:17 -04:00

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

// 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

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

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