Skip to content

Database

Persistence runs on ORM++ (private Go module, GOPRIVATE=gitlab.techeve.de). Backend via config:

  • SQLite (default): database_path in config.json — embedded, zero operational overhead.
  • PostgreSQL: database_dsn: "postgres://..." — nothing else needed. ORM++ guarantees behavioral equivalence; application code never branches on backend.

Connection, model registration, migration, and the ORM++ workers (projections, snapshots, archiving) are encapsulated by internal/storage/database.go (storage.Open). The complete ORM++ API reference lives in the ORM++ repo under doc/API.md.

Two levels, deliberately separated:

  1. Gateway configuration (User, APIKey, Tenant, SmtpRoute, BlocklistEntry, OutboundMail): orm.TenantFree() — global tables without a tenant column.
  2. Form data (Submission, Nonce): lives in the ORM++ tenant of the respective customer site (Tenant.OrmTenantID, created when a tenant is provisioned via db.Tenants().Create). Every access requires orm.WithTenant(ctx, tenant.OrmTenantID) — fail-closed. This gives you GDPR export (Tenants().Export) and right-to-be-forgotten (Archive + Purge) per customer site for free.

Submission is event-sourced: status transitions (received → scored → sent/quarantined → released/discarded/blocked/failed) are events; the history (sub.History(ctx)) is the audit log of the concept. The struct itself is only the folded read model (Apply function in internal/core/domain/submission.go).

Note: on ES read models, the query builder only knows the struct’s own fields — the aggregate timestamps are methods. That’s why Submission has its own ReceivedAt field (orm:"index").

Tenant.SigningSecret and SmtpRoute.Password carry orm:"encrypted" (AES-256-GCM, key = encryption_key from config.json). The database sees only ciphertext; these fields are neither filterable nor sortable and are never serialized. Back up the key — without it, these fields are lost.

  • Additive (new column/index/model): adjust the model or register it in registerModels() — ORM++‘s auto-diff handles the rest.
  • Non-additive (restructuring/removing a field): increase storage.SchemaVersion and add an orm.MigrationTo(db, n, ...) step (expand/contract with dual-write, see ORM++ doc/API.md §8). Mark fields slated for removal with orm:"deprecated" first.
  • Drift protection: a changed model without a version bump ⇒ startup error.

CRUD access is encapsulated by the repositories (internal/storage/repositories); orm.ErrNotFound is translated to repositories.ErrNotFound via translate(). For submissions (ES), the services use orm.New/Load/Query directly.

Tests open a throwaway database in the temp directory:

db, err := storage.Open(ctx, orm.SQLite(filepath.Join(t.TempDir(), "test.db")), make([]byte, 32))

Template: internal/core/services/services_test.go.

  • Never call UpdateSet with pointer values (orm.Set("X", time.Now()), not &now) — pointers bypass ORM++‘s value encoding and make the row unreadable on the next scan.
  • Nonce redemption uses the unique constraint as an atomic lock (repositories.NonceRepository.Consume): a second insert of the same nonce ⇒ ErrNonceUsed (replay).