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>
96 lines
2.9 KiB
JSON
96 lines
2.9 KiB
JSON
{
|
|
"name": "delivery-datetime-app",
|
|
"private": true,
|
|
"scripts": {
|
|
"build": "remix vite:build",
|
|
"predev": "npm run build:widget",
|
|
"dev": "shopify app dev",
|
|
"predeploy": "npm run build:widget",
|
|
"config:link": "shopify app config link",
|
|
"generate": "shopify app generate",
|
|
"deploy": "shopify app deploy",
|
|
"config:use": "shopify app config use",
|
|
"env": "shopify app env",
|
|
"start": "remix-serve ./build/server/index.js",
|
|
"docker-start": "npm run setup && npm run start",
|
|
"setup": "prisma generate && prisma migrate deploy",
|
|
"lint": "eslint --cache --cache-location ./node_modules/.cache/eslint .",
|
|
"typecheck": "tsc --noEmit",
|
|
"test": "vitest",
|
|
"test:integration": "vitest run --config vitest.integration.config.ts",
|
|
"test:e2e": "playwright test",
|
|
"build:widget": "esbuild extensions/datetime-widget/src/datetime-widget.ts --bundle --minify --target=es2019 --outfile=extensions/datetime-widget/assets/datetime-widget.js",
|
|
"worker": "tsx jobs/worker.ts",
|
|
"shopify": "shopify",
|
|
"prisma": "prisma",
|
|
"graphql-codegen": "graphql-codegen",
|
|
"vite": "vite"
|
|
},
|
|
"type": "module",
|
|
"engines": {
|
|
"node": ">=20.19 <22 || >=22.12"
|
|
},
|
|
"dependencies": {
|
|
"@prisma/client": "^6.2.1",
|
|
"@remix-run/dev": "^2.16.1",
|
|
"@remix-run/fs-routes": "^2.16.1",
|
|
"@remix-run/node": "^2.16.1",
|
|
"@remix-run/react": "^2.16.1",
|
|
"@remix-run/serve": "^2.16.1",
|
|
"@shopify/app-bridge-react": "^4.1.6",
|
|
"@shopify/polaris": "^12.0.0",
|
|
"@shopify/shopify-app-remix": "^4.1.0",
|
|
"@shopify/shopify-app-session-storage-prisma": "^9.0.1",
|
|
"bullmq": "^5.34.0",
|
|
"ioredis": "^5.4.2",
|
|
"isbot": "^5.1.0",
|
|
"luxon": "^3.5.0",
|
|
"prisma": "^6.2.1",
|
|
"react": "^18.2.0",
|
|
"react-dom": "^18.2.0",
|
|
"vite-tsconfig-paths": "^5.0.1"
|
|
},
|
|
"devDependencies": {
|
|
"@playwright/test": "^1.49.1",
|
|
"@remix-run/eslint-config": "^2.16.1",
|
|
"@remix-run/route-config": "^2.16.1",
|
|
"@shopify/api-codegen-preset": "^1.1.1",
|
|
"@types/eslint": "^9.6.1",
|
|
"@types/luxon": "^3.4.2",
|
|
"@types/node": "^22.2.0",
|
|
"@types/react": "^18.2.31",
|
|
"@types/react-dom": "^18.2.14",
|
|
"dotenv": "^16.4.7",
|
|
"esbuild": "^0.24.2",
|
|
"eslint": "^8.42.0",
|
|
"eslint-config-prettier": "^10.0.1",
|
|
"prettier": "^3.2.4",
|
|
"tsx": "^4.19.2",
|
|
"typescript": "^5.2.2",
|
|
"vite": "^6.2.2",
|
|
"vitest": "^2.1.8"
|
|
},
|
|
"workspaces": {
|
|
"packages": [
|
|
"extensions/*"
|
|
]
|
|
},
|
|
"trustedDependencies": [
|
|
"@shopify/plugin-cloudflare"
|
|
],
|
|
"resolutions": {
|
|
"@graphql-tools/url-loader": "8.0.16",
|
|
"@graphql-codegen/client-preset": "4.7.0",
|
|
"@graphql-codegen/typescript-operations": "4.5.0",
|
|
"minimatch": "9.0.5",
|
|
"vite": "^6.2.2"
|
|
},
|
|
"overrides": {
|
|
"@graphql-tools/url-loader": "8.0.16",
|
|
"@graphql-codegen/client-preset": "4.7.0",
|
|
"@graphql-codegen/typescript-operations": "4.5.0",
|
|
"minimatch": "9.0.5",
|
|
"vite": "^6.2.2"
|
|
}
|
|
}
|