Skip to content

refactor(edge,billing): move fence/register/expire RPCs next to the deep lifecycle#48

Merged
ElbertePlinio merged 1 commit into
mainfrom
refactor/checkout-deletion-orchestration
Jul 19, 2026
Merged

refactor(edge,billing): move fence/register/expire RPCs next to the deep lifecycle#48
ElbertePlinio merged 1 commit into
mainfrom
refactor/checkout-deletion-orchestration

Conversation

@ElbertePlinio

Copy link
Copy Markdown
Member

CAND-2 of #37 — first slice.

Problem

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 (createRegisteredCheckoutSession) and already had an equivalent private markCheckoutSessionExpired helper for the deletion flow. The RPC names and call order were duplicated across a package boundary instead of owned once.

Change

  • 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 inside 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 request/environment/CORS glue only.
  • Bump @pickforge/* 0.10.0 -> 0.11.0 and re-pin every Deno function's npm: 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 cover isCheckoutDeletionFenced/registerCheckoutSession/markCheckoutSessionExpired directly (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 — pass
  • bun run test — 352/352 pass
  • bun run build — pass (all 7 lockstep packages)
  • deno check on touched functions — could not be run against the real registry (published @pickforge/* is at 0.9.0; 0.10.0/0.11.0 aren't published yet, matching the existing publish-then-pin gap already present on main for every Deno function). Verified the new call sites' contextual typing directly against package src with tsc --strict instead (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-1 checkout-lifecycle.contract.test.ts lane 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.

…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.
@ElbertePlinio
ElbertePlinio merged commit 0d08e26 into main Jul 19, 2026
2 checks passed
@ElbertePlinio
ElbertePlinio deleted the refactor/checkout-deletion-orchestration branch July 19, 2026 17:38
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.
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