Files
DriverVault/API Server/internal/bootstrap/schema.go
T
tajniak81andClaude Opus 5 9bd5c523c4 Plugins: the global layer moves into the database, beside the other two
The integration cascade stored its top layer differently from the two below
it: org (L2) and user (L3) plugin config lived in PocketBase, in a
pluginSettings field, while the global (L1) layer sat in a plugins.json
next to the binary. That split was accretion rather than design - the file
was the whole store in the v1 MVP, and the per-tenant layers were later
built on PocketBase and layered on top of it instead of replacing it.

It also cost something real. plugins.json was a second state store with
different durability from pb_data: its own volume, its own ownership, its
own backup. Losing pb_data is unmissable; losing api_data was silent, which
is how "every plugin comes back disabled after a redeploy" happened.

L1 now lives in the app_settings collection - one record keyed "global",
holding its settings in a pluginSettings field, the same mechanism and the
same field name the layers below use. The documents still differ in shape,
because only L1 carries enable state and the registration of external
plugins, but the storage is no longer a special case.

The Manager grows a Store seam (PocketBase in production, file for the
import, memory for tests) and, more importantly, a loaded gate. Settings in
a database mean the store can be unreachable at boot - a cold stack, or a
service account still to be set from the panel. That must not read as "no
plugins configured", or the first save would write emptiness over real
settings. So until a read succeeds the Manager stays unloaded, every
mutation is refused, /api/admin/plugins* answers 503, and a background
retry backs off to two minutes. The same gate covers a document that will
not parse: it is never replaced by one built from an empty map, which is a
stronger guarantee than the .corrupt backup it replaces.

Writing to a store also revealed a hole in the previous fix. Classifying a
save failure as errPersist was left to each Store, and a store that
returned a plain error would fall through to the "saved, but the plugin
failed to start" branch and be reported as a 200 - the same silent-success
bug through a different door. The Manager now classifies, whatever the
Store returns; a test pins it.

Upgrades are automatic: on the first boot that finds no settings in the
database, an existing plugins.json is imported and renamed to
plugins.json.migrated. The import is refused if the store is merely
unreachable, or if the file does not parse, so a stale or broken file can
never overwrite live settings. /data is still needed - the panel rewrites
.env there when it retargets PocketBase - but plugin settings no longer
depend on it.

21 tests in internal/plugins cover both stores, including the production
path against a fake PocketBase: create-then-update of the singleton,
round-trip across a restart, an outage that leaves settings intact, a
missing collection reading as not-ready rather than empty, and the import
running exactly once. go build, go vet and go test ./... pass. Schema
changes are mirrored into scripts/setup-pocketbase.mjs as that file
requires. Not verified: no Docker CLI here, so no image was built and the
bootstrap of app_settings against a real PocketBase is untested outside the
fake.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 16:52:47 +02:00

276 lines
11 KiB
Go

