/** * Add a field to a LIVE PocketBase collection, idempotently. * * Exists because this repo's `pb_schema (1).json` is a stale export: * it lists fields the database does not have (and omits whole * collections). PocketBase SILENTLY DROPS unknown fields on update — no * error, no failed response — so code written against a phantom field * appears to work while storing nothing. * * SAFETY: * - Prints the current state and exits 0 if the field already exists. * - Sends the FULL `fields[]` array back with one addition. * `PATCH /api/collections/:c/fields/:id` 404s on PocketBase, so the * collection-level PATCH is the supported route. * - Re-reads afterwards and diffs field-by-field, failing loudly if * anything other than the addition changed. * * Usage: * npx tsx scripts/add-collection-field.ts order_items collection_date date * npx tsx scripts/add-collection-field.ts order_items pool_entered_at date */ import { config } from "dotenv"; config({ path: ".env.local" }); config({ path: ".env" }); const url = process.env.NEXT_PUBLIC_POCKETBASE_URL || ""; const email = process.env.PB_ADMIN_EMAIL || ""; const password = process.env.PB_ADMIN_PASSWORD || ""; type Field = Record & { name: string; type: string }; const [, , collectionArg, nameArg, typeArg] = process.argv; if (!collectionArg || !nameArg || !typeArg) { console.error( "usage: npx tsx scripts/add-collection-field.ts ", ); process.exit(2); } async function auth(): Promise { const res = await fetch(`${url}/api/collections/_superusers/auth-with-password`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ identity: email, password }), }); if (!res.ok) throw new Error(`auth failed: ${res.status} ${await res.text()}`); return ((await res.json()) as { token: string }).token; } async function getCollection(token: string, name: string) { const res = await fetch(`${url}/api/collections/${name}`, { headers: { Authorization: token }, }); if (!res.ok) throw new Error(`get ${name} failed: ${res.status} ${await res.text()}`); return (await res.json()) as { fields: Field[] }; } async function main() { if (!url || !email || !password) throw new Error("missing PB env vars"); const token = await auth(); const before = await getCollection(token, collectionArg); console.log(`${collectionArg}: ${before.fields.length} fields`); const existing = before.fields.find((f) => f.name === nameArg); if (existing) { console.log(`\n${nameArg} already exists (${existing.type}) — nothing to do.`); return; } // Copy the shape of an existing field of the same type so the new one // matches its siblings (PB stores per-field config beyond type/name). const template = before.fields.find((f) => f.type === typeArg); const newField: Field = { ...(template ?? {}), id: `fld_${nameArg.replace(/[^a-z0-9]/gi, "_").slice(0, 11)}`, name: nameArg, type: typeArg, required: false, presentable: false, hidden: false, system: false, }; const fields = [...before.fields, newField]; const patchRes = await fetch(`${url}/api/collections/${collectionArg}`, { method: "PATCH", headers: { Authorization: token, "Content-Type": "application/json" }, body: JSON.stringify({ fields }), }); if (!patchRes.ok) { throw new Error(`patch failed: ${patchRes.status} ${await patchRes.text()}`); } const after = await getCollection(token, collectionArg); const added = after.fields.filter((f) => !before.fields.some((b) => b.name === f.name)); const removed = before.fields.filter((f) => !after.fields.some((a) => a.name === f.name)); const changed = after.fields.filter((a) => { const b = before.fields.find((x) => x.name === a.name); return b && JSON.stringify(b) !== JSON.stringify(a); }); console.log(`\nadded: ${added.map((f) => `${f.name} (${f.type})`).join(", ") || "(none)"}`); console.log(`removed: ${removed.map((f) => f.name).join(", ") || "(none)"}`); console.log(`changed: ${changed.map((f) => f.name).join(", ") || "(none)"}`); if (added.length !== 1 || removed.length > 0 || changed.length > 0) { throw new Error("UNEXPECTED schema drift — inspect before proceeding."); } console.log(`\nOK: ${nameArg} added, nothing else moved.`); } void main().catch((err: unknown) => { console.error(`FAILED: ${err instanceof Error ? err.message : String(err)}`); process.exit(1); });