// Package bootstrap brings a fresh PocketBase up to the schema the API Server // expects, on server startup. It is the Go port of scripts/setup-pocketbase.mjs // and is idempotent: existing collections are reconciled (missing fields added, // relation/select options fixed) rather than recreated, and an already-present // super-admin user is left untouched. // // It creates the app-level collections and, optionally, the DriverVault // super-admin account. It does NOT create the PocketBase *superuser* — that is a // chicken-and-egg the REST API can't solve (creating a _superusers record needs // an existing superuser token), so the PocketBase container upserts it from env // on boot instead. Bootstrap authenticates with that same superuser. package bootstrap import ( "context" "encoding/json" "fmt" "log" "net/http" "net/url" "drivervault/apiserver/internal/pb" ) // Options configures a bootstrap run. SuperAdmin* are optional: when either the // email or password is empty, the super-admin step is skipped. type Options struct { UsersCollection string SuperAdminEmail string SuperAdminPassword string SuperAdminName string } // fieldDef is a schema field normalized to a single shape; it is rendered into // the right PocketBase wire format ("fields" for v0.23+, legacy "schema") per // the detected server version. type fieldDef struct { name string typ string required bool relTo string // relation target collection name cascadeDelete bool // relations only; default true values []string onCreate bool // autodate only onUpdate bool // autodate only maxSize int // file/json only, bytes mimeTypes []string } // Field builders, mirroring the F.* helpers in setup-pocketbase.mjs. func fText(name string, required bool) fieldDef { return fieldDef{name: name, typ: "text", required: required} } func fNumber(name string) fieldDef { return fieldDef{name: name, typ: "number"} } func fBool(name string) fieldDef { return fieldDef{name: name, typ: "bool"} } func fDate(name string, required bool) fieldDef { return fieldDef{name: name, typ: "date", required: required} } func fRelation(name, relTo string, required, cascade bool) fieldDef { return fieldDef{name: name, typ: "relation", required: required, relTo: relTo, cascadeDelete: cascade} } func fSelect(name string, values []string, required bool) fieldDef { return fieldDef{name: name, typ: "select", required: required, values: values} } func fAutodate(name string, onCreate, onUpdate bool) fieldDef { return fieldDef{name: name, typ: "autodate", onCreate: onCreate, onUpdate: onUpdate} } func fFile(name string, maxSize int, mimeTypes []string) fieldDef { return fieldDef{name: name, typ: "file", maxSize: maxSize, mimeTypes: mimeTypes} } func fJSON(name string, maxSize int) fieldDef { return fieldDef{name: name, typ: "json", maxSize: maxSize} } // attachment is the single optional file a record can carry (scan/receipt/photo). // The 10MB cap matches maxAttachmentUpload in the API server. func attachment() fieldDef { return fFile("file", 10485760, []string{ "application/pdf", "image/jpeg", "image/png", "image/webp", "image/heic", }) } // renderField turns a fieldDef into the map PocketBase expects, in either the // modern ("fields") or legacy ("schema") layout. idByName resolves a relation's // target collection name to its id. func renderField(d fieldDef, format string, idByName map[string]string) map[string]any { if format == "schema" { options := map[string]any{} switch d.typ { case "relation": options["collectionId"] = idByName[d.relTo] options["cascadeDelete"] = d.cascadeDelete options["maxSelect"] = 1 options["minSelect"] = 0 case "select": options["values"] = d.values options["maxSelect"] = 1 case "file": options["maxSelect"] = 1 options["maxSize"] = d.maxSize options["mimeTypes"] = mimeOrEmpty(d.mimeTypes) case "json": options["maxSize"] = d.maxSize } return map[string]any{"name": d.name, "type": d.typ, "required": d.required, "options": options} } field := map[string]any{"name": d.name, "type": d.typ, "required": d.required} switch d.typ { case "relation": field["collectionId"] = idByName[d.relTo] field["cascadeDelete"] = d.cascadeDelete field["maxSelect"] = 1 field["minSelect"] = 0 case "select": field["values"] = d.values field["maxSelect"] = 1 case "autodate": field["onCreate"] = d.onCreate field["onUpdate"] = d.onUpdate case "file": field["maxSelect"] = 1 field["maxSize"] = d.maxSize field["mimeTypes"] = mimeOrEmpty(d.mimeTypes) case "json": field["maxSize"] = d.maxSize } return field } func mimeOrEmpty(m []string) []string { if m == nil { return []string{} } return m } // Run authenticates as the superuser, creates any missing collections, reconciles // existing ones, and (when configured) ensures the DriverVault super-admin exists. // It is safe to call on every startup. func Run(ctx context.Context, client *pb.Client, opts Options) error { if err := client.Authenticate(ctx); err != nil { return fmt.Errorf("authenticate: %w", err) } existing, err := listCollections(ctx, client) if err != nil { return fmt.Errorf("list collections: %w", err) } format := detectFormat(existing) log.Printf("bootstrap: schema format %q", format) present := make(map[string]bool, len(existing)) idByName := make(map[string]string, len(existing)) for _, c := range existing { present[c.Name] = true idByName[c.Name] = c.ID } // Create in dependency order: organizations before users references it; cars // before its relations. "users" is PocketBase's built-in auth collection and // is never created here — only reconciled below. for _, name := range createOrder { if present[name] { continue } if err := createCollection(ctx, client, name, format, idByName); err != nil { return fmt.Errorf("create %s: %w", name, err) } log.Printf("bootstrap: ✓ %s created", name) // Refresh so later relations resolve newly-created collection ids. refreshed, err := listCollections(ctx, client) if err != nil { return fmt.Errorf("re-list collections: %w", err) } for _, c := range refreshed { present[c.Name] = true idByName[c.Name] = c.ID } } // Reconcile fields on existing collections (add missing + fix relation // cascade and select value lists). for _, name := range reconcileOrder { if err := reconcileFields(ctx, client, name, format, idByName); err != nil { return fmt.Errorf("reconcile %s: %w", name, err) } } if err := ensureSuperAdmin(ctx, client, opts); err != nil { return fmt.Errorf("super-admin: %w", err) } return nil } // EnsureCollection creates one collection from the desired schema if it is not // already present, and does nothing otherwise. // // It is deliberately narrower than Run: no field reconcile on other // collections, no super-admin. That makes it safe to call on a deployment that // turned the full bootstrap off, which is the case it exists for — a collection // the server cannot run without (app_settings, holding the plugin settings) is // missing, and no amount of retrying a read will conjure it. func EnsureCollection(ctx context.Context, client *pb.Client, name string) error { if _, known := collectionsSchema[name]; !known { return fmt.Errorf("unknown collection %q", name) } if err := client.Authenticate(ctx); err != nil { return fmt.Errorf("authenticate: %w", err) } existing, err := listCollections(ctx, client) if err != nil { return fmt.Errorf("list collections: %w", err) } idByName := make(map[string]string, len(existing)) present := false for _, c := range existing { idByName[c.Name] = c.ID if c.Name == name { present = true } } if present { return nil } if err := createCollection(ctx, client, name, detectFormat(existing), idByName); err != nil { return fmt.Errorf("create %s: %w", name, err) } return nil } // --- PocketBase collection calls ------------------------------------------- type collectionMeta struct { ID string `json:"id"` Name string `json:"name"` Fields []map[string]any `json:"fields"` Schema []map[string]any `json:"schema"` } func listCollections(ctx context.Context, client *pb.Client) ([]collectionMeta, error) { raw, status, err := client.Raw(ctx, http.MethodGet, "/api/collections?perPage=200", nil) if err != nil { return nil, err } if status < 200 || status >= 300 { return nil, fmt.Errorf("status %d: %s", status, raw) } var env struct { Items []collectionMeta `json:"items"` } if err := json.Unmarshal(raw, &env); err != nil { return nil, err } return env.Items, nil } // detectFormat reports whether this PocketBase serializes fields under "fields" // (v0.23+) or the legacy "schema" key, defaulting to the modern format. func detectFormat(cols []collectionMeta) string { for _, c := range cols { if len(c.Fields) > 0 { return "fields" } if len(c.Schema) > 0 { return "schema" } } return "fields" } func createCollection(ctx context.Context, client *pb.Client, name, format string, idByName map[string]string) error { rendered := make([]map[string]any, 0, len(collectionsSchema[name])) for _, d := range collectionsSchema[name] { rendered = append(rendered, renderField(d, format, idByName)) } body := map[string]any{ "name": name, "type": "base", format: rendered, // "fields" or "schema" // Rules left null => superuser-only access (the API Server is the only client). "listRule": nil, "viewRule": nil, "createRule": nil, "updateRule": nil, "deleteRule": nil, } if idx := indexes[name]; len(idx) > 0 { body["indexes"] = idx } raw, status, err := client.Raw(ctx, http.MethodPost, "/api/collections", body) if err != nil { return err } if status < 200 || status >= 300 { return fmt.Errorf("status %d: %s", status, raw) } var created struct { ID string `json:"id"` } if err := json.Unmarshal(raw, &created); err == nil { idByName[name] = created.ID } return nil } // reconcileFields brings an existing collection in line with the desired schema: // it appends missing fields and updates relation cascadeDelete and select value // lists on existing fields, preserving every existing field (including system // fields) and their ids. func reconcileFields(ctx context.Context, client *pb.Client, name, format string, idByName map[string]string) error { raw, status, err := client.Raw(ctx, http.MethodGet, "/api/collections/"+name, nil) if err != nil { return err } if status < 200 || status >= 300 { return fmt.Errorf("get: status %d: %s", status, raw) } var col collectionMeta if err := json.Unmarshal(raw, &col); err != nil { return err } current := col.Fields if format == "schema" { current = col.Schema } defs := collectionsSchema[name] defByName := make(map[string]fieldDef, len(defs)) for _, d := range defs { defByName[d.name] = d } var changes []string merged := make([]map[string]any, 0, len(current)+len(defs)) haveName := make(map[string]bool, len(current)) for _, f := range current { fname, _ := f["name"].(string) haveName[fname] = true if def, ok := defByName[fname]; ok { switch def.typ { case "relation": if asBool(f["cascadeDelete"]) != def.cascadeDelete { f["cascadeDelete"] = def.cascadeDelete changes = append(changes, fname+".cascadeDelete") } case "select": if !sameValues(f["values"], def.values) { f["values"] = def.values changes = append(changes, fname+".values") } } } merged = append(merged, f) } // Append missing fields. for _, d := range defs { if !haveName[d.name] { merged = append(merged, renderField(d, format, idByName)) changes = append(changes, "+"+d.name) } } if len(changes) == 0 { log.Printf("bootstrap: • %s up to date", name) return nil } patch := map[string]any{format: merged} raw, status, err = client.Raw(ctx, http.MethodPatch, "/api/collections/"+col.ID, patch) if err != nil { return err } if status < 200 || status >= 300 { return fmt.Errorf("patch: status %d: %s", status, raw) } log.Printf("bootstrap: ✓ %s — %v", name, changes) return nil } // ensureSuperAdmin creates the DriverVault super-admin user when it does not yet // exist. An already-present account (matched by email) is left untouched. func ensureSuperAdmin(ctx context.Context, client *pb.Client, opts Options) error { if opts.SuperAdminEmail == "" || opts.SuperAdminPassword == "" { return nil // not requested } coll := opts.UsersCollection if coll == "" { coll = "users" } filter := url.QueryEscape(fmt.Sprintf("email='%s'", opts.SuperAdminEmail)) raw, status, err := client.Raw(ctx, http.MethodGet, "/api/collections/"+coll+"/records?perPage=1&filter="+filter, nil) if err != nil { return err } if status < 200 || status >= 300 { return fmt.Errorf("lookup: status %d: %s", status, raw) } var found struct { TotalItems int `json:"totalItems"` } if err := json.Unmarshal(raw, &found); err != nil { return err } if found.TotalItems > 0 { log.Printf("bootstrap: • super-admin %s already exists", opts.SuperAdminEmail) return nil } name := opts.SuperAdminName if name == "" { name = "Administrator" } create := map[string]any{ "email": opts.SuperAdminEmail, "password": opts.SuperAdminPassword, "passwordConfirm": opts.SuperAdminPassword, "name": name, "role": "superadmin", "verified": true, "emailVisibility": false, } raw, status, err = client.Raw(ctx, http.MethodPost, "/api/collections/"+coll+"/records", create) if err != nil { return err } if status < 200 || status >= 300 { return fmt.Errorf("create: status %d: %s", status, raw) } log.Printf("bootstrap: ✓ super-admin %s created", opts.SuperAdminEmail) return nil } // --- helpers --------------------------------------------------------------- func asBool(v any) bool { b, _ := v.(bool) return b } // sameValues reports whether a select field's current values equal the desired // set (order-insensitive), matching the reconcile check in setup-pocketbase.mjs. func sameValues(current any, want []string) bool { arr, ok := current.([]any) if !ok || len(arr) != len(want) { return false } have := make(map[string]bool, len(arr)) for _, v := range arr { if s, ok := v.(string); ok { have[s] = true } } for _, w := range want { if !have[w] { return false } } return true }