fix(storage): local evidence objects no longer vanish on restart - #75
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 whosedefault_tax_ratesreferenced a tax rate created before the restart failed withresource_missing— and nothing in the surviving data explained why.GET /v1/tax_ratesreturned{"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 (
applyFixtureTaxRatesand 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:
TestSQLiteMigrationsRunderived 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 (020was taken onmain, and my file had to move to023).Files changed
internal/storage/migrations/023_local_evidence.sql—local_evidence(kind, id, data), PK(kind, id)internal/storage/local_evidence.go—SaveLocalEvidence/DeleteLocalEvidence/LoadLocalEvidenceinternal/api/local_evidence.go—LocalEvidenceRepository, kind constants,save/saveLocked/remove/addCash/restore; all direct map writes convertedinternal/api/api.go—Options.LocalEvidence; the four fixture-apply write pathsinternal/server/server.go— pass the run's store when it implements the interfaceinternal/api/local_evidence_persistence_test.go,internal/storage/storage_test.go,internal/api/api_test.go,CHANGELOG.mdVerification
go build ./...,gofmt -l,go vet ./internal/...— cleango test ./...— all packages passsaveskip the repo turns the restart test red (resource_missing)serveagainst a temp DB →POST /v1/tax_rates→SIGTERM→serveagain →GET /v1/tax_ratesreturns the rate. Before this change the same sequence returned{"data":[]}Open risks
restore()unmarshals documents asmap[string]any, so ametadatamap that wasmap[string]stringin memory comes back asmap[string]any. Response JSON is identical, and the one place that reads it back (tax_ratesupdate, metadata merge) already handled both shapes.500on the request that caused them, rather than being swallowed. Two call sites cannot report — a schedule transition during a clock advance, andcreateDispute, which returns an object, not an error — and are best-effort with a comment saying so.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.