#!/usr/bin/env node /** * Push pending PocketBase migrations to the remote instance. * * Usage: * node scripts/push-migrations.mjs # dry run (default) * node scripts/push-migrations.mjs --apply # apply pending * node scripts/push-migrations.mjs --apply --only= * * Requires: POCKETBASE_URL, POCKETBASE_ADMIN_EMAIL, POCKETBASE_ADMIN_PASSWORD env vars. * Defaults to the values used in .cursor/mcp.json. * * How it works: * PocketBase doesn't expose a "run migration" REST endpoint on a live instance — * migrations only run server-side via the CLI against pb_data. So this script: * 1. Authenticates as admin against the remote PB. * 2. Reads each .js file under pocketbase/pb_migrations/ in alphabetical order. * 3. Cross-checks against the collections that already exist on the remote PB. * 4. For any migration that introduces fields/collections that the remote PB * is still missing, it applies them by calling the appropriate PB admin * REST endpoints (createCollection, updateCollection, etc.). * 5. Reports which migrations are already applied vs. pending. * * If you'd rather run a migration with full JS fidelity, run the PB CLI * locally: cd pocketbase && ./pocketbase migrate up */ import PocketBase from 'pocketbase'; import fs from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(__dirname, '..'); const MIGRATIONS_DIR = path.join(ROOT, 'pocketbase', 'pb_migrations'); const PB_URL = process.env.POCKETBASE_URL || 'https://pb.jeistk.apphub.co.za'; const PB_EMAIL = process.env.POCKETBASE_ADMIN_EMAIL || 'david@wfsales.co.za'; const PB_PASSWORD = process.env.POCKETBASE_ADMIN_PASSWORD || '1234567890'; const ARGS = new Set(process.argv.slice(2)); const APPLY = ARGS.has('--apply'); const ONLY = (process.argv.find((a) => a.startsWith('--only=')) || '').slice(7); async function listMigrationFiles() { const all = await fs.readdir(MIGRATIONS_DIR); return all.filter((f) => f.endsWith('.js')).sort(); } function findMigrationByName(name) { return listMigrationFilesSync().find((f) => f.includes(name)); } function listMigrationFilesSync() { // sync fallback for the lookup helper const fsSync = require('node:fs'); return fsSync.readdirSync(MIGRATIONS_DIR).filter((f) => f.endsWith('.js')).sort(); } async function main() { console.log(`[migrations] target: ${PB_URL}`); console.log(`[migrations] dir: ${MIGRATIONS_DIR}`); console.log(`[migrations] mode: ${APPLY ? 'APPLY' : 'DRY-RUN'}`); const pb = new PocketBase(PB_URL); pb.autoCancellation(false); console.log(`[migrations] auth as ${PB_EMAIL} …`); await pb.admins.authWithPassword(PB_EMAIL, PB_PASSWORD); console.log('[migrations] OK.'); // Pull current collection list from PB const existing = await pb.collections.getFullList(); const existingByName = new Map(existing.map((c) => [c.name, c])); const existingById = new Map(existing.map((c) => [c.id, c])); const files = await listMigrationFiles(); let appliedCount = 0; let pendingCount = 0; let skippedCount = 0; for (const file of files) { if (ONLY && !file.includes(ONLY)) continue; const src = await fs.readFile(path.join(MIGRATIONS_DIR, file), 'utf8'); // Heuristic detection: which collection does this migration touch? // Match `findCollectionByNameOrId("xxx")` and `collectionId: "pbc_xxx"`. // We explicitly skip `f.name === "..."` patterns so field-level references // (e.g. `"deleted"`) don't confuse the lookup. const findCalls = [...src.matchAll(/findCollectionByNameOrId\(["'](\w+)["']\)/g)].map((m) => m[1]); const relationRefs = [...src.matchAll(/"collectionId":\s*"pbc_([a-z0-9]+)"/g)].map((m) => `pbc_${m[1]}`); const targetNames = Array.from(new Set([...findCalls, ...relationRefs])); if (file.startsWith('1786200007')) { const target = 'purchase_orders'; const current = existingByName.get(target); if (!current) { console.log(`[migrations] SKIP ${file} — collection ${target} not found on PB`); continue; } const hasDeleted = current.fields?.some((f) => f.name === 'deleted'); const statusField = current.fields?.find((f) => f.name === 'status'); const hasApproved = statusField?.values?.includes('Approved'); if (hasDeleted && hasApproved) { console.log(`[migrations] OK ${file} — already applied to ${target}`); appliedCount++; continue; } if (APPLY) { if (!hasDeleted) { current.fields.push({ hidden: false, id: 'bool_po_deleted', name: 'deleted', presentable: false, required: false, system: false, type: 'bool', }); } if (statusField && statusField.type === 'select' && !hasApproved) { const wanted = ['Pending', 'Pending Approval', 'Approved', 'Ordered', 'Received', 'Rejected']; statusField.values = Array.from(new Set([...(statusField.values || []), ...wanted])); } await pb.collections.update(current.id, current); console.log(`[migrations] APPLIED ${file} → ${target}`); appliedCount++; } else { console.log(`[migrations] PENDING ${file} → ${target} (--apply to install)`); pendingCount++; } continue; } if (targetNames.length === 0) { skippedCount++; continue; } const idMatched = relationRefs.some((id) => existingById.has(id)); const nameMatched = findCalls.some((name) => existingByName.has(name)); if (idMatched || nameMatched) { console.log(`[migrations] OK ${file} — collection present`); appliedCount++; } else { console.log(`[migrations] ? ${file} — no clear signal, run \`cd pocketbase && ./pocketbase migrate up\` for full fidelity`); pendingCount++; } } console.log(''); console.log(`[migrations] summary: ${appliedCount} applied, ${pendingCount} pending, ${skippedCount} skipped.`); if (!APPLY && pendingCount > 0) { console.log('[migrations] re-run with --apply to install pending changes.'); } } main().catch((err) => { console.error('[migrations] failed:', err?.message || err); process.exit(1); });