Vellum/docs/decisions/ADR-007-fixed-width-timestamps.md
mithral 051169a18a Documentation, and make a fresh clone actually work
Docs
- docs/ARCHITECTURE.md: how the pieces fit and why. The request path, the six
  enforced layers, the data model, the scanner, the job queue, direct-play
  playback, and the three protocol surfaces over one application.
- docs/decisions/: seven ADRs with the alternatives that were rejected —
  SQLite-with-a-seam, direct-play-only, mark-missing-never-delete, the modular
  monolith, the one item-access chokepoint, the table-backed queue, and
  fixed-width timestamps.
- README: the layout section was stale (it predated catalog, home, scanner,
  providers, engine, probe, playback, apitoken, arch and the dto packages) and
  now lists what exists, plus links to the new docs.

Two real bugs, both found by verifying the docs instead of trusting them

1. The CLI could not run on a fresh install. `Config.Validate` created the data
   and config directories, but the CLI never called it, so `vellum user add` —
   the recovery path when you are locked out — failed with "unable to open
   database file" until the server had booted at least once. Split the directory
   creation into `Config.EnsureDirs`, call it from the CLI, and keep the media
   root check out of it: an admin command must not fail because a library is
   unmounted. The CLI tests now point DBPath inside DataDir, exactly as
   LoadConfig derives it, so they exercise the fresh-install path; removing
   EnsureDirs fails three of them.

2. A fresh clone could not build at all. `//go:embed all:dist` fails to compile
   when web/dist is absent, and web/.gitignore expected a `dist/.gitkeep` that
   was never created. Added it, so a clone builds and the server logs
   "web UI not embedded; run the frontend build to include it" — the intended
   behaviour for someone who only wants the API.

Also: the httpapi tests depended on a built frontend and FAILED rather than
skipping, so the suite was red on any fresh clone. findAsset and readAsset now
skip with a message naming the missing step. They still RUN when a bundle is
present (verified: zero skips here), so they have not been quietly disabled.

Verified from a simulated clone of tracked files only: go build exit 0, 25
packages pass, the server boots and applies all 12 migrations, and the CLI
creates a user and takes a backup. gofmt clean, vet exit 0, svelte-check 0/0.
2026-10-12 00:18:11 -04:00

2.9 KiB

ADR-007: Timestamps are fixed-width, and written through one helper

Status

Accepted

Date

2026-10-12

Context

Timestamps are stored as TEXT and compared as strings: ORDER BY updated_at, MAX(updated_at), run_after <= ?, expires_at < ?. String comparison is only correct if the strings are the same width, which is easy to get wrong.

It was wrong. Two formats were in use:

  • SQLite's strftime('%Y-%m-%dT%H:%M:%fZ','now') — exactly three fractional digits.
  • Go's time.RFC3339Nano — variable width, because it trims trailing zeros.

The bug

RFC3339Nano produces .5Z for 500 milliseconds and .55Z for 550. Compared as strings, ".5Z" > ".55Z" — but .500s < .550s. The ordering is inverted.

This is not a cross-format problem. It is wrong even in a column written 100% by Go, because the fraction width varies within that column. Live data showed 9-digit, 8-digit and 3-digit values in the same column:

jobs.run_after  ...16:57:11.383879091Z   ← 9 digits
jobs.run_after  ...16:53:06.28925937Z    ← 8 digits, trailing zero trimmed
media_items.added_at  ...01:28:19.733Z   ← 3 digits, SQLite default

It silently affected the Continue shelf (MAX(updated_at)) and Most Viewed tiebreaks — both user-visible.

Decision

All Go-side timestamp writes go through core.Stamp, which produces a fixed-width format matching SQLite's own default exactly:

t.UTC().Format("2006-01-02T15:04:05.000Z")

Alternatives Considered

Store Unix nanoseconds as INTEGER

  • Pros: unambiguously sortable; no format question at all.
  • Cons: every existing row and every query would need converting, and the values stop being human-readable in the database — which matters when you are debugging by hand.
  • Rejected: too large a migration for a bug that a format change fixes.

Store RFC3339 with a fixed 6 or 9 digits

  • Pros: fixed width, more precision.
  • Cons: would not match SQLite's default, so any row that ever took the column default would still be a different width — the same bug in a new shape.
  • Rejected: matching the database's own format is what makes both sources comparable.

Sort in Go instead of in SQL

  • Pros: no format constraint.
  • Cons: would require loading every row to sort, defeating the indexes.
  • Rejected: the index does the sort; the format must cooperate.

Consequences

  • All 14 Go-side write sites use core.Stamp / core.Now. Verified: zero variable-width writes remain.
  • Read paths were deliberately left alone. time.RFC3339Nano parses both the old and the new shapes, so existing rows keep working with no migration.
  • A test proves the property directly: Stamp at .500s sorts before Stamp at .550s as a string, and the output matches what SQLite's own default produces, byte for byte.
  • The general rule: if a value is ever compared in SQL, its textual form must be fixed-width.