refactor(edge,billing): move fence/register/expire RPCs next to the deep lifecycle#48
Merged
Merged
Conversation
…eep lifecycle CAND-2 (issue #37), first slice. create-credit-checkout was the only production adapter for the checkout_lifecycle_is_deletion_fenced / checkout_lifecycle_register_session / checkout_lifecycle_mark_expired RPCs, hand-rolling all three with raw serviceSupabase.rpc() calls even though edge-shared already owns the register/ expire choreography contract via createRegisteredCheckoutSession and already has an equivalent private markCheckoutSessionExpired helper for the deletion flow. The RPC names and call order were duplicated instead of owned once. - edge-shared: export isCheckoutDeletionFenced and registerCheckoutSession (new), and export the existing markCheckoutSessionExpired, so the fence/ register/expire RPC contract for Checkout Session creation lives next to createRegisteredCheckoutSession and createDeleteAccountHandler instead of in a Deno adapter. - create-credit-checkout: drop the three hand-rolled RPC functions and wire the new edge-shared exports into createRegisteredCheckoutSession's callbacks. The adapter is now environment/CORS/request glue only. - Bump @pickforge/* 0.10.0 -> 0.11.0 and re-pin every Deno function's npm: import (existing publish-then-pin convention). Behavior is unchanged: createRegisteredCheckoutSession's DI signature and call order are untouched, so every existing contract/unit test still passes unmodified, and the CAND-1 local-Postgres lifecycle lane (bun run test:supabase) passes unchanged. Remaining orchestration (billing's completion/refund reconciliation vs. edge-shared's deletion fencing/fixpoint/finalization) stays split for a follow-up slice to keep this diff reviewable.
7 tasks
ElbertePlinio
added a commit
that referenced
this pull request
Jul 19, 2026
…cation (#51) Issue #37 (CAND-4): operator-router's idempotency only became durable AFTER provider work. The handler checked `credit_ledger` for a completed route, invoked the OpenAI-compatible provider, and only then wrote a debit row keyed by the idempotency key — two concurrent requests for the same key could both find nothing completed, both call the provider, and the losing response was simply discarded after the winner's debit landed (wasted provider spend with no compensating record). New `router_attempts` table and three service-role-only RPCs (`router_attempt_claim` / `router_attempt_complete` / `router_attempt_fail`, supabase/migrations/20260722000000_router_attempt_claim.sql) move the durable decision in front of the provider call: at most one caller ever owns "go call the provider" for a given (user_id, idempotency_key) at a time. A claim that is neither completed nor explicitly failed is recoverable once its lease elapses. This is additive only: existing `credit_ledger` rows written before this migration remain authoritative for old completed routes (`findDebitedRouteResult`), independent of whether a `router_attempts` row exists for that key. `createOperatorRouterHandler` now claims before invoking the provider, completes the claim with the provider outcome, then debits — releasing the claim immediately on a definitive provider failure, and skipping straight to a debit retry (no re-invocation) when a claim is found already completed. - `packages/edge-shared/src/index.ts`: `claimRouteAttempt`, `completeRouteAttempt`, `failRouteAttempt`, `findDebitedRouteResult` own the RPC/route-metadata encode-decode boundary; `debitCredits` is unchanged. `createOperatorRouterHandler` takes `serviceSupabase` instead of separate `findCompletedRoute`/`debit` callbacks, and returns 409 `attempt_in_progress` while another claim is live. - `supabase/functions/operator-router/index.ts`: wires the caller's service-role client straight through; drops the adapter-owned `findCompletedRoute` query and `debit` callback. - `packages/edge-shared/test/edge-shared.test.ts`: replaces the `findCompletedRoute`/`debit` fakes with a `FakeRouterServiceSupabase` reimplementing the claim/complete/fail/debit RPC contract, covering the full claim -> provider -> complete -> debit choreography, live claims, lease recovery, and debit-retry-without-re-invoking-the- provider. - `supabase/tests/database/router_attempt_claim.test.sql`: pgTAP coverage for claim/in_progress/completed outcomes, idempotent completion replay, failure recovery, lease-based recovery, per-user isolation, privilege grants, and input validation. - `packages/edge-shared/test/router-attempt.contract.test.ts` (+ `router-fixtures.ts`, `postgres-router-client.ts`): a local-Postgres contract lane, following #47/#50's convention, proving the durable claim/complete/fail/debit functions against real Postgres, including two genuinely concurrent claims for the same key resolving to exactly one claim, and a full `createOperatorRouterHandler` run proving the provider is invoked and the ledger is debited exactly once across two concurrent same-key requests. Wired into `bun run test:supabase`. - Bumps `@pickforge/*` 0.11.0 -> 0.12.0 and re-pins every Deno function's `npm:` import, per the existing publish-then-pin convention. Validated: `bun run typecheck`; `bun run test` (366/366); `bun run build`; `supabase db reset` (all migrations apply cleanly); `bun run test:supabase` against real local Postgres — pgTAP (178 tests, including the new router_attempt_claim lane), welcome-credits concurrency, the CAND-1 checkout-lifecycle contract lane (20/20) and CAND-3 sync LWW contract lane (8/8) stay green, and the new router attempt contract lane (8/8) all pass; `supabase db advisors --local --type security --fail-on warn` clean. `deno check` against the real npm registry isn't possible pre-publish (same documented gap as #48: published `@pickforge/*` is still behind main) — confirmed the pre-existing gap is unchanged (this diff's remaining `deno check` errors are a strict subset of main's) and validated the adapter's typing directly against local `src` via `tsc` instead.
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.
CAND-2 of #37 — first slice.
Problem
create-credit-checkoutwas the only production adapter for thecheckout_lifecycle_is_deletion_fenced/checkout_lifecycle_register_session/checkout_lifecycle_mark_expiredRPCs, hand-rolling all three with rawserviceSupabase.rpc()calls even thoughedge-sharedalready owns the register/expire choreography contract (createRegisteredCheckoutSession) and already had an equivalent privatemarkCheckoutSessionExpiredhelper for the deletion flow. The RPC names and call order were duplicated across a package boundary instead of owned once.Change
edge-shared: exportisCheckoutDeletionFencedandregisterCheckoutSession(new), and export the existingmarkCheckoutSessionExpired, so the fence/register/expire RPC contract for Checkout Session creation lives next tocreateRegisteredCheckoutSessionandcreateDeleteAccountHandlerinstead of inside a Deno adapter.create-credit-checkout: drop the three hand-rolled RPC functions and wire the newedge-sharedexports intocreateRegisteredCheckoutSession's callbacks. The adapter is now request/environment/CORS glue only.@pickforge/*0.10.0->0.11.0and re-pin every Deno function'snpm:import (existing publish-then-pin convention — no export removal, all changes are additive).Behavior
Unchanged.
createRegisteredCheckoutSession's DI signature and call order are untouched, so every existing contract/unit test passes unmodified. New unit tests coverisCheckoutDeletionFenced/registerCheckoutSession/markCheckoutSessionExpireddirectly (success + RPC-error + invalid-result-shape paths).Scope
This is the first coherent slice: it moves fence/register/expire next to the deep lifecycle and slims the one Deno adapter that hand-rolled them. It intentionally does not consolidate billing's completion/refund reconciliation with edge-shared's deletion fencing/fixpoint/finalization — that split (money-path vs. account-path lifecycle ownership) is larger and deserves its own reviewable PR rather than growing this diff.
Validation
bun run typecheck— passbun run test— 352/352 passbun run build— pass (all 7 lockstep packages)deno checkon touched functions — could not be run against the real registry (published@pickforge/*is at0.9.0;0.10.0/0.11.0aren't published yet, matching the existing publish-then-pin gap already present onmainfor every Deno function). Verified the new call sites' contextual typing directly against packagesrcwithtsc --strictinstead (no implicit-any, no type errors).supabase db start+PICKFORGE_ALLOW_LOCAL_DB_TESTS=1 bun run test:supabase(pgTAP database suite, welcome-credits concurrency check, and the CAND-1checkout-lifecycle.contract.test.tslane against real Postgres) — all pass (140 pgTAP assertions, 20/20 contract tests, 78 expect() calls).supabase db advisors --local --type security --fail-on warn— no issues found.Closes CAND-2 slice 1 of #37.