JEISA-WEBAPP/scripts/push-migrations.mjs

164 lines
6.3 KiB
JavaScript
Raw Normal View History

#!/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=<migration_name>
*
* 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);
});