Skip to content

[Adapters] Implement EmdashInventoryStore over one inventory document per SKU with embedded holds - #248

Merged
vedanshujain merged 1 commit into
feat/in-process-commercefrom
feat/emdash-inventory-store
Sep 13, 2026
Merged

vedanshujain merged 1 commit into
feat/in-process-commercefrom
feat/emdash-inventory-store

Conversation

@vedanshujain

Copy link
Copy Markdown
Contributor

What

EmdashInventoryStore implements all 13 methods of the domain's InventoryStore port on
top of ctx.storage-shaped collections. Inventory is modeled as one document per SKU,
carrying onHand and the live holds map inline, so a decrement and the hold that caused
it commit as a single atomic write — no oversell and once-only are the same atom. This is
the third increment of the effort to move the commerce store onto document storage.

Document model

  • inventory/{sku} — the aggregate document: onHand, the live holds map, and a bounded
    applied-movement ring.
  • reservation_keys/{idempotencyKey} — a durable per-key claim written via
    create-if-absent BEFORE the inventory CAS, so once-only survives hold pruning; the claim
    is then marked terminal.
  • reservation_index/{reservationId} → sku, written before the hold, which is how
    id-only port methods find their document and how unknown ids are provable.
  • inventory_movements/{prefixedKey} — intent-carrying claims with an applied record;
    the aggregate's own witness is a bounded 256-entry appliedMovements ring plus a
    per-hold lastMovementKey.

Semantics worth knowing

  • Reserve is claim → CAS → terminal, a two-step with one crash window healed by any
    replayer and a second, mitigated window closed by an in-step key re-read.
  • Commit, release and expire write the terminal answer before pruning.
  • commitMany on an unknown id throws, while adoptMany folds it into lost, per the
    contract.
  • adjust re-derives from the hold as stored, matching the SQL reference.
  • A hold without an expiry is not adoptable, per the port.
  • Bounded jittered CAS retry with an exported ceiling of 12, and a typed retryable
    StorageContentionError distinct from OUT_OF_STOCK.
  • One accepted bounded residual on stock-movement replay after ring eviction, written as
    the sweeper contract for a later increment.

Verification

Check Result
Lint / typecheck / format clean
SQLite suite 76 passed (contract 59)
Postgres suite 153 passed / 1 pre-existing skip (contract 59 on each dialect, zero skips introduced)
No-oversell race (Postgres) 20 loops × 50 concurrent reserves for 5 units → exactly 5 winners each, onHand 0, max CAS attempts 6 of 12, 0 contention errors
Same-key race (20-way) one hold, identical answers
Concurrent same-key + different-key adjust identical same-key answers, conservation holds
Gated-CAS window case passes, with a negative control
Heal case enters the real completion path
Host-pin suite 4 passed
Build ok
Emitted JS host-free

Review

Two independent reviews over three rounds; round 1 and 2 requested changes on once-only
durability across pruning, adjust replay semantics and ledger growth; round 3 both
approve; an independent verification run passed on both dialects.

🤖 Generated with Claude Code

https://claude.ai/code/session_011NjdC8awspUte5wML6eY2X

… per SKU with embedded holds

The full 13-method InventoryStore port over document storage, on one aggregate
document per SKU with the live holds embedded in it.

The holds live inside the inventory document because an inventory decrement is
not idempotent unless the row records who applied it. So the decrement is ONE
compareAndSet on inventory/{sku} in which the onHand >= qty guard (computed in
JS), the new count and the hold record all commit together — no oversell and
once-only are the same atom.

Reserve is a two-step with exactly ONE crash window: claim reservation_keys/{key}
create-if-absent, carrying the sku, the qty and the minted reservation id → the
inventory compareAndSet → update the key document to its terminal ReserveResult.
The window is "claim written, compareAndSet not yet run", and it is healed rather
than merely tolerated — any replayer finds the claimed document and completes it
deterministically, reusing the RECORDED reservation id instead of minting a
second one, so the decrement happens exactly once and every caller gets the same
answer. A sweeper reaps claims nothing replays. What the embedded aggregate
removes is the SQL adapter's SECOND window (a pending reservation flipped to held
separately from the decrement); the claim window cannot be removed by any
single-document primitive, because the claim and the units live in different
documents by necessity. An OUT_OF_STOCK reserve mints nothing at all.

reservation_index is what pays for the embedding. Six port methods take
reservation ids with no sku, and a hold embedded per SKU cannot be found from an
id alone; the index document is written before the hold, so an id absent from it
is provably unknown — which preserves the port's asymmetry (commitMany throws for
a truly unknown id, adoptMany folds one into lost). Its create-if-absent result is
asserted, so a colliding id is loud rather than silently adopted. It also carries
the reservation's terminal state, because pruning a hold would otherwise erase the
difference between "never existed" and "existed and was released".

The terminal outcome is written to the key document BEFORE the hold is pruned, so
a replay after a prune answers from it instead of looking fresh and decrementing
again. That ORDERING is only observable under fault injection, which belongs to
the race-and-crash tier; the suites here pin its consequence.

The inventory CAS re-reads the key document on any attempt that finds no hold. No
hold is not proof the decrement never happened: a peer completing the same claim
may have created, committed and PRUNED the hold in between, and a committed prune
leaves onHand low with nothing to show for it, so writing a second hold there
would lose units permanently and silently. A terminal key document ends the
attempt with the recorded answer instead.

Every ledger is bounded. adjust/restock/removeStock keep their once-only record in
inventory_movements — one document per key carrying the full intent and then the
recorded answer, which is also what makes a key reused for a different movement
the port's typed rejection. The hot aggregate keeps only a 256-entry ring of
recently applied keys plus one field per hold, so no map on it grows without
limit; the residual that bound leaves is accepted as bounded and written down as a
contract the sweeper must satisfy.

adjust re-derives rather than refusing, as the SQL reference does: a completion
reads the hold's current qty and applies the absolute target against it, and the
claim's recorded fromQty is audit, not a guard. Its only outcomes are the port's
own, and every caller — claim winner or same-key loser — derives its answer from
the durable record, so one key cannot produce two answers.

Cross-SKU work is honest about not being atomic: adopt/adoptMany/commitMany/
releaseAdopted classify every id up front (duplicates collapsed), then apply one
compareAndSet per SKU, each idempotent by reservation id.

Contention is answered with bounded full-jittered retry and a documented ceiling
(CAS_MAX_ATTEMPTS = 12; the depth a writer can lose is bounded by the units on
hand, not the size of the crowd — the race measures 6). Exhaustion throws the
typed retryable StorageContentionError, carrying the last retryable host abort as
its cause, never OUT_OF_STOCK: a shopper who could have bought must not be told
the item is gone. The 503 mapping is a later increment.

Verified: the domain's inventoryStoreContract green on both dialects over real
storage repositories, no adapter-introduced skips, plus the concurrency suite on
the tier that can race — exactly the stocked number of winners on every loop, the
count ending at zero, retry depth strictly inside the ceiling, and a shared-key
burst resolving to one reservation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011NjdC8awspUte5wML6eY2X
@vedanshujain
vedanshujain merged commit 5212449 into feat/in-process-commerce Sep 13, 2026
1 check passed
@vedanshujain
vedanshujain deleted the feat/emdash-inventory-store branch September 13, 2026 15:19
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