JEISA-WEBAPP/scripts/push-migrations.mjs
Dave 0b8f003f87 feat(po): add MCP server, agent-friendly PO API, and PDF rendering
- server/pb-mcp.mjs: 15-tool MCP server for purchase-order workflow
  (CRUD, suppliers, stock, customers, customer POs, validate_draft,
  generate_order_number)
- src/lib/api/purchaseOrders.ts: typed helpers shared by UI + MCP + n8n
- src/components/purchase-orders/pdf.ts: jsPDF PO generator
- src/components/purchase-orders/ViewOrderDialog.tsx: read-only dialog
- src/components/purchase-orders/AttachmentsDialog.tsx: attachment mgmt
- src/components/purchase-orders/NumberInput.tsx: numeric input
- scripts/mcp-create-po.mjs: end-to-end driver (MCP stdio smoke test)
- pocketbase/pb_migrations/1786200007_*: schema migration
- docs/MCP_AND_N8N.md: MCP + n8n integration guide

Verified end-to-end via MCP: generate_order_number → create_purchase_order
with snapshot_items for an actual quote (DRILL-19042 / 161053).
2026-09-07 11:12:13 +02:00

164 lines
No EOL
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);
});