archivist: fix setup.go to keep the original collection shape

The previous commit replaced setup.go with an incomplete rewrite. This
restores the original file (fixed columns with created/updated, pages,
sha256 unique index, config-removal cleanup, event relation,
superuser upsert, errAppendOnly) and keeps only the intended changes:
- enum fields become select fields created with the dummy placeholder
  (or the configured template names for the template field)
- the startup cleanup of the enum value lists runs at the end of Setup
- setup_test.go asserts the select columns and the cleanup
This commit is contained in:
2026-11-12 09:43:52 +01:00
parent 900c799f82
commit 9d7a1add12
2 changed files with 136 additions and 101 deletions
+90 -58
View File
@@ -23,104 +23,136 @@ 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).
// Startup cleanup of the official enum value lists (US-37, 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 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")
}
// 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},
)
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.
// One column per declared field, mapped from the field type (US-03).
for _, f := range cfg.FieldList {
var newField core.Field
switch f.Type {
case "string":
case "text":
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":
// 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.
// Official select fields (US-37): the application manages the
// value lists itself (enums.go) - creation with the dummy
// placeholder, or with the configured template names for the
// special "template" field; values added as documents are
// processed or from the dashboard; unused values cleaned up
// 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)
}
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 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())
}
}
if created {
return app.Save(coll)
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)
}
return nil
}
// ensureEvents creates the append-only events collection (US-04).
func isFixed(name string) bool {
for _, f := range []string{"sha256", "file_key", "pages", "status", "created", "updated"} {
if f == name {
return true
}
}
return false
}
func ensureEvents(app core.App) error {
if _, err := app.FindCollectionByNameOrId(eventsName); err == nil {
return nil
}
coll := core.NewBaseCollection(eventsName)
for _, field := range []core.Field{
&core.TextField{Name: "document"},
&core.TextField{Name: "event_type"},
&core.JSONField{Name: "payload"},
&core.AutodateField{Name: "timestamp", OnCreate: true},
} {
coll.Fields.Add(field)
docColl, err := app.FindCollectionByNameOrId("documents")
if err != nil {
return err
}
return app.Save(coll)
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"}},
&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)
}
return nil
}
// 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
}
existing, err := app.FindAuthRecordByEmail(superusers, email)
if err == nil && existing != nil {
return nil
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)
}
record := core.NewRecord(superusers)
record.Email = email
if err := record.SetPassword(password); err != nil {
return err
}
return app.Save(record)
rec.SetPassword(password)
return app.Save(rec)
}
var errAppendOnly = errors.New("document_events is append-only: update and delete are refused")
+46 -43
View File
@@ -9,72 +9,75 @@ import (
"github.com/napnap75/archivist/internal/config"
)
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()
// TestEnsureDocumentsCreatesSelectEnums replaces the former text-storage
// workaround: declared enums are now official select fields (US-03, US-37),
// created with the single dummy placeholder.
func TestEnsureDocumentsCreatesSelectEnums(t *testing.T) {
app := pocketbase.NewWithConfig(pocketbase.Config{DefaultDataDir: t.TempDir()})
if err := app.Bootstrap(); err != nil {
t.Fatalf("bootstrap PocketBase: %v", err)
}
defer app.ClearBootstrap()
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 := Setup(app, "admin@example.net", "password-123456", cfg); err != nil {
t.Fatalf("setup: %v", err)
}
return app
}
// 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, want := range map[string][]string{
"template": {"default"},
"documentType": {DummyEnumValue},
} {
for _, name := range []string{"documentType", "bankAccount"} {
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)
if !reflect.DeepEqual(field.Values, []string{DummyEnumValue}) {
t.Errorf("field %q values = %v, want [%s]", name, field.Values, DummyEnumValue)
}
}
}
// 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.
// TestCleanupEnumValues covers the startup cleanup (US-37): a value kept
// because a document uses it, an unused value dropped, the dummy
// placeholder kept only while alone, and the template field forced to the
// configured template names.
func TestCleanupEnumValues(t *testing.T) {
cfg := enumTestCfg(t)
app := enumTestApp(t, cfg)
app := pocketbase.NewWithConfig(pocketbase.Config{DefaultDataDir: t.TempDir()})
if err := app.Bootstrap(); err != nil {
t.Fatalf("bootstrap PocketBase: %v", err)
}
defer app.ClearBootstrap()
fields := []config.Field{
{Name: "documentType", Type: "enum"},
{Name: "template", Type: "enum"},
}
cfg := &config.Config{
Fields: map[string]config.Field{
"documentType": fields[0],
"template": fields[1],
},
FieldList: fields,
Templates: map[string]*config.CompiledTemplate{
"default": {},
},
}
if err := Setup(app, "admin@example.net", "password-123456", cfg); err != nil {
t.Fatalf("setup: %v", err)
}
if err := AddEnumValue(app, "documentType", "Facture"); err != nil {
t.Fatalf("add enum value: %v", err)
}