metatrondelivery/app/services/holds.server.ts
metatroncubeswdev 7a340ad135 feat: Phase 4 — enforcement Functions + slot-holds
The core competitive moat (PRODUCT_STRATEGY.md §3.1): checkout can no
longer complete without a valid, still-available slot, on any Shopify
plan. User confirmed writing Functions in JS rather than Rust — no Rust
toolchain was available in this environment, and IMPLEMENTATION_PLAN.md §1
explicitly allows JS as a fallback ("Rust preferred, JS acceptable").

- app/services/holds.server.ts: Redis-backed soft slot-holds with TTL.
  Hold creation is a Lua script (EVAL) — the "does this slot have room"
  check and the reservation itself have to be one atomic Redis operation,
  or two concurrent requests can both read "one spot left" and both
  succeed. Outstanding holds per slot live in a sorted set scored by expiry
  (so eviction is just ZREMRANGEBYSCORE, no separate expiry job needed to
  read a correct count), and a repeat request from the same cart renews
  its own hold instead of competing against the capacity gate again.
- app/routes/apps.scheduling.hold.tsx: public app-proxy endpoint the widget
  calls the moment a shopper picks a slot — reserves capacity BEFORE the
  cart attribute is written, since the attribute alone is just two
  shoppers racing to write the same field.
- extensions/datetime-widget: now fetches the cart token, requests a hold
  first, and only writes cart attributes on success; shows an error and
  refreshes the slot list if it loses the race.
- app/services/booking.server.ts + webhooks.orders.create.tsx: converts an
  order's dd_* cart attributes into a confirmed Booking (idempotent on
  orderId — webhooks redeliver), releases the matching hold, and writes a
  `delivery_datetime.booking` order metafield so the slot is visible on the
  order record natively (IMPLEMENTATION_PLAN.md §5.2/§2). Deliberately does
  NOT re-check capacity and reject at this point — by the time an order
  exists, payment has happened; that's the Function's job, earlier.
- webhooks.orders.cancelled.tsx: marks the Booking cancelled, freeing its
  capacity.
- extensions/validation-slot (Cart/Checkout Validation Function): blocks
  checkout when the cart's dd_* attributes are missing or incomplete — a
  shopper who bypasses the widget entirely (clears the attribute, calls the
  cart API directly) still cannot check out, because this runs inside
  Shopify's own checkout, not the browser. Scope note documented in
  evaluate.js: this doesn't yet re-validate against a live capacity
  snapshot at the moment checkout completes ("has since been taken" in
  §3.1) — Functions can't call our DB, and a metafield-snapshot refresh
  pipeline for that is unscoped work; the 10-minute hold TTL is the interim
  mitigation for that specific race.
- extensions/delivery-customization: relabels every delivery option to the
  shopper's actual chosen method + date ("Pickup — Aug 25" instead of a
  generic carrier label), directly fixing the "estimated delivery date on a
  pickup order" complaint §3.1 names. payment-customization is deliberately
  NOT built yet — there's no configurable payment-method rule for it to
  enforce until later phases add one; shipping a no-op Function serves
  nothing.
- Prisma: added Booking (no SlotHold table — Redis is the sole source of
  truth for holds, per §4's own "(Redis-backed)" annotation; mirroring it
  into Postgres would just be a sync-consistency burden with no benefit).
- Both Function extensions are hand-scaffolded from Shopify's documented
  JS Function structure (same interactive-login limitation as the Theme
  App Extension) — README.md flags that `shopify app function schema`
  should be run before deploying to confirm the input queries still match
  the live schema.

New tests/integration/ suite (separate vitest config, needs live
Redis+Postgres — `docker compose up -d` locally, a services: block in CI)
holds the tests that can't be meaningfully mocked: the slot-hold
concurrency test CLAUDE.md calls out as non-negotiable (20 concurrent
requests for 1 unit of capacity → exactly 1 succeeds; 30 for 5 → exactly 5;
release-then-retry; TTL expiry; same-cart renewal) and the full
hold-to-booking lifecycle including idempotency on webhook redelivery.
Both Functions' pure decision logic is separately unit-tested (11 tests).
60 total tests now pass (52 unit + 8 integration).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 18:22:51 -04:00

