// Idempotent PocketBase schema setup for the Car Control project. // // Creates the collections behind the app: cars, service_records and parts (which // match the original "Car Service.xlsx"), the sharing/tenancy tables, and the // fuel, maintenance, document and reminder logs layered on top. 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 // — including document attachments, which are proxied by the API rather than // served as public file URLs. // // 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 }), // Single-file attachment. maxSize is in bytes; mimeTypes [] means "any". file: (name, maxSize, mimeTypes = []) => ({ name, type: "file", required: false, maxSize, mimeTypes }), // Free-form JSON blob. maxSize is in bytes. json: (name, maxSize = 100000) => ({ name, type: "json", required: false, maxSize }), }; 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; } if (def.type === "file") { options.maxSelect = 1; options.maxSize = def.maxSize; options.mimeTypes = def.mimeTypes || []; } if (def.type === "json") { options.maxSize = def.maxSize; } 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; } if (def.type === "file") { field.maxSelect = 1; field.maxSize = def.maxSize; field.mimeTypes = def.mimeTypes || []; } if (def.type === "json") { field.maxSize = def.maxSize; } 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, }; if (INDEXES[name]) body.indexes = INDEXES[name]; 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(", ")}`); } // The single optional attachment a record can carry — a scan, a receipt, a // workshop invoice, a photo of a part. The 10MB cap matches maxAttachmentUpload // in the API server, and the file is reached only via the API's own file // endpoint, never as a public URL. const attachment = () => F.file("file", 10485760, [ "application/pdf", "image/jpeg", "image/png", "image/webp", "image/heic", ]); // 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"), // Roadworthiness inspection cycle. Only prefills a check's next-due date — // the legal interval changes as the car ages, so each check can override it. F.number("technical_check_interval_days"), 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"), // Bi-fuel LPG conversions are their own choice rather than a flag: the car // runs on either tank, so "petrol + LPG" is what an owner picks it out as. F.select("fuel_type", [ "petrol", "petrol_lpg", "diesel", "diesel_lpg", "hybrid", "electric", "hydrogen", ]), F.text("build_date"), // ISO YYYY-MM-DD (date-only; VIN 10th digit ≈ model year) F.text("first_registration_date"), // ISO YYYY-MM-DD // Link to the manufacturer service this car came from (see // internal/api/vehicleproviders.go): the plugin name, plus that plugin's own // id for the vehicle (the VIN, for Toyota). Set when a car is imported from // or linked to a connected account; blank for a hand-entered car. F.text("provider"), F.text("provider_vehicle_id"), // What this car's page shows: the tabs switched off (["fuel"] on an EV) and // the Information rows switched off (["differentialOil"]). Properties of the // car, so everyone it is shared with sees the same page. The hidden sets, // not the visible ones, so anything added in a later release is on by // default. Keys are validated in internal/api/cars.go. F.json("hidden_tabs", 2000), F.json("hidden_fields", 2000), // The order the tabs are laid out in, as tab keys, and the order the // Information rows are laid out in, as field keys — the hidden ones // included in both, so one switched back on returns to where it was. Empty // means the page's own default order. metric_order is the same for the // headline readings on the connected service's tab. F.json("tab_order", 2000), F.json("field_order", 2000), F.json("metric_order", 2000), // 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"), attachment(), // the workshop receipt / stamped service-book page ], // Mandatory roadworthiness inspections (przegląd techniczny / MOT / TÜV). // Like service_records but time-only: a check falls due on a date whatever the // odometer reads. valid_until is the expiry printed on the certificate; when // blank the API derives it from the car's interval. technical_checks: [ F.relation("car", "cars", true), F.date("date", true), F.select("result", ["passed", "failed"]), F.number("cost"), F.text("station"), F.date("valid_until"), F.text("notes"), attachment(), // the certificate ], parts: [ F.relation("car", "cars", true), F.text("name", true), F.text("part_number"), F.text("category"), F.text("notes"), attachment(), // a photo of the box, or the part's spec sheet ], // Fuel refills. Consumption is NOT stored — the API derives it from the whole // history on read (models.ComputeFuelDerived), so correcting an old fill fixes // every figure it affects with no rows to migrate. fuel_entries: [ F.relation("car", "cars", true), F.date("date", true), F.number("km"), // odometer at the pump F.number("liters"), F.number("cost"), // Filled to the brim — the reference point efficiency is measured between. F.bool("full_tank"), // A refill happened before this one without being logged, so any window // containing it is left uncomputed rather than reported as implausibly good. F.bool("missed_fill"), F.text("station"), F.text("notes"), attachment(), // the pump receipt ], // Charging sessions for an electric car. The EV counterpart of fuel_entries // and deliberately the same shape: kWh where litres would be, a charge to the // usual full point as the reference the windows are measured between, and // consumption derived on read (models.ComputeChargingDerived). charging_sessions: [ F.relation("car", "cars", true), F.date("date", true), F.number("km"), // odometer when plugging in F.number("kwh"), F.number("cost"), // Charged to the car's usual full point — the reference point. F.bool("full_charge"), // The car was charged before this without being logged, so any window // containing it is left uncomputed rather than reported as implausible. F.bool("missed_session"), F.text("location"), // "Home", "Ionity Koge" F.text("notes"), attachment(), // the charge point's receipt ], // Workshop visits and repairs. Deliberately separate from service_records: // that collection is the routine interval schedule (and drives next-service // due), this one is unplanned/one-off garage work with a labour bill. maintenance_entries: [ F.relation("car", "cars", true), F.date("date", true), F.number("km"), F.select("type", ["repair", "inspection", "bodywork", "tyres", "diagnostics", "recall", "warranty", "other"]), F.select("status", ["scheduled", "in_progress", "completed"]), F.text("workshop"), F.text("location"), F.text("description"), F.text("parts_used"), F.number("labor_cost"), F.number("parts_cost"), F.text("invoice_number"), F.date("warranty_until"), F.text("notes"), attachment(), // the workshop's invoice ], // Insurance, pollution certificates, registration papers … The expiry date is // the point of the record: it drives the renewal status badges and the // auto-derived reminders. Blank expiry = never expires. car_documents: [ F.relation("car", "cars", true), F.select("type", ["insurance", "pollution", "registration", "inspection", "roadTax", "warranty", "other"]), F.text("title", true), F.text("provider"), F.text("reference"), F.date("issue_date"), F.date("expiry_date"), F.number("cost"), F.text("notes"), attachment(), // the scan/PDF of the paperwork itself ], // User-set reminders. The API additionally synthesises read-only ones from // document expiry dates and the next service due — those are derived on read // and have no rows here. reminders: [ F.relation("car", "cars", true), F.text("title", true), F.select("type", ["maintenance", "document", "service", "inspection", "other"]), F.date("due_date"), F.number("due_km"), // Non-zero => recurring: completing rolls the trigger forward by this much. F.number("repeat_days"), F.number("repeat_km"), F.bool("done"), F.date("done_at"), F.text("notes"), ], // 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), ], // Append-only audit trail for OCPP charger control (start/stop/limit/reset/ // unlock/…, token generate/revoke, and charger connects). Actor/org are stored // as plain text ids (not relations) so the trail survives user or org deletion. // Written best-effort by the API Server (internal/api/integrations_ankersolix_control.go); // if this collection is absent, control still works and only the structured log // line remains. control_audit: [ F.text("user_id"), F.text("org_id"), F.text("serial"), F.text("action", true), F.text("result"), F.json("params", 10000), F.autodate("created", true, false), ], // The chargers a user owns — their own wallbox, not the public network. A // charger belongs to a person rather than to a car: it charges whichever car // is plugged into it, and it outlives any of them. Created by importing from a // connected charger service (Anker Solix, Greencell); see // internal/api/chargerproviders.go. home_chargers: [ F.text("name", true), F.text("serial"), F.text("vendor"), // "Anker Solix", "Greencell" — who makes it F.text("model"), // "A5191" F.text("site_name"), F.number("power_kw"), F.text("connector"), // Where this charger came from: the provider id plus that provider's own id // for it (the serial, for both providers we speak to). Blank when added by hand. F.text("provider"), F.text("provider_charger_id"), // Owner. Non-cascading, like a car's: deleting a user must not wipe their records. F.relation("owner", "users", false, false), // A charger carries no date of its own, so the import order is the only order // there is to list them in. PocketBase adds no created field to a collection // defined through the API, so it is declared here like the audit trail's. F.autodate("created", true, false), ], // Server-wide settings as a single record, keyed "global". Today it holds // pluginSettings: the top (L1) layer of the integration cascade — every // plugin's enable state, its global config, and the registration of any // external HTTP plugin. The org and user layers below keep their own plugin // config in a field of the same name. See internal/plugins/store.go. app_settings: [ F.text("key", true), F.json("pluginSettings", 200000), ], // Tenants that users belong to. A superadmin spans all of them; an admin // manages only their own. organizations: [ F.text("name", true), F.autodate("created", true, false), // Per-organization plugin/integration config — the middle (org admin) layer // of the integration cascade (API Server → org admin → user). Shape: // { "": { "config": {…}, "disabled": bool } } // See internal/api/integrations.go. Only meaningful for plugins that expose // a per-user cascade (today: toyota). F.json("pluginSettings"), ], // 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"]), // European currencies plus the non-European ones the panel already offered. // Kept in step with validCurrencies in internal/api/me.go and CURRENCY_CODES // in the web app's Settings.vue. F.select("currency", [ "EUR", "GBP", "CHF", "PLN", "CZK", "HUF", "RON", "BGN", "DKK", "SEK", "NOK", "ISK", "ALL", "AMD", "AZN", "BAM", "BYN", "GEL", "MDL", "MKD", "RSD", "RUB", "TRY", "UAH", "USD", "CAD", "AUD", "JPY", ]), F.select("font_size", ["small", "medium", "large"]), // Holds every arrangement on this user's pages still — the garage, a car's // tabs and Information rows, the provider's readings — so reading a page // cannot nudge its layout. Per user, like car_order. F.bool("drag_locked"), F.date("deletion_requested_at"), // Access role. Empty value is treated as "user" by the API. F.select("role", ["user", "admin", "superadmin"]), // Organization membership. Non-cascading on purpose: deleting an org must // not delete its people. (The API refuses to delete an org that still has // members, so this should not arise in practice.) F.relation("organization", "organizations", false, false), // Per-user plugin/integration config — the bottom (user) layer of the // integration cascade. Shape: // { "": { "config": {…}, "enabled": bool } } // The `enabled` flag is the personal opt-in; see internal/api/integrations.go. F.json("pluginSettings"), // The garage order: car ids as this user dragged them, e.g. ["c2","c1"]. // Per user rather than per car, so it also covers cars shared with them and // never reorders somebody else's garage. See internal/api/cars.go. F.json("car_order", 20000), ], }; // Extra SQL indexes, applied at collection-create time. Organization names are // unique so the API can rely on PocketBase rejecting a duplicate. const INDEXES = { // One settings record per key, so the global singleton cannot be duplicated. app_settings: ["CREATE UNIQUE INDEX `idx_app_settings_key` ON `app_settings` (`key`)"], organizations: ["CREATE UNIQUE INDEX `idx_organizations_name` ON `organizations` (`name`)"], // Every read of these is "…for this car", and the fuel history is walked in // odometer order to build its efficiency windows. fuel_entries: ["CREATE INDEX `idx_fuel_entries_car_km` ON `fuel_entries` (`car`, `km`)"], charging_sessions: ["CREATE INDEX `idx_charging_sessions_car_km` ON `charging_sessions` (`car`, `km`)"], maintenance_entries: ["CREATE INDEX `idx_maintenance_entries_car_date` ON `maintenance_entries` (`car`, `date`)"], car_documents: ["CREATE INDEX `idx_car_documents_car_expiry` ON `car_documents` (`car`, `expiry_date`)"], reminders: ["CREATE INDEX `idx_reminders_car_due` ON `reminders` (`car`, `due_date`)"], // Read as "this car's checks, newest first" every time. technical_checks: ["CREATE INDEX `idx_technical_checks_car_date` ON `technical_checks` (`car`, `date`)"], // A charger is looked up by its owner, and by serial when checking whether the // account it came from has already been imported. home_chargers: ["CREATE INDEX `idx_home_chargers_owner_serial` ON `home_chargers` (`owner`, `serial`)"], // Audit is queried "this charger's events, newest first" and "this user's events". control_audit: [ "CREATE INDEX `idx_control_audit_serial_created` ON `control_audit` (`serial`, `created`)", "CREATE INDEX `idx_control_audit_user_created` ON `control_audit` (`user_id`, `created`)", ], }; 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 (organizations before users references it; 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 [ "app_settings", "organizations", "cars", "service_records", "technical_checks", "parts", "car_shares", "fuel_entries", "charging_sessions", "maintenance_entries", "car_documents", "reminders", "control_audit", "home_chargers", ]) { 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 // and select values — this is what grows users.role to include "superadmin" // and adds users.organization on an existing deployment). for (const name of [ "app_settings", "organizations", "users", "cars", "service_records", "technical_checks", "parts", "car_shares", "fuel_entries", "charging_sessions", "maintenance_entries", "car_documents", "reminders", "control_audit", "home_chargers", ]) { await reconcileFields(token, name, DESIRED[name], format, idByName); } console.log( "\nDone. Collections ready: app_settings, organizations, users, cars,\n" + "service_records,\n" + "technical_checks, parts, car_shares, fuel_entries, charging_sessions,\n" + "maintenance_entries, car_documents, reminders, control_audit, home_chargers.", ); console.log( "Note: the legacy `sessions` collection is no longer used (auth moved to PocketBase\n" + "tokens). It is left in place rather than dropped — delete it by hand if you want.", ); } main().catch((err) => { console.error("\nSetup failed:", err.message); process.exit(1); });