// Idempotent PocketBase schema setup for the Car Control project. // // Creates three collections — cars, service_records, parts — matching the // original "Car Service.xlsx". Access rules are left admin-only (null) on // purpose: every client goes through the API Server, which authenticates as a // superuser, so the database is never exposed directly. // // Usage (PowerShell): // $env:PB_URL="http://10.2.1.10:8027" // $env:PB_ADMIN_EMAIL="you@example.com" // $env:PB_ADMIN_PASSWORD="secret" // node scripts/setup-pocketbase.mjs // // Re-running is safe: existing collections are skipped. const PB_URL = (process.env.PB_URL || "http://10.2.1.10:8027").replace(/\/+$/, ""); const EMAIL = process.env.PB_ADMIN_EMAIL; const PASSWORD = process.env.PB_ADMIN_PASSWORD; if (!EMAIL || !PASSWORD) { console.error("Set PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD environment variables."); process.exit(1); } async function authenticate() { const endpoints = [ "/api/collections/_superusers/auth-with-password", "/api/admins/auth-with-password", ]; for (const ep of endpoints) { const res = await fetch(PB_URL + ep, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ identity: EMAIL, password: PASSWORD }), }); if (res.ok) { const data = await res.json(); return data.token; } } throw new Error("Authentication failed. Check PB_ADMIN_EMAIL / PB_ADMIN_PASSWORD."); } async function listCollections(token) { const res = await fetch(PB_URL + "/api/collections?perPage=200", { headers: { Authorization: token }, }); if (!res.ok) throw new Error(`list collections failed: ${res.status} ${await res.text()}`); const data = await res.json(); return Array.isArray(data) ? data : data.items || []; } // Detects whether this PocketBase version serializes fields under "fields" // (v0.23+) or the legacy "schema" key. function detectFormat(collections) { for (const c of collections) { if (Array.isArray(c.fields)) return "fields"; if (Array.isArray(c.schema)) return "schema"; } return "fields"; // default to modern format } // Field builders normalized to {name,type,required,relTo}. They are rendered // into the right wire shape per detected format. const F = { text: (name, required = false) => ({ name, type: "text", required }), number: (name) => ({ name, type: "number", required: false }), bool: (name) => ({ name, type: "bool", required: false }), date: (name, required = false) => ({ name, type: "date", required }), relation: (name, relTo, required = false, cascadeDelete = true) => ({ name, type: "relation", required, relTo, cascadeDelete }), select: (name, values, required = false) => ({ name, type: "select", required, values }), autodate: (name, onCreate = false, onUpdate = false) => ({ name, type: "autodate", required: false, onCreate, onUpdate }), }; function renderField(def, format, idByName) { if (format === "schema") { // Legacy: options nested under "options". const options = {}; if (def.type === "relation") { options.collectionId = idByName[def.relTo]; options.cascadeDelete = def.cascadeDelete !== false; options.maxSelect = 1; options.minSelect = 0; } if (def.type === "select") { options.values = def.values; options.maxSelect = 1; } return { name: def.name, type: def.type, required: def.required, options }; } // Modern: options flattened onto the field. const field = { name: def.name, type: def.type, required: def.required }; if (def.type === "relation") { field.collectionId = idByName[def.relTo]; field.cascadeDelete = def.cascadeDelete !== false; field.maxSelect = 1; field.minSelect = 0; } if (def.type === "select") { field.values = def.values; field.maxSelect = 1; } if (def.type === "autodate") { field.onCreate = def.onCreate; field.onUpdate = def.onUpdate; } return field; } async function createCollection(token, name, defs, format, idByName) { const rendered = defs.map((d) => renderField(d, format, idByName)); const body = { name, type: "base", [format]: rendered, // "fields" or "schema" // Rules left null => superuser-only access (API Server is the only client). listRule: null, viewRule: null, createRule: null, updateRule: null, deleteRule: null, }; const res = await fetch(PB_URL + "/api/collections", { method: "POST", headers: { "Content-Type": "application/json", Authorization: token }, body: JSON.stringify(body), }); if (!res.ok) throw new Error(`create ${name} failed: ${res.status} ${await res.text()}`); const created = await res.json(); idByName[name] = created.id; return created; } async function getCollection(token, idOrName) { const res = await fetch(PB_URL + "/api/collections/" + idOrName, { headers: { Authorization: token }, }); if (!res.ok) throw new Error(`get ${idOrName} failed: ${res.status} ${await res.text()}`); return res.json(); } // reconcileFields brings an existing collection's schema in line with the desired // definition: it appends any missing fields AND updates relation options // (currently cascadeDelete) and select options (the "values" list) on existing // fields. Existing field ids/data are kept. Safe to re-run as the schema // evolves (e.g. adding cars.current_km, enabling cascade delete, or adding a // new select choice like a date-format option). async function reconcileFields(token, name, defs, format, idByName) { const col = await getCollection(token, name); const current = col[format] || []; const byName = new Map(current.map((f) => [f.name, f])); const changes = []; // Update relation cascadeDelete and select values on existing fields to match desired. const merged = current.map((f) => { const def = defs.find((d) => d.name === f.name); if (def && def.type === "relation") { const wantCascade = def.cascadeDelete !== false; if (f.cascadeDelete !== wantCascade) { changes.push(`${f.name}.cascadeDelete=${wantCascade}`); return { ...f, cascadeDelete: wantCascade }; } } if (def && def.type === "select") { const same = Array.isArray(f.values) && f.values.length === def.values.length && def.values.every((v) => f.values.includes(v)); if (!same) { changes.push(`${f.name}.values=[${def.values.join(",")}]`); return { ...f, values: def.values }; } } return f; }); // Append missing fields. const missing = defs.filter((d) => !byName.has(d.name)); for (const d of missing) { merged.push(renderField(d, format, idByName)); changes.push(`+${d.name}`); } if (changes.length === 0) { console.log(`• ${name} — up to date`); return; } const res = await fetch(PB_URL + "/api/collections/" + col.id, { method: "PATCH", headers: { "Content-Type": "application/json", Authorization: token }, body: JSON.stringify({ [format]: merged }), }); if (!res.ok) throw new Error(`update ${name} failed: ${res.status} ${await res.text()}`); console.log(`✓ ${name} — ${changes.join(", ")}`); } // Desired schema. Edit here to evolve collections; re-run the script to apply. const DESIRED = { cars: [ F.text("name", true), F.text("make"), F.text("model"), F.number("year"), F.text("registration"), F.text("registration_country"), F.text("vin"), F.number("service_interval_days"), F.number("service_interval_km"), F.text("oil_spec"), F.text("transmission_oil_spec"), F.text("differential_oil_spec"), F.text("brake_fluid_spec"), F.text("coolant_spec"), F.number("current_km"), F.select("fuel_type", ["petrol", "diesel", "hybrid", "electric"]), F.text("build_date"), // ISO YYYY-MM-DD (date-only; VIN 10th digit ≈ model year) F.text("first_registration_date"), // ISO YYYY-MM-DD // Owner of this car. Non-cascading on purpose: deleting a user must not // wipe their cars (account deletion in me.go intentionally leaves cars). // required:false at the DB level — the API always sets owner on create and // existing rows are backfilled (scripts/backfill-car-owners.mjs). F.relation("owner", "users", false, false), ], service_records: [ F.relation("car", "cars", true), F.date("date", true), F.number("km"), F.bool("changed_oil"), F.bool("changed_engine_air_filter"), F.bool("changed_cabin_air_filter"), F.text("notes"), ], parts: [ F.relation("car", "cars", true), F.text("name", true), F.text("part_number"), F.text("category"), ], // Per-car sharing grants. One row = "this user may access this car" at the // given permission. Cascades on both relations so grants disappear when // either the car or the user is deleted. (Owner access is NOT stored here — // it's implied by cars.owner.) car_shares: [ F.relation("car", "cars", true), F.relation("user", "users", true), F.select("permission", ["read", "write"], true), F.autodate("created", true, false), ], // Custom fields layered onto the built-in "users" auth collection (which // already ships with email/name/avatar). Settings-panel additions: users: [ F.text("bio"), F.select("theme", ["light", "dark", "system"]), F.text("locale"), F.select("date_format", ["YMD", "DMY_NUM", "DMY", "MDY"]), F.select("font_size", ["small", "medium", "large"]), F.date("deletion_requested_at"), // Access role. Empty value is treated as "user" by the API. F.select("role", ["user", "admin"]), ], // One row per issued login token, so "active sessions" can be listed and // individually revoked without needing a stateful session store elsewhere. sessions: [ F.relation("user", "users", true), F.text("jti", true), F.text("device_label"), F.text("ip"), F.text("user_agent"), F.bool("revoked"), F.date("expires_at", true), F.autodate("created", true, false), ], }; async function main() { console.log(`Connecting to ${PB_URL} ...`); const token = await authenticate(); console.log("Authenticated as superuser."); let collections = await listCollections(token); const format = detectFormat(collections); console.log(`Schema format: "${format}"`); const idByName = {}; for (const c of collections) idByName[c.name] = c.id; // Create in dependency order (cars before its relations; "users" already // exists as PocketBase's built-in auth collection, so it's never created // here — only reconciled below). for (const name of ["cars", "service_records", "parts", "sessions", "car_shares"]) { if (collections.some((c) => c.name === name)) continue; await createCollection(token, name, DESIRED[name], format, idByName); console.log(`✓ ${name} — created`); // Refresh so later relations can reference newly-created collection ids. collections = await listCollections(token); for (const c of collections) idByName[c.name] = c.id; } // Reconcile fields on existing collections (add missing + fix relation options). for (const name of ["users", "cars", "service_records", "parts", "sessions", "car_shares"]) { await reconcileFields(token, name, DESIRED[name], format, idByName); } console.log("\nDone. Collections ready: users, cars, service_records, parts, sessions, car_shares."); } main().catch((err) => { console.error("\nSetup failed:", err.message); process.exit(1); });