package bootstrap
// This file is the Go mirror of the DESIRED schema, create order, reconcile
// order and INDEXES in scripts/setup-pocketbase.mjs. Keep the two in sync: edit
// here and re-run the server (bootstrap applies on startup), or run the script.
//
// Access rules are left null on every collection on purpose — every client goes
// through the API Server, which authenticates as a superuser, so the database is
// never exposed directly (including attachments, proxied by the API).
// collectionsSchema is the desired field set per collection.
var collectionsSchema = map[string][]fieldDef{
"cars": {
fText("name", true),
fText("make", false),
fText("model", false),
fNumber("year"),
fText("registration", false),
fText("registration_country", false),
fText("vin", false),
fNumber("service_interval_days"),
fNumber("service_interval_km"),
// Roadworthiness inspection cycle. Only prefills a check's next-due date.
fNumber("technical_check_interval_days"),
fText("oil_spec", false),
fText("transmission_oil_spec", false),
fText("differential_oil_spec", false),
fText("brake_fluid_spec", false),
fText("coolant_spec", false),
fNumber("current_km"),
// Bi-fuel LPG conversions are their own choice: the car runs on either tank.
fSelect("fuel_type", []string{
"petrol", "petrol_lpg", "diesel", "diesel_lpg", "hybrid", "electric", "hydrogen",
}, false),
fText("build_date", false), // ISO YYYY-MM-DD (date-only)
fText("first_registration_date", false), // ISO YYYY-MM-DD
// Link to the manufacturer service this car came from: the plugin name plus
// that plugin's own id for the vehicle (the VIN, for Toyota). See
// internal/api/vehicleproviders.go. Blank for a hand-entered car.
fText("provider", false),
fText("provider_vehicle_id", false),
// What this car's page shows: the tabs switched off (["fuel"] on an EV)
// and the Information fields switched off (["differentialOil"] on a car
// without one). Properties of the car, so everyone it is shared with sees
// the same page. Stored as the hidden sets, so anything added in a later
// release is on by default. Keys are validated in internal/api/cars.go.
fJSON("hidden_tabs", 2000),
fJSON("hidden_fields", 2000),
// The order the tabs are laid out in, as tab keys, the same for the
// Information rows, and the same for the connected service's headline
// readings. Empty means the page's own default order.
fJSON("tab_order", 2000),
fJSON("field_order", 2000),
fJSON("metric_order", 2000),
// Owner of this car. Non-cascading: deleting a user must not wipe their cars.
fRelation("owner", "users", false, false),
},
"service_records": {
fRelation("car", "cars", true, true),
fDate("date", true),
fNumber("km"),
fBool("changed_oil"),
fBool("changed_engine_air_filter"),
fBool("changed_cabin_air_filter"),
fText("notes", false),
attachment(), // the workshop receipt / stamped service-book page
},
// Mandatory roadworthiness inspections (przegląd techniczny / MOT / TÜV).
"technical_checks": {
fRelation("car", "cars", true, true),
fDate("date", true),
fSelect("result", []string{"passed", "failed"}, false),
fNumber("cost"),
fText("station", false),
fDate("valid_until", false),
fText("notes", false),
attachment(), // the certificate
},
"parts": {
fRelation("car", "cars", true, true),
fText("name", true),
fText("part_number", false),
fText("category", false),
fText("notes", false),
attachment(), // a photo of the box, or the part's spec sheet
},
// Fuel refills. Consumption is derived on read, not stored.
"fuel_entries": {
fRelation("car", "cars", true, true),
fDate("date", true),
fNumber("km"), // odometer at the pump
fNumber("liters"),
fNumber("cost"),
fBool("full_tank"),
fBool("missed_fill"),
fText("station", false),
fText("notes", false),
attachment(), // the pump receipt
},
// Charging sessions for an electric car — the EV counterpart of fuel_entries,
// same shape so the two logs behave alike. Consumption is derived on read.
"charging_sessions": {
fRelation("car", "cars", true, true),
fDate("date", true),
fNumber("km"), // odometer when plugging in
fNumber("kwh"),
fNumber("cost"),
fBool("full_charge"),
fBool("missed_session"),
fText("location", false), // "Home", "Ionity Køge"
fText("notes", false),
attachment(), // the charge point's receipt
},
// Workshop visits and repairs (unplanned/one-off garage work with a labour bill).
"maintenance_entries": {
fRelation("car", "cars", true, true),
fDate("date", true),
fNumber("km"),
fSelect("type", []string{"repair", "inspection", "bodywork", "tyres", "diagnostics", "recall", "warranty", "other"}, false),
fSelect("status", []string{"scheduled", "in_progress", "completed"}, false),
fText("workshop", false),
fText("location", false),
fText("description", false),
fText("parts_used", false),
fNumber("labor_cost"),
fNumber("parts_cost"),
fText("invoice_number", false),
fDate("warranty_until", false),
fText("notes", false),
attachment(), // the workshop's invoice
},
// Insurance, pollution certificates, registration papers … expiry drives reminders.
"car_documents": {
fRelation("car", "cars", true, true),
fSelect("type", []string{"insurance", "pollution", "registration", "inspection", "roadTax", "warranty", "other"}, false),
fText("title", true),
fText("provider", false),
fText("reference", false),
fDate("issue_date", false),
fDate("expiry_date", false),
fNumber("cost"),
fText("notes", false),
attachment(), // the scan/PDF of the paperwork
},
// User-set reminders (derived document/service ones are computed on read).
"reminders": {
fRelation("car", "cars", true, true),
fText("title", true),
fSelect("type", []string{"maintenance", "document", "service", "inspection", "other"}, false),
fDate("due_date", false),
fNumber("due_km"),
fNumber("repeat_days"),
fNumber("repeat_km"),
fBool("done"),
fDate("done_at", false),
fText("notes", false),
},
// Per-car sharing grants. Cascades on both relations.
"car_shares": {
fRelation("car", "cars", true, true),
fRelation("user", "users", true, true),
fSelect("permission", []string{"read", "write"}, true),
fAutodate("created", true, false),
},
// Append-only audit trail for OCPP charger control. Actor/org stored as plain
// text ids (not relations) so the trail survives user or org deletion.
"control_audit": {
fText("user_id", false),
fText("org_id", false),
fText("serial", false),
fText("action", true),
fText("result", false),
fJSON("params", 10000),
fAutodate("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 (L2) and user (L3) layers keep their own
// plugin config in a field of the same name below, so the global layer is
// stored the way they are instead of in a file beside the binary.
"app_settings": {
fText("key", true),
fJSON("pluginSettings", 200000),
},
// Tenants that users belong to.
"organizations": {
fText("name", true),
fAutodate("created", true, false),
// Per-organization plugin/integration config (middle layer of the cascade).
fJSON("pluginSettings", 100000),
},
// Custom fields layered onto the built-in "users" auth collection.
"users": {
fText("bio", false),
fSelect("theme", []string{"light", "dark", "system"}, false),
fText("locale", false),
fSelect("date_format", []string{"YMD", "DMY_NUM", "DMY", "MDY"}, false),
fSelect("currency", []string{
"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",
}, false),
fSelect("font_size", []string{"small", "medium", "large"}, false),
// 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 the garage order.
fBool("drag_locked"),
fDate("deletion_requested_at", false),
// Access role. Empty value is treated as "user" by the API.
fSelect("role", []string{"user", "admin", "superadmin"}, false),
// Organization membership. Non-cascading: deleting an org keeps its people.
fRelation("organization", "organizations", false, false),
// Per-user plugin/integration config (bottom layer of the cascade).
fJSON("pluginSettings", 100000),
// The garage order: car ids in the order this user arranged them. Per
// user rather than per car, so it also covers cars shared with them and
// never reorders somebody else's garage.
fJSON("car_order", 20000),
},
}
// createOrder is the dependency order for creating missing collections.
// "users" is PocketBase's built-in auth collection and is never created here.
var createOrder = []string{
"app_settings",
"organizations",
"cars",
"service_records",
"technical_checks",
"parts",
"car_shares",
"fuel_entries",
"charging_sessions",
"maintenance_entries",
"car_documents",
"reminders",
"control_audit",
}
// reconcileOrder additionally includes "users" so its custom fields (role,
// organization, preferences) are added to the built-in collection.
var reconcileOrder = []string{
"app_settings",
"organizations",
"users",
"cars",
"service_records",
"technical_checks",
"parts",
"car_shares",
"fuel_entries",
"charging_sessions",
"maintenance_entries",
"car_documents",
"reminders",
"control_audit",
}
// indexes are extra SQL indexes applied at collection-create time.
var indexes = map[string][]string{
// 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`)"},
"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`)"},
"technical_checks": {"CREATE INDEX `idx_technical_checks_car_date` ON `technical_checks` (`car`, `date`)"},
"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`)",
},
}