Skip to content

fix(api): persist plan store to disk, add expiry and 410 on expired plans - #24

Merged
ericmt-98 merged 1 commit into
Micopay:mainfrom
davieslennox0:fix/persist-plan-store
Aug 24, 2026
Merged

fix(api): persist plan store to disk, add expiry and 410 on expired plans#24
ericmt-98 merged 1 commit into
Micopay:mainfrom
davieslennox0:fix/persist-plan-store

Conversation

@davieslennox0

Copy link
Copy Markdown
Contributor

Closes #17

Description

Migrates planStore from a bare Map<string, SwapPlan> to a disk-backed PlanStore class following the same file-per-record + atomic-write pattern already used by swapStore in the same file. Plans now survive restarts and are visible across processes sharing SWAP_STORE_DIR. Adds an explicit TTL (30 min default) and distinguishes expired plans (410 Gone) from unknown plan IDs (404 Not Found).

Changes

  • apps/api/src/lib/swapStore.tsPlanStore class replaces the bare Map; the planStore export is preserved, so existing set/get call sites are unchanged. Plans are written as plan-<id>.json under the same STORE_DIR the swap store uses; the prefix is what keeps the two from reading each other's files.
  • apps/api/src/routes/agent.ts — the execute endpoint now answers 410 plan_expired vs 404 plan_not_found. (The plan/execute routes live in agent.ts, not swaps.ts.)
  • apps/api/src/index.tsplanStore.cleanup() on startup, so the directory does not grow forever.
  • apps/api/src/__tests__/plan-store.test.ts — 8 store-level tests.
  • apps/api/src/__tests__/agent-execute-plan-expiry.test.ts — 3 route-level tests for the 410/404 split.

One deliberate design note

get() does not delete an expired plan. If it did, the isExpired() check that runs immediately after it in execute would find nothing and answer 404 — exactly the confusion this issue is about. Expired records are reaped by cleanup() instead. For the same reason loadFromDisk() skips expired records but leaves their files in place, and isExpired() reads the file directly so the 410 survives a restart.

get() also falls back to disk on a memory miss, which is what makes the second-process case work for an instance that was already running when the plan was created.

Acceptance Criteria

  • Plan created before a restart is still executable after it
  • Two processes sharing SWAP_STORE_DIR see each other's plans
  • Expired plan returns 410 with a distinct message from unknown plan_id 404
  • SWAP_STORE_DIR='' keeps everything in memory
  • Tests cover: survives reload, cross-process visibility, expiry (in memory and after restart), unknown-id, memory-only mode, cleanup

Verification

  • npm test -w @micopay/api — 23 files, 163 passed, 1 skipped (was 21 files / 152 passed before this change)
  • npm run test:concurrency -w @micopay/api — 2/2 OK, unchanged
  • npm run typecheck -w @micopay/api — clean

Note: typecheck fails on a clean checkout with Cannot find module '@micopay/types' until the workspace packages are built (npx turbo build --filter=@micopay/types --filter=@micopay/sdk). That is pre-existing and unrelated to this change.

Type of Change

  • fix: bug fix

Checklist

  • My code follows the coding conventions of this project
  • I have added/updated tests
  • My changes generate no new warnings or errors

…lans

- PlanStore class follows swapStore file-per-record pattern with atomic writes
- Plans persist across restarts and are visible across processes via shared SWAP_STORE_DIR
- DEFAULT_PLAN_TTL_MS = 30 minutes; expired plans cleaned up on startup and via cleanup()
- execute endpoint returns 410 Gone (plan_expired) vs 404 (plan_not_found)
- SWAP_STORE_DIR='' keeps everything in memory for tests
- get() deliberately does not delete an expired plan: dropping it there would
  make the following execute answer 404 where it must answer 410
- 11 new tests: survives reload, cross-process visibility, expiry path (in
  memory and after restart), unknown-id path, memory-only mode, cleanup, and
  the 410-vs-404 distinction at the HTTP boundary

Closes Micopay#17

@ericmt-98 ericmt-98 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the reference implementation for how to take one of these issues. Merging.

What stood out:

  • You reused the mechanism instead of inventing one. BRIDGE-10 asked you to follow swapStore's file-per-record + atomic-write pattern and its SWAP_STORE_DIR disable switch rather than building a second store. You did, down to the per-process .tmp suffix, and the plan- prefix is a real answer to the shared-directory problem rather than a hope.
  • The tests are tests. Instantiating PlanStore against a mkdtemp directory and re-importing the module with vi.resetModules() makes "survives a restart" and "a second process sees it" genuine assertions rather than mocks agreeing with themselves. You also ran test:concurrency, which the issue's notes pointed at.
  • The design note earned its place. get() not deleting an expired record, because the isExpired() check right after it would then answer 404 where it owes a 410, is the subtle part of this issue — and you found it, explained it, and left the reasoning in the code where the next person will need it.

All five acceptance criteria met.

Three follow-up nits, none blocking, no need to hold the merge for them:

  1. set() keys memory by planId while loadFromDisk() re-keys by record.plan.id. Identical today; if they ever diverge the key changes across a restart.
  2. archivoDePlan() sanitises the id, so a/b and a_b collide on one file. Plan ids are internally generated, so this isn't reachable now.
  3. cleanup() only runs at boot, so a long-lived process never reaps. A periodic call would close it.

Thank you — this was a pleasure to review.

@ericmt-98
ericmt-98 merged commit 01dcab6 into Micopay:main Aug 24, 2026
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.

[BRIDGE-10] Swap plans are stored in memory, so an agent pays $0.01 for a plan a restart destroys

2 participants