#!/usr/bin/env node /** * pb_migration_shareholder_change.mjs * * ONE-OFF, IDEMPOTENT schema migration that adds the storage the * "5 - Change in shareholders" section needs. * * --------------------------------------------------------------------------- * WHY A MIGRATION INSTEAD OF RUNTIME ADMIN CALLS * --------------------------------------------------------------------------- * Editing a PocketBase collection schema requires superuser rights. Doing that * from a request handler would ship admin credentials into the hot path and * demand superuser auth on every deploy. This script applies the change ONCE, * ahead of time; the running app then uses only ordinary record-level APIs * (which lib/pb.ts already self-heals with PB_ADMIN_*). * * Re-running is safe: every step checks for the field/option first and skips it * if already present. Run it as often as you like. * * --------------------------------------------------------------------------- * WHAT IT CHANGES (all additive - no existing field, row or option is modified * or removed) * --------------------------------------------------------------------------- * ysterbaard_resolutions + kind select * director_change (default) * shareholder_change * * ysterbaard_resolution_attendees + shares number (integer, optional) * + director_role gains two options: * transferor (seller of shares) * transferee (buyer of shares) * * Existing resolutions default to kind='director_change', so section 4 keeps * behaving exactly as before. * * --------------------------------------------------------------------------- * USAGE * --------------------------------------------------------------------------- * node scripts/pb_migration_shareholder_change.mjs # apply * node scripts/pb_migration_shareholder_change.mjs --check # report only * * Credentials come from .env.local (PB_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD). */ import { readFileSync } from "node:fs"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; const HERE = path.dirname(fileURLToPath(import.meta.url)); const ENV_FILE = process.env.ENV_FILE || path.join(HERE, "..", ".env.local"); const RESOLUTIONS_COL = "pbc_266170881"; // ysterbaard_resolutions const ATTENDEES_COL = "pbc_1608833669"; // ysterbaard_resolution_attendees const NEW_KINDS = ["director_change", "shareholder_change"]; const NEW_SHARE_ROLES = ["transferor", "transferee"]; // --------------------------------------------------------------------------- // env // --------------------------------------------------------------------------- function loadEnv() { let raw = ""; try { raw = readFileSync(ENV_FILE, "utf8"); } catch { throw new Error(`Could not read env file: ${ENV_FILE}`); } // The "m" flag matters: without it "^" only anchors to the very start of the // file, so every key except a leading one reads as missing. const get = (key) => { const m = raw.match(new RegExp(`^\\s*${key}\\s*=\\s*"?([^"\\r\\n]+)`, "m")); return m ? m[1].trim() : ""; }; const cfg = { url: (process.env.PB_URL || get("PB_URL")).replace(/\/+$/, ""), email: process.env.PB_ADMIN_EMAIL || get("PB_ADMIN_EMAIL"), password: process.env.PB_ADMIN_PASSWORD || get("PB_ADMIN_PASSWORD"), }; const missing = Object.entries(cfg) .filter(([, v]) => !v) .map(([k]) => k); if (missing.length) { throw new Error( `Missing config in ${ENV_FILE}: ${missing.join(", ")}. ` + `Set them in the file or pass as environment variables.` ); } return cfg; } // --------------------------------------------------------------------------- // pb // --------------------------------------------------------------------------- async function auth(cfg) { const res = await fetch(`${cfg.url}/api/collections/_superusers/auth-with-password`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ identity: cfg.email, password: cfg.password }), }); if (!res.ok) { throw new Error(`Superuser auth failed: HTTP ${res.status} ${await res.text()}`); } return (await res.json()).token; } async function pbGet(token, url) { const res = await fetch(url, { headers: { Authorization: token }, cache: "no-store" }); if (!res.ok) { throw new Error(`GET ${url} failed: HTTP ${res.status} ${await res.text()}`); } return res.json(); } // This PocketBase instance serves collection updates from // PATCH /api/collections/{id}. The two routes newer PocketBase builds expose // (PUT /api/collections/{id} and the PUT /api/collections upsert) both return // 404 here, so PATCH is the verb to use - verified against the live instance, // not guessed. async function pbPatch(token, url, body) { const res = await fetch(url, { method: "PATCH", headers: { Authorization: token, "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (!res.ok) { throw new Error(`PATCH ${url} failed: HTTP ${res.status} ${await res.text()}`); } return res.json(); } // --------------------------------------------------------------------------- // schema helpers // --------------------------------------------------------------------------- // // GET /api/collections/{id} returns the whole schema, including internal keys // on every field. PATCH expects that same shape back, so the safe approach is: // read -> mutate one slice -> write the rest verbatim. // PocketBase stores select options as a plain array of strings on this // instance (["existing","resigned",...]), NOT as {value,color} objects. // optionValue tolerates both shapes so the script keeps working if a collection // was ever edited through the admin UI. function optionValue(v) { return typeof v === "string" ? v : (v && v.value) || ""; } function optionValues(field) { return (field.values || []).map(optionValue).filter(Boolean); } function kindField() { return { name: "kind", type: "select", required: false, presentable: false, values: [...NEW_KINDS], maxSelect: 1, max: 0, min: 0, pattern: "", autogeneratePattern: false, }; } function sharesField() { return { name: "shares", type: "number", required: false, presentable: false, onlyInt: true, min: null, max: null, system: false, }; } /** * Strip server-managed keys from a field read out of PB before writing it back. * PocketBase derives `id` itself and refuses to accept `system: true` fields on * create, so both are dropped. `hidden` is preserved explicitly because PB * expects the key to be present. */ function stripServerKeys(field) { const { id, system, hidden, ...rest } = field; return { ...rest, hidden: Boolean(hidden), system: false }; } function normaliseFields(fields) { return fields.map(stripServerKeys); } // --------------------------------------------------------------------------- // steps // --------------------------------------------------------------------------- /** * Add `field` to a collection if `name` is not present. * Returns "added" | "present" | "would-add". */ async function ensureField({ token, base, colId, field, checkOnly, label }) { const url = `${base}/api/collections/${colId}`; const current = await pbGet(token, url); const fields = current.fields || []; if (fields.some((f) => f.name === field.name)) { console.log(` - ${label}: already present - skipped`); return "present"; } if (checkOnly) { console.log(` - ${label}: would be added`); return "would-add"; } const updated = await pbPatch(token, url, { ...current, fields: normaliseFields([...fields, field]), }); if (!(updated.fields || []).some((f) => f.name === field.name)) { throw new Error(`${label}: PATCH succeeded but field is missing`); } console.log(` + ${label}: added`); return "added"; } /** * Append select options to an existing select field, keeping the current * options and their order intact. * Returns "added" | "present" | "would-add". */ async function ensureSelectOptions({ token, base, colId, fieldName, newValues, checkOnly, label, }) { const url = `${base}/api/collections/${colId}`; const current = await pbGet(token, url); const fields = current.fields || []; const field = fields.find((f) => f.name === fieldName); if (!field) { throw new Error(`Select field "${fieldName}" does not exist on collection ${colId}`); } if (field.type !== "select") { throw new Error(`Field "${fieldName}" is a "${field.type}", expected "select"`); } const have = new Set(optionValues(field)); const missing = newValues.filter((v) => !have.has(v)); if (missing.length === 0) { console.log(` - ${label}: already present - skipped`); return "present"; } if (checkOnly) { console.log(` - ${label}: would add ${missing.join(", ")}`); return "would-add"; } const updated = await pbPatch(token, url, { ...current, fields: normaliseFields( fields.map((f) => f.name === fieldName ? { ...f, values: [...optionValues(f), ...missing] } : f ) ), }); const now = (updated.fields || []).find((f) => f.name === fieldName); const nowHave = new Set(optionValues(now || {})); const stillMissing = missing.filter((v) => !nowHave.has(v)); if (stillMissing.length) { throw new Error(`${label}: PATCH succeeded but options missing`); } console.log(` + ${label}: added ${missing.join(", ")}`); return "added"; } // --------------------------------------------------------------------------- // main // --------------------------------------------------------------------------- async function main() { const checkOnly = process.argv.includes("--check"); const cfg = loadEnv(); console.log(""); console.log("PB migration - change in shareholders"); console.log(` target : ${cfg.url}`); console.log(` mode : ${checkOnly ? "CHECK ONLY (no writes)" : "APPLY"}`); console.log(""); const token = await auth(cfg); console.log("1. ysterbaard_resolutions"); await ensureField({ token, base: cfg.url, colId: RESOLUTIONS_COL, field: kindField(), checkOnly, label: "kind select (director_change | shareholder_change)", }); console.log("2. ysterbaard_resolution_attendees"); await ensureField({ token, base: cfg.url, colId: ATTENDEES_COL, field: sharesField(), checkOnly, label: "shares number", }); await ensureSelectOptions({ token, base: cfg.url, colId: ATTENDEES_COL, fieldName: "director_role", newValues: NEW_SHARE_ROLES, checkOnly, label: "director_role options (transferor | transferee)", }); // ---- verify ---- console.log(""); console.log("Verification"); const res = await pbGet(token, `${cfg.url}/api/collections/${RESOLUTIONS_COL}`); const att = await pbGet(token, `${cfg.url}/api/collections/${ATTENDEES_COL}`); const kind = (res.fields || []).find((f) => f.name === "kind"); const shares = (att.fields || []).find((f) => f.name === "shares"); const role = (att.fields || []).find((f) => f.name === "director_role"); const roleValues = optionValues(role || {}); console.log(` resolutions.kind : ${kind ? optionValues(kind).join(", ") : "MISSING"}`); console.log( ` attendees.shares : ${ shares ? `number${shares.onlyInt ? " (integer)" : ""}` : "MISSING" }` ); console.log(` attendees.director_role : ${roleValues.join(", ")}`); const problems = []; if (!kind) problems.push("resolutions.kind missing"); if (!shares) problems.push("attendees.shares missing"); for (const v of NEW_SHARE_ROLES) { if (!roleValues.includes(v)) problems.push(`director_role.${v} missing`); } // Guard the option set that section 4 depends on. for (const v of ["existing", "resigned", "appointed", "external"]) { if (!roleValues.includes(v)) problems.push(`director_role.${v} LOST`); } console.log(""); if (problems.length) { console.error(`FAILED: ${problems.join("; ")}`); process.exitCode = 1; return; } console.log( checkOnly ? "Check complete. Re-run without --check to apply." : "Migration complete. The app now needs only record-level API access." ); console.log(""); } main().catch((err) => { console.error(""); console.error("Migration failed:", err.message); console.error(""); process.exitCode = 1; });