120 lines
4.4 KiB
TypeScript
120 lines
4.4 KiB
TypeScript
|
|
/**
|
||
|
|
* 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<string, unknown> & { name: string; type: string };
|
||
|
|
|
||
|
|
const [, , collectionArg, nameArg, typeArg] = process.argv;
|
||
|
|
|
||
|
|
if (!collectionArg || !nameArg || !typeArg) {
|
||
|
|
console.error(
|
||
|
|
"usage: npx tsx scripts/add-collection-field.ts <collection> <field_name> <type>",
|
||
|
|
);
|
||
|
|
process.exit(2);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function auth(): Promise<string> {
|
||
|
|
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);
|
||
|
|
});
|