164 lines
6.3 KiB
JavaScript
164 lines
6.3 KiB
JavaScript
|
|
#!/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);
|
||
|
|
});
|