Skip to content

fix(storage): local evidence objects no longer vanish on restart - #75

Merged
midagedev merged 1 commit into
mainfrom
fix/persist-local-evidence
Aug 28, 2026
Merged

fix(storage): local evidence objects no longer vanish on restart#75
midagedev merged 1 commit into
mainfrom
fix/persist-local-evidence

Conversation

@midagedev

Copy link
Copy Markdown
Owner

Summary

Coupons, promotion codes, subscription schedules, disputes, tax rates, tax IDs and customer cash balances lived only in process memory (localEvidenceStore) while every other object was in SQLite. A restart produced a half-restored dataset: customers, subscriptions and invoices came back, but a subscription whose default_tax_rates referenced a tax rate created before the restart failed with resource_missing — and nothing in the surviving data explained why. GET /v1/tax_rates returned {"data":[]} against a database that was otherwise intact.

They are now written through to the run's own store, so isolation and lifetime follow the run: a file-backed run keeps them, an in-memory run stays ephemeral. That also means run scoping is unchanged — evidence simply lives where every other billing object already lives.

Persistence sits in the evidence store's accessors, not in the handlers, because there are two write paths: the REST handlers and fixture apply (applyFixtureTaxRates and friends, which supply explicit IDs). A handler-level save would have missed the path seeded environments actually use.

Deletes persist too — otherwise a restart resurrects a deleted coupon.

Idempotency keys stay in memory deliberately: losing them on restart is the Stripe-like behaviour, so they are not part of this change.

Also: TestSQLiteMigrationsRun derived its expectation from a hand-written 22-term boolean chain that needed an edit per migration. It now reads the embedded migration files, so it needs no edit and it fails on a gap or a duplicated number — which this branch hit for real (020 was taken on main, and my file had to move to 023).

Files changed

  • internal/storage/migrations/023_local_evidence.sqllocal_evidence(kind, id, data), PK (kind, id)
  • internal/storage/local_evidence.goSaveLocalEvidence / DeleteLocalEvidence / LoadLocalEvidence
  • internal/api/local_evidence.goLocalEvidenceRepository, kind constants, save/saveLocked/remove/addCash/restore; all direct map writes converted
  • internal/api/api.goOptions.LocalEvidence; the four fixture-apply write paths
  • internal/server/server.go — pass the run's store when it implements the interface
  • internal/api/local_evidence_persistence_test.go, internal/storage/storage_test.go, internal/api/api_test.go, CHANGELOG.md

Verification

  • go build ./..., gofmt -l, go vet ./internal/... — clean
  • go test ./... — all packages pass
  • New: restart test (same store, second handler) covering both write paths plus a coupon; delete-survives-restart; nil-repo stays in memory; storage round-trip incl. update, cross-kind id collision, and idempotent delete
  • Mutation-checked: making save skip the repo turns the restart test red (resource_missing)
  • The api test helper now wires the store by default, so the whole existing api suite runs against the persisted path
  • End-to-end on a real process: serve against a temp DB → POST /v1/tax_ratesSIGTERMserve again → GET /v1/tax_rates returns the rate. Before this change the same sequence returned {"data":[]}

Open risks

  • restore() unmarshals documents as map[string]any, so a metadata map that was map[string]string in memory comes back as map[string]any. Response JSON is identical, and the one place that reads it back (tax_rates update, metadata merge) already handled both shapes.
  • Persistence failures now surface as 500 on the request that caused them, rather than being swallowed. Two call sites cannot report — a schedule transition during a clock advance, and createDispute, which returns an object, not an error — and are best-effort with a comment saying so.
  • Existing databases have no evidence rows, so a store written before this change starts empty. Re-applying the fixture pack repopulates it.

Gate status

go build, gofmt, go vet, go test ./... green locally. No changes to webhook order, signature, retry, or billing math; the billing-state surface touched (evidence lookups) is covered by the new tests.

Coupons, promotion codes, subscription schedules, disputes, tax rates,
tax IDs and customer cash balances lived only in process memory while
every other object was in SQLite. A restart therefore produced a
half-restored dataset: customers, subscriptions and invoices came back,
but a subscription whose default_tax_rates referenced a tax rate created
before the restart failed with resource_missing, and nothing in the
surviving data explained why.

They are now written through to the run's own store, so isolation and
lifetime follow the run: a file-backed run keeps them, an in-memory run
stays ephemeral. Persistence lives in the evidence store's own accessors
because there are two write paths — the REST handlers and fixture apply,
which supplies explicit IDs — and a handler-level save would have missed
the one seeded environments actually use.

Idempotency keys stay in memory deliberately; losing them on restart is
the Stripe-like behaviour.

The api test helper now wires the store by default, so the existing
suite exercises the persisted path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@midagedev
midagedev merged commit a0af7ce into main Aug 28, 2026
2 of 3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant