archivist: official enum select fields with application-managed value lists

- enum fields become PocketBase select columns created with a single
  '-----' placeholder; the special template field starts with the
  configured template names (US-03, US-37)
- new enums.go owns the value lists: EnsureEnumValues registers new
  values before a record save validates them, CleanupEnumValues runs at
  startup and keeps only the values a document uses (placeholder kept
  only while alone), template forced to the configuration
- setup_test.go now asserts the select columns and the cleanup
This commit is contained in:
2026-11-12 09:39:20 +01:00
parent a834cf2576
commit 2c0fb89144
3 changed files with 329 additions and 104 deletions
+181
View File
@@ -0,0 +1,181 @@
// Package pbapp: official enum value lists (US-03, US-37).
//
// Declared enum fields are PocketBase select columns. The application owns
// their value lists: creation with a single "-----" placeholder, values
// added when a processed document or a dashboard action introduces them,
// unused values removed at startup. The special "template" field always
// keeps exactly the template names of the configuration.
package pbapp
import (
"fmt"
"sort"
"ganigeorgiev.com/pocketbase/pocketbase/core"
"github.com/napnap75/archivist/internal/config"
)
// DummyEnumValue is the placeholder that keeps a select field valid when a
// collection is created before any real value exists. It survives cleanup
// only while it is the field's only value (a PocketBase select needs at
// least one).
const DummyEnumValue = "-----"
// enumFieldValues reads the official select values of a declared enum
// field, placeholder excluded, sorted.
func enumFieldValues(app core.App, name string) ([]string, error) {
coll, err := app.FindCollectionByNameOrId("documents")
if err != nil {
return nil, err
}
field, _ := coll.Fields.GetByName(name).(*core.SelectField)
if field == nil {
return nil, fmt.Errorf("field %s is not an enum", name)
}
values := make([]string, 0, len(field.Values))
for _, v := range field.Values {
if v != DummyEnumValue {
values = append(values, v)
}
}
sort.Strings(values)
return values, nil
}
// enumVocabulary returns the initial values of an enum field at collection
// creation: the template names for the special "template" field, the dummy
// placeholder otherwise.
func enumVocabulary(cfg *config.Config, name string) []string {
if name == "template" {
if names := sortedTemplateNames(cfg); len(names) > 0 {
return names
}
}
return []string{DummyEnumValue}
}
// AddEnumValue appends value to the field's official select list when it
// is missing; the record save that follows can then validate against it.
func AddEnumValue(app core.App, name, value string) error {
coll, err := app.FindCollectionByNameOrId("documents")
if err != nil {
return err
}
field, _ := coll.Fields.GetByName(name).(*core.SelectField)
if field == nil {
return fmt.Errorf("field %s is not an enum", name)
}
for _, v := range field.Values {
if v == value {
return nil
}
}
field.Values = append(field.Values, value)
return app.Save(coll)
}
// EnsureEnumValues adds every enum value carried by fields to its official
// list, so the subsequent record save passes the select validation.
func EnsureEnumValues(app core.App, cfg *config.Config, fields map[string]any) error {
for _, f := range cfg.FieldList {
if f.Type != "enum" {
continue
}
v, _ := fields[f.Name].(string)
if v == "" {
continue
}
if err := AddEnumValue(app, f.Name, v); err != nil {
return fmt.Errorf("enum %s: %w", f.Name, err)
}
}
return nil
}
// CleanupEnumValues runs at startup: the "template" field keeps exactly the
// configured template names; every other enum field keeps only the values
// still used by a document; the dummy placeholder survives only when a
// field has no other value (a PocketBase select needs at least one).
func CleanupEnumValues(app core.App, cfg *config.Config) {
for _, f := range cfg.FieldList {
if f.Type != "enum" {
continue
}
coll, err := app.FindCollectionByNameOrId("documents")
if err != nil {
return
}
field, _ := coll.Fields.GetByName(f.Name).(*core.SelectField)
if field == nil {
continue
}
var want []string
if f.Name == "template" {
want = sortedTemplateNames(cfg)
} else {
used, err := distinctValues(app, f.Name)
if err != nil {
app.Logger().Error("documents: enum cleanup query failed", "field", f.Name, "error", err)
continue
}
want = used
}
if len(want) == 0 {
want = []string{DummyEnumValue}
}
sort.Strings(want)
same := len(want) == len(field.Values)
if same {
for i, v := range want {
if field.Values[i] != v {
same = false
break
}
}
}
if same {
continue
}
removed := diffStrings(field.Values, want)
field.Values = want
if err := app.Save(coll); err != nil {
app.Logger().Error("documents: enum cleanup save failed", "field", f.Name, "error", err)
continue
}
app.Logger().Info("documents: enum values cleaned up", "field", f.Name, "removed", removed)
}
}
// distinctValues returns the sorted non-empty distinct values of a column.
func distinctValues(app core.App, name string) ([]string, error) {
var rows []struct {
V string `db:"v"`
}
err := app.DB().NewQuery(
fmt.Sprintf(`SELECT DISTINCT "%s" AS v FROM documents WHERE "%s" IS NOT NULL AND TRIM("%s") != '' ORDER BY v`, name, name, name),
).All(&rows)
if err != nil {
return nil, err
}
values := make([]string, 0, len(rows))
for _, row := range rows {
values = append(values, row.V)
}
return values, nil
}
func diffStrings(from, to []string) []string {
keep := map[string]bool{}
for _, v := range to {
keep[v] = true
}
var removed []string
for _, v := range from {
if !keep[v] {
removed = append(removed, v)
}
}
return removed
}
+58 -84
View File
@@ -23,130 +23,104 @@ func Setup(app core.App, adminEmail, adminPassword string, cfg *config.Config) e
if err := ensureSuperuser(app, adminEmail, adminPassword); err != nil {
return err
}
// Startup cleanup of the official enum value lists (enums.go).
CleanupEnumValues(app, cfg)
return nil
}
// ensureDocuments creates or checks the documents collection: status,
// file_key, then one column per declared field (US-03). The migration
// policy is explicit refusal: any deviation from the startup shape makes
// the application refuse to start rather than guess.
func ensureDocuments(app core.App, cfg *config.Config) error {
coll, err := app.FindCollectionByNameOrId("documents")
created := false
if err != nil {
// First start: create the collection with its fixed columns (US-03).
coll = core.NewBaseCollection("documents")
created = true
}
// Fixed columns (re)declared on every start, ordering preserved, plus
// the standard PocketBase created/updated autodate columns for
// convenience in the admin UI (US-03).
coll.Fields.RemoveByName("sha256")
coll.Fields.RemoveByName("file_key")
coll.Fields.RemoveByName("pages")
coll.Fields.RemoveByName("status")
coll.Fields.RemoveByName("created")
coll.Fields.RemoveByName("updated")
coll.Fields.Add(
&core.TextField{Name: "sha256"},
&core.TextField{Name: "file_key"},
&core.JSONField{Name: "pages"},
&core.SelectField{Name: "status", MaxSelect: 1, Values: []string{"pending", "processed", "validated"}},
&core.AutodateField{Name: "created", OnCreate: true},
&core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true},
)
// Fixed schema: status and file_key (US-03).
status := coll.Fields.GetByName("status")
if status == nil {
status = &core.SelectField{Name: "status", MaxSelect: 1, Values: []string{"pending", "processed", "validated"}}
coll.Fields.Add(status)
} else if status, ok := status.(*core.SelectField); !ok {
return errors.New("field status exists with a different type; refusing to start")
}
// One column per declared field, mapped from the field type (US-03).
fileKey := coll.Fields.GetByName("file_key")
if fileKey == nil {
fileKey = &core.TextField{Name: "file_key"}
coll.Fields.Add(fileKey)
} else if _, ok := fileKey.(*core.TextField); !ok {
return errors.New("field file_key exists with a different type; refusing to start")
}
// One column per declared field, with the type mapping US-03.
for _, f := range cfg.FieldList {
var newField core.Field
switch f.Type {
case "text":
case "string":
newField = &core.TextField{Name: f.Name}
case "number":
newField = &core.NumberField{Name: f.Name}
case "date":
newField = &core.DateField{Name: f.Name}
case "boolean":
newField = &core.BoolField{Name: f.Name}
case "enum":
// PocketBase select fields require a fixed non-empty vocabulary;
// config enums are free-form, so store them as text.
newField = &core.TextField{Name: f.Name}
// Official select fields: the application manages the value
// lists itself (enums.go) - creation with the placeholder,
// values added as documents are processed or from the
// dashboard, cleanup at startup.
newField = &core.SelectField{Name: f.Name, MaxSelect: 1, Values: enumVocabulary(cfg, f.Name)}
default:
return fmt.Errorf("field %s has unsupported type %s", f.Name, f.Type)
}
if existing := coll.Fields.GetByName(f.Name); existing != nil {
if existing.Type() != newField.Type() {
return fmt.Errorf("field %s changed type from %s to %s: refusing to start",
f.Name, existing.Type(), newField.Type())
}
continue
}
coll.Fields.Add(newField)
}
// Fields removed from the config are removed from the schema; values are
// lost by design (they remain in the event snapshots).
for _, f := range coll.Fields {
if f.GetSystem() {
continue
}
if _, declared := cfg.Fields[f.GetName()]; !declared && !isFixed(f.GetName()) {
app.Logger().Warn("documents: column removed", "field", f.GetName(), "reason", "removed from config")
coll.Fields.RemoveByName(f.GetName())
existing := coll.Fields.GetByName(f.Name)
if existing == nil {
coll.Fields.Add(newField)
} else if existing.Type() != newField.Type() {
return fmt.Errorf("field %s changed type (%s to %s); refusing to start", f.Name, existing.Type(), newField.Type())
}
}
if err := app.Save(coll); err != nil {
return fmt.Errorf("save documents collection: %w", err)
}
if _, err := app.DB().NewQuery(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_documents_sha256 ON documents (sha256)").Execute(); err != nil {
return fmt.Errorf("sha256 unique index: %w", err)
if created {
return app.Save(coll)
}
return nil
}
func isFixed(name string) bool {
for _, f := range []string{"sha256", "file_key", "pages", "status", "created", "updated"} {
if f == name {
return true
}
}
return false
}
// ensureEvents creates the append-only events collection (US-04).
func ensureEvents(app core.App) error {
if _, err := app.FindCollectionByNameOrId(eventsName); err == nil {
return nil
}
docColl, err := app.FindCollectionByNameOrId("documents")
if err != nil {
return err
}
coll := core.NewBaseCollection(eventsName)
coll.Fields.Add(
&core.RelationField{Name: "document", CollectionId: docColl.Id, MaxSelect: 1, CascadeDelete: true},
&core.SelectField{Name: "event_type", MaxSelect: 1, Values: []string{"imported", "corrected", "validated"}},
for _, field := range []core.Field{
&core.TextField{Name: "document"},
&core.TextField{Name: "event_type"},
&core.JSONField{Name: "payload"},
&core.DateField{Name: "timestamp"},
&core.AutodateField{Name: "created", OnCreate: true},
&core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true},
)
// Append-only (US-04): API rules locked to superusers are not enough,
// the standard admin UI uses a superuser; the request hooks in
// BindHooks reject updates and deletes for everyone.
if err := app.Save(coll); err != nil {
return fmt.Errorf("save %s collection: %w", eventsName, err)
&core.AutodateField{Name: "timestamp", OnCreate: true},
} {
coll.Fields.Add(field)
}
return nil
return app.Save(coll)
}
// ensureSuperuser upserts the administrator account (US-01).
func ensureSuperuser(app core.App, email, password string) error {
superusers, err := app.FindCollectionByNameOrId(core.CollectionNameSuperusers)
if err != nil {
return err
}
rec, err := app.FindFirstRecordByFilter(superusers.Name, "email = {:e}", map[string]any{"e": email})
if err != nil || rec == nil {
rec = core.NewRecord(superusers)
rec.SetEmail(email)
existing, err := app.FindAuthRecordByEmail(superusers, email)
if err == nil && existing != nil {
return nil
}
rec.SetPassword(password)
return app.Save(rec)
record := core.NewRecord(superusers)
record.Email = email
if err := record.SetPassword(password); err != nil {
return err
}
return app.Save(record)
}
var errAppendOnly = errors.New("document_events is append-only: update and delete are refused")
+90 -20
View File
@@ -1,42 +1,112 @@
package pbapp
import (
"reflect"
"testing"
"ganigeorgiev.com/pocketbase/pocketbase"
"ganigeorgiev.com/pocketbase/pocketbase/core"
"github.com/napnap75/archivist/internal/config"
)
func TestEnsureDocumentsUsesTextForFreeFormEnums(t *testing.T) {
func enumTestCfg(t *testing.T) *config.Config {
t.Helper()
tpl, err := config.CompileTemplate("default", "Divers/{documentType}.pdf")
if err != nil {
t.Fatalf("compile template: %v", err)
}
fields := []config.Field{
{Name: "template", Type: "enum"},
{Name: "documentType", Type: "enum"},
}
return &config.Config{
Fields: map[string]config.Field{
"template": fields[0],
"documentType": fields[1],
},
FieldList: fields,
Templates: map[string]*config.CompiledTemplate{"default": tpl},
}
}
func enumTestApp(t *testing.T, cfg *config.Config) core.App {
t.Helper()
app := pocketbase.NewWithConfig(pocketbase.Config{DefaultDataDir: t.TempDir()})
if err := app.Bootstrap(); err != nil {
t.Fatalf("bootstrap PocketBase: %v", err)
}
defer app.ClearBootstrap()
if err := Setup(app, "admin@example.net", "password-123456", cfg); err != nil {
t.Fatalf("setup: %v", err)
}
return app
}
fields := []config.Field{
{Name: "documentType", Type: "enum"},
{Name: "bankAccount", Type: "enum"},
}
cfg := &config.Config{
Fields: map[string]config.Field{
"documentType": fields[0],
"bankAccount": fields[1],
},
FieldList: fields,
}
if err := ensureDocuments(app, cfg); err != nil {
t.Fatalf("ensure documents: %v", err)
}
// US-03: declared enums become official select fields; the "template"
// field starts with the configured template names, the others with the
// placeholder.
func TestEnsureDocumentsCreatesSelectEnums(t *testing.T) {
cfg := enumTestCfg(t)
app := enumTestApp(t, cfg)
defer app.ClearBootstrap()
coll, err := app.FindCollectionByNameOrId("documents")
if err != nil {
t.Fatalf("find documents collection: %v", err)
}
for _, name := range []string{"documentType", "bankAccount"} {
field := coll.Fields.GetByName(name)
if field == nil || field.Type() != "text" {
t.Errorf("field %q = %v, want text field", name, field)
for name, want := range map[string][]string{
"template": {"default"},
"documentType": {DummyEnumValue},
} {
field, ok := coll.Fields.GetByName(name).(*core.SelectField)
if !ok || field == nil {
t.Fatalf("field %q is not a select field", name)
}
if !reflect.DeepEqual(field.Values, want) {
t.Errorf("field %q values = %v, want %v", name, field.Values, want)
}
}
}
// US-37: the startup cleanup keeps the values documents use, drops the
// others, keeps the placeholder only while alone, and forces the template
// list to the configuration.
func TestCleanupEnumValues(t *testing.T) {
cfg := enumTestCfg(t)
app := enumTestApp(t, cfg)
defer app.ClearBootstrap()
if err := AddEnumValue(app, "documentType", "Facture"); err != nil {
t.Fatalf("add enum value: %v", err)
}
if err := AddEnumValue(app, "documentType", "obsolete"); err != nil {
t.Fatalf("add enum value: %v", err)
}
coll, err := app.FindCollectionByNameOrId("documents")
if err != nil {
t.Fatalf("find documents collection: %v", err)
}
rec := core.NewRecord(coll)
rec.Set("file_key", "pending/x.pdf")
rec.Set("status", "pending")
rec.Set("documentType", "Facture")
if err := app.Save(rec); err != nil {
t.Fatalf("save record: %v", err)
}
CleanupEnumValues(app, cfg)
values, err := enumFieldValues(app, "documentType")
if err != nil {
t.Fatalf("enum field values: %v", err)
}
if !reflect.DeepEqual(values, []string{"Facture"}) {
t.Errorf("documentType values = %v, want [Facture]", values)
}
tplValues, err := enumFieldValues(app, "template")
if err != nil {
t.Fatalf("enum field values: %v", err)
}
if !reflect.DeepEqual(tplValues, []string{"default"}) {
t.Errorf("template values = %v, want [default]", tplValues)
}
}