Documentation
¶
Overview ¶
Package store is the smcloud Postgres persistence layer (ADR 0040 / docs/v2-design/sm-cloud-p1.md, step S1): the authoritative, UUID-keyed, retentive off-site QSO store behind the SM Cloud backup/restore service.
Data model ¶
Three tables — tenants, logbooks, qsos. A QSO is stored whole as JSONB (the full types.Qso), with its identity + reconcile fields lifted into columns:
- uuid the QSO's own UUID — with tenant_id the upsert key (uniqueness is tenant-scoped, migration 0004: UUIDs are public in exports, so a globally-unique uuid let one tenant occupy another's UUID), preserved end to end so a restore round-trips identity (never an ADIF re-import).
- modified_at drives reconcile diffing on (uuid, modified_at).
- deleted_at a tombstone; the cloud is retentive, so a peer that missed a delete still reconciles to the tombstone rather than resurrecting.
Single-tenant in P1 but multi-tenant-ready — every QSO carries tenant_id + logbook_id from the start.
Queries: hand-written, not ORM-generated ¶
The hot-path operations — batch upsert-by-UUID and the (uuid, modified_at) manifest — are hand-written SQL. Postgres UPSERT and JSONB are outside sqlboiler's generated CRUD, so these core ops stay explicit. This is deliberate, not a workaround: an ORM buys little for a JSONB upsert store.
sqlboiler is CONFIGURED (sqlboiler.toml) but not yet generated — the `task models:cloud` step (run against a migrated dev DB) would produce the relational scaffolding (tenants/logbooks) + a qsos read model if a consumer needs typed CRUD beyond the hand-written ops. Until then no models/ package exists in-tree and nothing imports it.
Driver + migrations ¶
Store is driver-agnostic: the caller opens *sql.DB with a registered Postgres driver and hands it in. Migrations live in migrations/ (golang-migrate, Postgres dialect) and are applied out of band (dev: `task migrate:cloud:up`; a runtime applier lands with cmd/smcloud). Auth credentials are deliberately NOT modelled here — ADR 0040 phases auth, and credentials arrive in their own table once the security assessment lands.
Index ¶
- Constants
- Variables
- func Migrate(db *sql.DB) error
- func ResolveTestDSN() (dsn, skip string)
- type LogbookInfo
- type ManifestEntry
- type Record
- type Store
- func (s *Store) EnsureLogbook(ctx context.Context, tenantID int64, name string) (int64, error)
- func (s *Store) EnsureTenant(ctx context.Context, callsign, name string) (int64, error)
- func (s *Store) Export(ctx context.Context, tenantID int64) ([]Record, error)
- func (s *Store) ExportSnapshot(ctx context.Context, tenantID int64, onBooks func([]LogbookInfo) error, ...) (err error)
- func (s *Store) Get(ctx context.Context, tenantID int64, uuid string) (Record, error)
- func (s *Store) Logbook(ctx context.Context, id int64) (LogbookInfo, error)
- func (s *Store) Logbooks(ctx context.Context, tenantID int64) ([]LogbookInfo, error)
- func (s *Store) Manifest(ctx context.Context, logbookID int64) ([]ManifestEntry, error)
- func (s *Store) Upsert(ctx context.Context, recs []Record) (applied int, err error)
- func (s *Store) UpsertEvidence(ctx context.Context, tenantID int64, recs []evidencewire.Record) ([]evidencewire.RowOutcome, error)
- type VersionConflictError
Constants ¶
const DefaultTestDSN = "postgres://smcloud:smcloud@localhost:5432/smcloud?sslmode=disable"
DefaultTestDSN is the disposable Postgres the integration tests use when the default is explicitly enabled (task db:pg:up brings it up; CI runs a fresh service container at the same address).
Variables ¶
var ErrEmptyPayload = stderr.New("smcloud: record has empty payload")
ErrEmptyPayload is returned by Upsert when a Record carries no JSON payload. The payload column is NOT NULL, so catching it here yields a UUID-tagged error rather than a bare Postgres constraint violation. errors.Is-matchable.
var ErrNotFound = stderr.New("smcloud: qso not found")
ErrNotFound is returned by Get when no QSO row matches the UUID. It is errors.Is-matchable through the operation-tagged wrapper.
Functions ¶
func Migrate ¶
Migrate applies the embedded smcloud migrations to db, bringing the schema to the latest version — the runtime applier the store doc promised for cmd/smcloud. It uses golang-migrate with the SAME source files and the same schema_migrations tracking table as the dev-workflow CLI (`task migrate:cloud:up`), so a database migrated one way is a no-op the other. Idempotent: an up-to-date schema returns nil.
func ResolveTestDSN ¶
func ResolveTestDSN() (dsn, skip string)
ResolveTestDSN returns the Postgres DSN the integration tests should use and, when they must be skipped for lack of an explicit test-database opt-in, a non-empty reason (package review, 2026-08-10).
The default DSN (postgres://smcloud:smcloud@localhost:5432/smcloud) could equally be a developer's working database, and these tests run destructive schema teardown. The safe signal is a CURRENT, explicit opt-in — never a persistent marker, which authorizes wiping a database that was empty during one run and later repurposed for real:
- SMCLOUD_TEST_DSN set → the caller named a specific database; that IS the opt-in, so it is used as-is.
- otherwise the default localhost DSN is used ONLY when SMCLOUD_TEST_ALLOW_DEFAULT is set (task test and CI set it, since both run against a disposable database); an ordinary `go test` skips rather than risk erasing whatever is at the default address.
Exported so every harness (store, server, and the smcloud forwarder e2e) shares one policy.
Types ¶
type LogbookInfo ¶
type LogbookInfo struct {
ID int64 `json:"id"`
TenantID int64 `json:"-"`
Name string `json:"name"`
}
LogbookInfo is a logbook's identity row — what the HTTP layer lists and checks ownership against (TenantID never goes on the wire).
type ManifestEntry ¶
type ManifestEntry struct {
UUID string `json:"uuid"`
ModifiedAt time.Time `json:"modified_at"`
Revision int64 `json:"revision"`
Deleted bool `json:"deleted"`
}
ManifestEntry is one row of a logbook's reconcile manifest — the minimal (uuid, modified_at, revision, deleted) tuple a peer diffs against its local copy. Revision 0 = pre-revision row (ADR 0050).
type Record ¶
type Record struct {
UUID string
TenantID int64
LogbookID int64
ModifiedAt time.Time
Revision int64
DeletedAt *time.Time
Payload json.RawMessage
}
Record is one stored QSO: the identity + reconcile columns lifted out for indexing, plus the full types.Qso as JSON. DeletedAt non-nil marks a tombstone. Revision is the row's monotonic edit counter (ADR 0050) — the primary ordering the upsert guard applies; 0 = pre-revision legacy row.
type Store ¶
type Store struct {
// contains filtered or unexported fields
}
Store is the smcloud Postgres persistence layer (see package doc). Driver- agnostic: the caller owns the *sql.DB (and its Close).
func (*Store) EnsureLogbook ¶
EnsureLogbook returns the id of the (tenant, name) logbook, creating it on first use. Idempotent (unique per tenant).
func (*Store) EnsureTenant ¶
EnsureTenant returns the id of the tenant for callsign, creating it on first use. Idempotent (callsign is unique), so it is safe on every startup. A re-ensure with an EMPTY name keeps the stored name — "ensure" must not wipe a previously stored value; a non-empty name updates it (the caller is authoritative). The DO UPDATE (rather than DO NOTHING) is also what lets RETURNING id yield the existing row's id on conflict.
The unique constraint is case-SENSITIVE, and boot only started normalising callsigns (trim + uppercase) after early deployments existed — so a database provisioned by an older build may hold a case-variant row ("7q5mlv"). Upserting the canonical spelling against that would create a NEW empty tenant beside it and silently orphan the existing backup from the token's view. So: a single case-insensitive match is first RENAMED to the canonical spelling and reused; multiple rows differing only in case (corrupt provisioning) fail loudly rather than guess which one owns the data.
func (*Store) Export ¶
Export returns EVERY record a tenant owns, tombstones included (restore needs the deleted markers), ordered by (logbook_id, uuid) for a stable dump.
func (*Store) ExportSnapshot ¶
func (s *Store) ExportSnapshot(ctx context.Context, tenantID int64, onBooks func([]LogbookInfo) error, onRecord func(Record) error) (err error)
ExportSnapshot streams a tenant's logbooks AND every record it owns from ONE repeatable-read, read-only transaction — the consistent dump behind GET /v1/export (the full-fidelity restore source). Two separate autocommit reads can interleave with a concurrent push: a new logbook and its first batch committing between them yields QSOs whose logbook_id is absent from the same dump's logbook list, which restore has no way to map.
onBooks is called exactly once, before any onRecord call, with the (small, bounded) logbook list; onRecord is then called once per QSO row. Records are NOT accumulated — the export set is unbounded (live rows + tombstones, per tenant, growing forever), so materialising it made every export an O(logbook) heap allocation and a service-level OOM lever (review 2026-07-20 #3). A non-nil error from either callback aborts the scan and is returned verbatim (no wrapping — the caller distinguishes its own write errors from store read errors by identity). The transaction never escapes this method.
func (*Store) Get ¶
Get returns the full record for a tenant's UUID, tombstones included (restore needs the deleted marker). Tenant-scoped because uuid alone stopped being a key at migration 0004 — the same UUID may exist under multiple tenants. Returns ErrNotFound (errors.Is-matchable) when no row matches.
func (*Store) Logbook ¶
Logbook returns one logbook's identity row — the ownership check the HTTP layer runs before serving a per-logbook read. Returns ErrNotFound (errors.Is-matchable) when the id doesn't exist.
func (*Store) Manifest ¶
Manifest returns the (uuid, modified_at, deleted) list for a logbook — the diff basis a peer reconciles against. Tombstones are included (the cloud is retentive). Ordered by uuid for stable diffing.
func (*Store) Upsert ¶
Upsert writes a batch of QSO records by UUID in a single transaction — inserts new rows, updates existing ones; a Record with DeletedAt set is a tombstone. It returns the number of rows actually applied: an insert or update counts, while a row rejected by the stale-push guard does not, so a caller can log "pushed N, applied M" and see how many were stale (sync telemetry).
Guards on the ON CONFLICT WHERE:
- revision, then modified_at (ADR 0050): an incoming record overwrites a stored one when its revision is strictly higher; on a revision TIE a strictly later modified_at still wins. An EXACT (revision, modified_at) tie is an idempotent no-op when the authoritative state matches, and a VersionConflictError when the payload/tombstone/logbook diverges — a single writer must never emit two states at one version (PT-1). Revision is the primary order because local modified_at is second-precision wall clock — it cannot order two edits within one second, and a reconcile-goroutine push racing the serial worker could regress the payload invisibly on such a tie. All-legacy rows (both revisions 0) get exactly the pre-0050 semantics via the fallback. A NEWER non-tombstone over a tombstone resurrects the row by design — edit-after-delete wins by recency (local is authoritative); a STALE missed-delete push is rejected by this same guard, so the tombstone holds.
- Tenant isolation is structural: uuid uniqueness is scoped to (tenant_id, uuid) (migration 0004), so a conflict can only ever be an intra-tenant re-push. QSO UUIDs are client-generated and appear in every exported logbook (not secret) — under the pre-0004 global-unique uuid, a cross-tenant collision hit the old WHERE tenant guard and became a zero-row "success" (applied 0), making that UUID permanently unbackable for the later tenant (review 2026-07-20 #2). With the composite key the same UUID simply coexists per tenant; no guard clause is needed.
modified_at / deleted_at are truncated to canonicalPrecision so the stored value matches what the reconcile hash expects (see canonicalPrecision).
func (*Store) UpsertEvidence ¶
func (s *Store) UpsertEvidence(ctx context.Context, tenantID int64, recs []evidencewire.Record) ([]evidencewire.RowOutcome, error)
UpsertEvidence ingests one §5 sync batch for a tenant: exactly one outcome per record, positionally, uuid echoed. Row faults answer on their own row and never abort batch-mates; an error return means the BATCH failed as a unit (server fault) and no outcome may be trusted.
The batch runs at SERIALIZABLE isolation so the tombstone probe and the record insert (separate tables) cannot interleave to resurrect a superseded record (package review, 2026-08-10): Postgres SSI aborts one of a dangerous pair and the whole batch — idempotent — is retried. A persistent failure surfaces as an error; the client re-offers.
type VersionConflictError ¶
type VersionConflictError struct {
UUID string
}
VersionConflictError is returned by Upsert when an incoming record ties the stored (revision, modified_at) but carries divergent authoritative state — a different payload, a live/tombstone disagreement, different tombstone metadata, or a different backup logbook. ADR 0050 introduced revisions precisely because equal-version divergence makes the backup silently untrustworthy, so a tie-divergence is a protocol invariant violation, not a state to resolve by arrival order (PT-1): the whole batch is rolled back, the offending UUID is named, and the stored row is left untouched.
func (*VersionConflictError) Error ¶
func (e *VersionConflictError) Error() string