118 lines
4.1 KiB
TypeScript

import type { Method } from "@prisma/client";
import redis from "../lib/redis.server";
// Soft slot-holds: when a shopper picks a slot, we reserve one unit of its
// capacity for a short TTL so a second shopper can't grab the same last
// unit while the first is still filling out checkout (PRODUCT_STRATEGY.md
// §3.1 — "the last-slot race condition"). Redis is the sole source of
// truth for holds (see the schema.prisma comment on why there's no
// matching Postgres table); its native key expiry is exactly the semantics
// a time-limited reservation needs.
//
// Each slot's outstanding holds live in one Redis sorted set, scored by
// expiry timestamp (ms). Creating a hold has to be atomic — read the
// current count, compare to the capacity budget, and add the new member —
// or two concurrent requests can both read "one spot left" and both
// succeed. That's done with a Lua script (EVAL), which Redis runs
// single-threaded and atomically; there is no equivalent way to get this
// right with separate round-trips from Node.
export const DEFAULT_HOLD_TTL_MS = 10 * 60 * 1000; // 10 minutes
const TRY_CREATE_HOLD_SCRIPT = `
local key = KEYS[1]
local now = tonumber(ARGV[1])
local expiresAt = tonumber(ARGV[2])
local member = ARGV[3]
local capacity = tonumber(ARGV[4])
redis.call('ZREMRANGEBYSCORE', key, '-inf', now)
-- The same cart re-confirming its own already-held slot (a retried
-- request, a back/forward navigation, a duplicate click) is a renewal, not
-- a new claim — it must not have to compete against the capacity gate a
-- second time, or a shopper can lock themselves out of the slot they
-- already hold.
if redis.call('ZSCORE', key, member) then
redis.call('ZADD', key, expiresAt, member)
return 1
end
local count = redis.call('ZCARD', key)
if count < capacity then
redis.call('ZADD', key, expiresAt, member)
redis.call('PEXPIRE', key, 3600000)
return 1
else
return 0
end
`;
redis.defineCommand("tryCreateHoldScript", {
numberOfKeys: 1,
lua: TRY_CREATE_HOLD_SCRIPT,
});
// ioredis's defineCommand() adds the method at runtime without changing the
// client's static type, so this augments it for the one command we added.
declare module "ioredis" {
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- must match ioredis's own generic arity to merge
interface RedisCommander<Context> {
tryCreateHoldScript(
key: string,
now: number,
expiresAt: number,
member: string,
capacity: number,
): Promise<number>;
}
}
export interface SlotIdentity {
shopDomain: string;
locationId: string;
method: Method;
/** The slot's exact start instant, as an ISO string (stable, timezone-unambiguous cache key). */
slotStartIso: string;
}
function holdSetKey({ shopDomain, locationId, method, slotStartIso }: SlotIdentity): string {
return `hold:${shopDomain}:${locationId}:${method}:${slotStartIso}`;
}
export interface TryCreateHoldResult {
success: boolean;
expiresAt: number;
}
/**
* Attempts to reserve one unit of capacity for `cartToken` on the given
* slot. `capacityBudget` is the capacity still available to holds — i.e.
* template capacity minus already-confirmed Bookings, computed by the
* caller from Postgres immediately before calling this. Returns
* success: false if the slot's outstanding holds already meet that budget.
*/
export async function tryCreateHold(
slot: SlotIdentity,
cartToken: string,
capacityBudget: number,
ttlMs: number = DEFAULT_HOLD_TTL_MS,
): Promise<TryCreateHoldResult> {
const now = Date.now();
const expiresAt = now + ttlMs;
const result = await redis.tryCreateHoldScript(holdSetKey(slot), now, expiresAt, cartToken, capacityBudget);
return { success: result === 1, expiresAt };
}
export async function releaseHold(slot: SlotIdentity, cartToken: string): Promise<void> {
await redis.zrem(holdSetKey(slot), cartToken);
}
/** Active (non-expired) hold count for a slot — evicts expired members first. */
export async function countActiveHolds(slot: SlotIdentity): Promise<number> {
const key = holdSetKey(slot);
await redis.zremrangebyscore(key, "-inf", Date.now());
return redis.zcard(key);
}