Migrate store to SQLite (modernc, cgo-free) + boot import of legacy JSON (#28) #30

Merged
Cordy merged 6 commits from feat/sqlite-store into main 2026-08-25 01:48:20 +00:00
Owner

Implements #28. Not auto-merged — this swaps the live server's storage engine, so it wants your review + a deliberate deploy.

What changed

Only main.go (plus go.mod/go.sum and a new test). The in-memory maps stay the authoritative read/write model, so every other file's st.Recs[...] / st.save() call site is unchanged — the migration is confined to openStore() and save().

  • Driver: modernc.org/sqlite v1.57.0 — pure Go. CGO_ENABLED=0 go build ./... verified green in CI, so the existing cross-compile/deploy is unaffected.
  • Schema: per-entity tables licenses / accounts / orgs / invites. Primary key + a couple of extracted lookup columns (paddle_sub, email, org_id, revoked on licenses) for future SQL/reporting; the full struct lives JSON-encoded in a data column, so Go struct changes never need a column migration.
  • save(): whole in-memory state written in one transaction (WAL). A crash or failed statement rolls back — the old whole-file JSON write had no such guarantee. Kept void/best-effort to match the previous contract; failures now log via an optional store logger.
  • Boot import: if the DB is empty and a legacy licenses.json exists, it's imported once, then renamed licenses.json.migrated. Idempotent — a second boot reads from the DB and skips it.
  • Path handling: DATA_PATH may still point at the old .json; the .db is derived beside it, so the systemd env needs no change. Default is now licenses.db.

Sessions / reset / OAuth state

Unchanged — still in-memory (a restart re-prompts login by design).

Retention

Unchanged — records kept forever (load-bearing for late-renewal same-id re-sync).

Verification (CI, this branch)

  • CGO_ENABLED=0 go build ./... — clean.
  • go vet ./... — clean.
  • Full existing suite — ok, untouched (proves the maps-authoritative swap is transparent).
  • New store_sqlite_test.go: TestSQLiteImportsLegacyJSON (import once, rename aside, no re-import on reopen) and TestSQLiteRoundTrip (persist → reopen → extracted-column check → delete propagates through the full-rewrite save). Both PASS.

Deploy notes (when you choose to)

  1. Copy the live licenses.json off the box first — belt-and-braces; the import only renames it, but take the snapshot anyway.
  2. Deploy: the new binary boots, imports the JSON, renames it .migrated. First-boot logs will show issuance/paddle warnings as usual; check healthz.
  3. Rollback is clean: stop, mv licenses.json.migrated licenses.json, redeploy the previous binary. The DB file can be left or removed.
  4. Backup/replication is deliberately out of scope here — tracked in #29 (go-live).
Implements #28. **Not auto-merged — this swaps the live server's storage engine, so it wants your review + a deliberate deploy.** ## What changed Only `main.go` (plus `go.mod`/`go.sum` and a new test). The in-memory maps stay the authoritative read/write model, so **every other file's `st.Recs[...]` / `st.save()` call site is unchanged** — the migration is confined to `openStore()` and `save()`. - **Driver:** `modernc.org/sqlite` v1.57.0 — pure Go. `CGO_ENABLED=0 go build ./...` verified green in CI, so the existing cross-compile/deploy is unaffected. - **Schema:** per-entity tables `licenses` / `accounts` / `orgs` / `invites`. Primary key + a couple of extracted lookup columns (paddle_sub, email, org_id, revoked on licenses) for future SQL/reporting; the full struct lives JSON-encoded in a `data` column, so Go struct changes never need a column migration. - **`save()`:** whole in-memory state written in **one transaction** (WAL). A crash or failed statement rolls back — the old whole-file JSON write had no such guarantee. Kept void/best-effort to match the previous contract; failures now log via an optional store logger. - **Boot import:** if the DB is empty and a legacy `licenses.json` exists, it's imported once, then renamed `licenses.json.migrated`. Idempotent — a second boot reads from the DB and skips it. - **Path handling:** `DATA_PATH` may still point at the old `.json`; the `.db` is derived beside it, so **the systemd env needs no change**. Default is now `licenses.db`. ## Sessions / reset / OAuth state Unchanged — still in-memory (a restart re-prompts login by design). ## Retention Unchanged — records kept forever (load-bearing for late-renewal same-id re-sync). ## Verification (CI, this branch) - `CGO_ENABLED=0 go build ./...` — clean. - `go vet ./...` — clean. - Full existing suite — ok, untouched (proves the maps-authoritative swap is transparent). - New `store_sqlite_test.go`: `TestSQLiteImportsLegacyJSON` (import once, rename aside, no re-import on reopen) and `TestSQLiteRoundTrip` (persist → reopen → extracted-column check → delete propagates through the full-rewrite save). Both PASS. ## Deploy notes (when you choose to) 1. **Copy the live `licenses.json` off the box first** — belt-and-braces; the import only renames it, but take the snapshot anyway. 2. Deploy: the new binary boots, imports the JSON, renames it `.migrated`. First-boot logs will show issuance/paddle warnings as usual; check `healthz`. 3. **Rollback** is clean: stop, `mv licenses.json.migrated licenses.json`, redeploy the previous binary. The DB file can be left or removed. 4. Backup/replication is deliberately **out of scope** here — tracked in #29 (go-live).
Cordy merged commit a6acbe39d4 into main 2026-08-25 01:48:20 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: Cordy/cairn-license-server#30
No description provided.