From b319dc16f0158e3b0ee232ff47019cc6f2659ace Mon Sep 17 00:00:00 2001 From: Vedanshu Date: Sun, 20 Sep 2026 12:32:11 +0000 Subject: [PATCH 1/5] [Service][Adapters] Delete @otta-sh/service and @otta-sh/store-postgres MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit INC-D3b. With commerce running in-process inside the plugin since INC-D3a, the standalone Hono service and the Kysely/Postgres store adapter are both dead code. Both packages go entirely, along with their project references. `@otta-sh/store-postgres` took its 24 migrations, every `Kysely*Store`, its `.`/`./pg`/`./testing` subpaths, 29 `*.dialects.test.ts` files and 12 `*.pg.test.ts` race files with it. NONE of that concurrency coverage is lost: every one of the 12 race files already has a same-named, re-pointed `store-emdash` counterpart from the Phase A/B increments, and all 12 were run against the local test Postgres and confirmed green BEFORE this deletion — adjust-concurrency, coupon-no-over-redeem, no-oversell, no-oversell-cart, no-oversell-checkout, no-oversell-checkout-multiline, refund-race, resolve-reconciliation-race, restock-concurrency, rules-cas-race, sku-rename-race and variant-sku-rename-race, 52 tests passing. The no-oversell gate is intact; it just runs over the document adapter now. Nothing had to be rescued from the package. `store-emdash`'s dialect harness builds its own Kysely `PostgresDialect`/`SqliteDialect` straight from `kysely`, `pg` and `better-sqlite3`, all declared in its own package.json, and `store-emdash/src/id-gen.ts` has carried its own copy of `uuidIdGen` since it was written precisely so this copy could go. `test:pg` needs no change to keep selecting the right files: its glob walks `packages/*/test`, so it now resolves to 60 `store-emdash` files and zero deleted ones. The CI `integration` job, its Postgres service container and `test:pg` are all untouched. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CQbJYWWm8tf8owshm7XRp8 --- packages/plugin/tsconfig.json | 2 - packages/service/package.json | 58 - packages/service/src/app.ts | 279 --- packages/service/src/auth.ts | 70 - packages/service/src/config.ts | 62 - packages/service/src/email/senders.ts | 74 - packages/service/src/index.ts | 176 -- packages/service/src/routes/admin.ts | 1444 ------------ packages/service/src/routes/auth.ts | 102 - packages/service/src/routes/carts.ts | 414 ---- packages/service/src/routes/catalog.ts | 72 - packages/service/src/routes/entitlements.ts | 155 -- packages/service/src/routes/internal-auth.ts | 19 - .../service/src/routes/internal-emails.ts | 47 - packages/service/src/routes/inventory.ts | 91 - packages/service/src/routes/me.ts | 150 -- packages/service/src/routes/orders.ts | 445 ---- .../service/src/routes/product-commerce.ts | 581 ----- packages/service/src/routes/reports.ts | 155 -- packages/service/src/routes/rules-admin.ts | 623 ----- packages/service/src/routes/session-auth.ts | 25 - packages/service/src/routes/settings.ts | 83 - packages/service/src/routes/webhooks.ts | 56 - packages/service/src/schemas.ts | 884 ------- packages/service/src/stripe-wiring.ts | 53 - packages/service/src/worker.ts | 444 ---- packages/service/src/x402-wiring.ts | 53 - .../service/test/admin-cancel-http.test.ts | 185 -- .../service/test/admin-coupons-http.test.ts | 194 -- .../test/admin-customer-context-http.test.ts | 179 -- .../test/admin-fulfillment-http.test.ts | 225 -- .../test/admin-order-notes-http.test.ts | 166 -- .../service/test/admin-orders-http.test.ts | 601 ----- .../test/admin-product-edit-http.test.ts | 425 ---- .../service/test/admin-products-http.test.ts | 669 ------ packages/service/test/admin-read-gate.test.ts | 297 --- .../service/test/admin-refund-http.test.ts | 394 ---- .../admin-resolve-reconciliation-http.test.ts | 236 -- .../service/test/admin-restock-http.test.ts | 201 -- .../service/test/admin-timeline-http.test.ts | 143 -- packages/service/test/auth.test.ts | 89 - .../service/test/carts.http.contract.test.ts | 583 ----- ...talog-commerce-batch.http-contract.test.ts | 150 -- .../test/checkout-intent.http.pg.test.ts | 231 -- .../checkout-quote.http.contract.pg.test.ts | 130 -- packages/service/test/config.test.ts | 69 - .../test/customers.http.contract.pg.test.ts | 314 --- .../service/test/edit-product-schema.test.ts | 84 - .../test/entitlements-check-auth.app.test.ts | 152 -- ...ements-check-auth.http.contract.pg.test.ts | 287 --- packages/service/test/expire-holds.test.ts | 92 - .../service/test/helpers/start-test-server.ts | 453 ---- .../test/http-inventory-contract.pg.test.ts | 118 - .../service/test/inventory-not-found.test.ts | 258 --- .../test/orders.http.contract.pg.test.ts | 280 --- .../test/product-commerce-http.test.ts | 1055 --------- .../products-list-low-stock-schema.test.ts | 156 -- .../test/public-order-redaction.test.ts | 370 --- packages/service/test/qty-bounds.test.ts | 236 -- packages/service/test/reports-http.test.ts | 252 -- .../test/rules-admin.http.contract.pg.test.ts | 380 --- packages/service/test/service-token.test.ts | 388 ---- packages/service/test/settings-http.test.ts | 107 - packages/service/test/stripe-wiring.test.ts | 58 - packages/service/test/worker-entry.pg.test.ts | 240 -- packages/service/test/worker-entry.test.ts | 392 ---- packages/service/test/x402-wiring.test.ts | 49 - packages/service/tsconfig.json | 15 - packages/service/tsdown.config.ts | 7 - packages/service/vitest.config.ts | 8 - packages/store-postgres/package.json | 57 - packages/store-postgres/src/dialects-pg.ts | 19 - .../store-postgres/src/dialects-sqlite.ts | 17 - packages/store-postgres/src/dialects.ts | 12 - packages/store-postgres/src/id-gen.ts | 8 - packages/store-postgres/src/index.ts | 84 - .../src/kysely-address-store.ts | 137 -- .../store-postgres/src/kysely-cart-store.ts | 455 ---- .../store-postgres/src/kysely-coupon-store.ts | 406 ---- .../src/kysely-credential-verifier.ts | 156 -- .../src/kysely-customer-store.ts | 104 - .../src/kysely-entitlement-store.ts | 97 - .../src/kysely-inventory-store.ts | 796 ------- .../src/kysely-order-notes-store.ts | 89 - .../store-postgres/src/kysely-order-store.ts | 1343 ----------- .../src/kysely-payment-event-store.ts | 81 - .../src/kysely-product-commerce-store.ts | 2052 ----------------- .../src/kysely-reporting-store.ts | 258 --- .../src/kysely-session-store.ts | 106 - .../src/kysely-settings-store.ts | 135 -- .../src/kysely-shipping-rules-store.ts | 277 --- .../src/kysely-tax-rules-store.ts | 174 -- .../src/migrations/0001_phase0_inventory.ts | 27 - .../src/migrations/0002_product_commerce.ts | 71 - .../src/migrations/0003_cart.ts | 84 - ...0004_product_commerce_active_updated_at.ts | 34 - .../src/migrations/0005_orders.ts | 126 - .../0006_customers_sessions_outbox.ts | 95 - .../migrations/0007_shipping_tax_coupons.ts | 98 - .../0008_settings_and_reporting_indices.ts | 57 - .../0009_orders_admin_list_indices.ts | 24 - .../src/migrations/0010_order_notes.ts | 35 - .../0011_reconciliation_resolution.ts | 37 - .../src/migrations/0012_order_fulfillment.ts | 39 - .../src/migrations/0013_order_cancellation.ts | 37 - .../src/migrations/0014_order_events.ts | 44 - ...015_product_commerce_admin_list_indices.ts | 26 - .../0016_inventory_stock_movements.ts | 32 - .../0017_product_commerce_data_model_adds.ts | 51 - .../src/migrations/0018_coupons_admin_list.ts | 38 - .../migrations/0019_order_shipping_address.ts | 42 - .../src/migrations/0020_refunds.ts | 65 - .../src/migrations/0021_cart_order_id.ts | 29 - .../migrations/0022_order_lookup_indices.ts | 92 - .../src/migrations/0023_product_variants.ts | 90 - .../0024_entitlement_lookup_indices.ts | 105 - .../store-postgres/src/migrations/index.ts | 95 - packages/store-postgres/src/pg.ts | 90 - packages/store-postgres/src/schema.ts | 623 ----- packages/store-postgres/src/testing.ts | 83 - .../address-book-contract.dialects.test.ts | 16 - .../test/adjust-concurrency.pg.test.ts | 164 -- .../test/cart-fence.dialects.test.ts | 87 - .../test/cart-store-contract.dialects.test.ts | 20 - .../test/coupon-lifecycle.dialects.test.ts | 122 - .../test/coupon-no-over-redeem.pg.test.ts | 158 -- .../coupon-reconciliation.dialects.test.ts | 174 -- ...dential-verifier-contract.dialects.test.ts | 16 - .../store-postgres/test/customer-harness.ts | 129 -- .../customer-store-contract.dialects.test.ts | 16 - .../test/describe-each-dialect.ts | 489 ---- .../test/entitlement-lookup-indices.test.ts | 275 --- ...ntitlement-store-contract.dialects.test.ts | 16 - .../test/hold-expiry.dialects.test.ts | 125 - .../inventory-store-contract.dialects.test.ts | 18 - .../store-postgres/test/migration-gap.test.ts | 141 -- .../test/no-oversell-cart.pg.test.ts | 98 - .../no-oversell-checkout-multiline.pg.test.ts | 263 --- .../test/no-oversell-checkout.pg.test.ts | 220 -- .../test/no-oversell.pg.test.ts | 138 -- ...der-cancellation-contract.dialects.test.ts | 168 -- .../test/order-flow.dialects.test.ts | 605 ----- ...rder-fulfillment-contract.dialects.test.ts | 167 -- packages/store-postgres/test/order-harness.ts | 488 ---- .../order-items-insert-batch.dialects.test.ts | 151 -- .../test/order-lookup-indices.test.ts | 254 -- ...rder-notes-store-contract.dialects.test.ts | 51 - .../order-store-contract.dialects.test.ts | 16 - .../order-timeline-contract.dialects.test.ts | 81 - ...order-transition-contract.dialects.test.ts | 20 - .../test/outbox-dispatch.dialects.test.ts | 133 -- .../test/parse-aggregate.test.ts | 26 - .../product-commerce-batch.dialects.test.ts | 131 -- ...t-commerce-snapshot-batch.dialects.test.ts | 125 - ...t-commerce-store-contract.dialects.test.ts | 19 - .../refund-order-contract.dialects.test.ts | 21 - .../test/refund-race.pg.test.ts | 400 ---- .../test/reporting.contract.dialects.test.ts | 15 - .../test/reporting.seeded.test.ts | 123 - .../reserve-cart-line-crash.dialects.test.ts | 189 -- .../resolve-reconciliation-race.pg.test.ts | 118 - .../test/restock-concurrency.pg.test.ts | 245 -- .../test/rules-cas-race.pg.test.ts | 72 - .../rules-stores-contract.dialects.test.ts | 29 - .../test/session-contract.dialects.test.ts | 16 - .../test/settings.contract.dialects.test.ts | 14 - .../test/sku-rename-ledger.dialects.test.ts | 256 -- .../test/sku-rename-race.pg.test.ts | 567 ----- packages/store-postgres/test/ticking-clock.ts | 28 - .../test/variant-sku-rename-race.pg.test.ts | 940 -------- packages/store-postgres/tsconfig.json | 10 - packages/store-postgres/tsdown.config.ts | 7 - packages/store-postgres/vitest.config.ts | 16 - scripts/pg-test-files.sh | 7 +- tsconfig.json | 2 - 175 files changed, 4 insertions(+), 34858 deletions(-) delete mode 100644 packages/service/package.json delete mode 100644 packages/service/src/app.ts delete mode 100644 packages/service/src/auth.ts delete mode 100644 packages/service/src/config.ts delete mode 100644 packages/service/src/email/senders.ts delete mode 100644 packages/service/src/index.ts delete mode 100644 packages/service/src/routes/admin.ts delete mode 100644 packages/service/src/routes/auth.ts delete mode 100644 packages/service/src/routes/carts.ts delete mode 100644 packages/service/src/routes/catalog.ts delete mode 100644 packages/service/src/routes/entitlements.ts delete mode 100644 packages/service/src/routes/internal-auth.ts delete mode 100644 packages/service/src/routes/internal-emails.ts delete mode 100644 packages/service/src/routes/inventory.ts delete mode 100644 packages/service/src/routes/me.ts delete mode 100644 packages/service/src/routes/orders.ts delete mode 100644 packages/service/src/routes/product-commerce.ts delete mode 100644 packages/service/src/routes/reports.ts delete mode 100644 packages/service/src/routes/rules-admin.ts delete mode 100644 packages/service/src/routes/session-auth.ts delete mode 100644 packages/service/src/routes/settings.ts delete mode 100644 packages/service/src/routes/webhooks.ts delete mode 100644 packages/service/src/schemas.ts delete mode 100644 packages/service/src/stripe-wiring.ts delete mode 100644 packages/service/src/worker.ts delete mode 100644 packages/service/src/x402-wiring.ts delete mode 100644 packages/service/test/admin-cancel-http.test.ts delete mode 100644 packages/service/test/admin-coupons-http.test.ts delete mode 100644 packages/service/test/admin-customer-context-http.test.ts delete mode 100644 packages/service/test/admin-fulfillment-http.test.ts delete mode 100644 packages/service/test/admin-order-notes-http.test.ts delete mode 100644 packages/service/test/admin-orders-http.test.ts delete mode 100644 packages/service/test/admin-product-edit-http.test.ts delete mode 100644 packages/service/test/admin-products-http.test.ts delete mode 100644 packages/service/test/admin-read-gate.test.ts delete mode 100644 packages/service/test/admin-refund-http.test.ts delete mode 100644 packages/service/test/admin-resolve-reconciliation-http.test.ts delete mode 100644 packages/service/test/admin-restock-http.test.ts delete mode 100644 packages/service/test/admin-timeline-http.test.ts delete mode 100644 packages/service/test/auth.test.ts delete mode 100644 packages/service/test/carts.http.contract.test.ts delete mode 100644 packages/service/test/catalog-commerce-batch.http-contract.test.ts delete mode 100644 packages/service/test/checkout-intent.http.pg.test.ts delete mode 100644 packages/service/test/checkout-quote.http.contract.pg.test.ts delete mode 100644 packages/service/test/config.test.ts delete mode 100644 packages/service/test/customers.http.contract.pg.test.ts delete mode 100644 packages/service/test/edit-product-schema.test.ts delete mode 100644 packages/service/test/entitlements-check-auth.app.test.ts delete mode 100644 packages/service/test/entitlements-check-auth.http.contract.pg.test.ts delete mode 100644 packages/service/test/expire-holds.test.ts delete mode 100644 packages/service/test/helpers/start-test-server.ts delete mode 100644 packages/service/test/http-inventory-contract.pg.test.ts delete mode 100644 packages/service/test/inventory-not-found.test.ts delete mode 100644 packages/service/test/orders.http.contract.pg.test.ts delete mode 100644 packages/service/test/product-commerce-http.test.ts delete mode 100644 packages/service/test/products-list-low-stock-schema.test.ts delete mode 100644 packages/service/test/public-order-redaction.test.ts delete mode 100644 packages/service/test/qty-bounds.test.ts delete mode 100644 packages/service/test/reports-http.test.ts delete mode 100644 packages/service/test/rules-admin.http.contract.pg.test.ts delete mode 100644 packages/service/test/service-token.test.ts delete mode 100644 packages/service/test/settings-http.test.ts delete mode 100644 packages/service/test/stripe-wiring.test.ts delete mode 100644 packages/service/test/worker-entry.pg.test.ts delete mode 100644 packages/service/test/worker-entry.test.ts delete mode 100644 packages/service/test/x402-wiring.test.ts delete mode 100644 packages/service/tsconfig.json delete mode 100644 packages/service/tsdown.config.ts delete mode 100644 packages/service/vitest.config.ts delete mode 100644 packages/store-postgres/package.json delete mode 100644 packages/store-postgres/src/dialects-pg.ts delete mode 100644 packages/store-postgres/src/dialects-sqlite.ts delete mode 100644 packages/store-postgres/src/dialects.ts delete mode 100644 packages/store-postgres/src/id-gen.ts delete mode 100644 packages/store-postgres/src/index.ts delete mode 100644 packages/store-postgres/src/kysely-address-store.ts delete mode 100644 packages/store-postgres/src/kysely-cart-store.ts delete mode 100644 packages/store-postgres/src/kysely-coupon-store.ts delete mode 100644 packages/store-postgres/src/kysely-credential-verifier.ts delete mode 100644 packages/store-postgres/src/kysely-customer-store.ts delete mode 100644 packages/store-postgres/src/kysely-entitlement-store.ts delete mode 100644 packages/store-postgres/src/kysely-inventory-store.ts delete mode 100644 packages/store-postgres/src/kysely-order-notes-store.ts delete mode 100644 packages/store-postgres/src/kysely-order-store.ts delete mode 100644 packages/store-postgres/src/kysely-payment-event-store.ts delete mode 100644 packages/store-postgres/src/kysely-product-commerce-store.ts delete mode 100644 packages/store-postgres/src/kysely-reporting-store.ts delete mode 100644 packages/store-postgres/src/kysely-session-store.ts delete mode 100644 packages/store-postgres/src/kysely-settings-store.ts delete mode 100644 packages/store-postgres/src/kysely-shipping-rules-store.ts delete mode 100644 packages/store-postgres/src/kysely-tax-rules-store.ts delete mode 100644 packages/store-postgres/src/migrations/0001_phase0_inventory.ts delete mode 100644 packages/store-postgres/src/migrations/0002_product_commerce.ts delete mode 100644 packages/store-postgres/src/migrations/0003_cart.ts delete mode 100644 packages/store-postgres/src/migrations/0004_product_commerce_active_updated_at.ts delete mode 100644 packages/store-postgres/src/migrations/0005_orders.ts delete mode 100644 packages/store-postgres/src/migrations/0006_customers_sessions_outbox.ts delete mode 100644 packages/store-postgres/src/migrations/0007_shipping_tax_coupons.ts delete mode 100644 packages/store-postgres/src/migrations/0008_settings_and_reporting_indices.ts delete mode 100644 packages/store-postgres/src/migrations/0009_orders_admin_list_indices.ts delete mode 100644 packages/store-postgres/src/migrations/0010_order_notes.ts delete mode 100644 packages/store-postgres/src/migrations/0011_reconciliation_resolution.ts delete mode 100644 packages/store-postgres/src/migrations/0012_order_fulfillment.ts delete mode 100644 packages/store-postgres/src/migrations/0013_order_cancellation.ts delete mode 100644 packages/store-postgres/src/migrations/0014_order_events.ts delete mode 100644 packages/store-postgres/src/migrations/0015_product_commerce_admin_list_indices.ts delete mode 100644 packages/store-postgres/src/migrations/0016_inventory_stock_movements.ts delete mode 100644 packages/store-postgres/src/migrations/0017_product_commerce_data_model_adds.ts delete mode 100644 packages/store-postgres/src/migrations/0018_coupons_admin_list.ts delete mode 100644 packages/store-postgres/src/migrations/0019_order_shipping_address.ts delete mode 100644 packages/store-postgres/src/migrations/0020_refunds.ts delete mode 100644 packages/store-postgres/src/migrations/0021_cart_order_id.ts delete mode 100644 packages/store-postgres/src/migrations/0022_order_lookup_indices.ts delete mode 100644 packages/store-postgres/src/migrations/0023_product_variants.ts delete mode 100644 packages/store-postgres/src/migrations/0024_entitlement_lookup_indices.ts delete mode 100644 packages/store-postgres/src/migrations/index.ts delete mode 100644 packages/store-postgres/src/pg.ts delete mode 100644 packages/store-postgres/src/schema.ts delete mode 100644 packages/store-postgres/src/testing.ts delete mode 100644 packages/store-postgres/test/address-book-contract.dialects.test.ts delete mode 100644 packages/store-postgres/test/adjust-concurrency.pg.test.ts delete mode 100644 packages/store-postgres/test/cart-fence.dialects.test.ts delete mode 100644 packages/store-postgres/test/cart-store-contract.dialects.test.ts delete mode 100644 packages/store-postgres/test/coupon-lifecycle.dialects.test.ts delete mode 100644 packages/store-postgres/test/coupon-no-over-redeem.pg.test.ts delete mode 100644 packages/store-postgres/test/coupon-reconciliation.dialects.test.ts delete mode 100644 packages/store-postgres/test/credential-verifier-contract.dialects.test.ts delete mode 100644 packages/store-postgres/test/customer-harness.ts delete mode 100644 packages/store-postgres/test/customer-store-contract.dialects.test.ts delete mode 100644 packages/store-postgres/test/describe-each-dialect.ts delete mode 100644 packages/store-postgres/test/entitlement-lookup-indices.test.ts delete mode 100644 packages/store-postgres/test/entitlement-store-contract.dialects.test.ts delete mode 100644 packages/store-postgres/test/hold-expiry.dialects.test.ts delete mode 100644 packages/store-postgres/test/inventory-store-contract.dialects.test.ts delete mode 100644 packages/store-postgres/test/migration-gap.test.ts delete mode 100644 packages/store-postgres/test/no-oversell-cart.pg.test.ts delete mode 100644 packages/store-postgres/test/no-oversell-checkout-multiline.pg.test.ts delete mode 100644 packages/store-postgres/test/no-oversell-checkout.pg.test.ts delete mode 100644 packages/store-postgres/test/no-oversell.pg.test.ts delete mode 100644 packages/store-postgres/test/order-cancellation-contract.dialects.test.ts delete mode 100644 packages/store-postgres/test/order-flow.dialects.test.ts delete mode 100644 packages/store-postgres/test/order-fulfillment-contract.dialects.test.ts delete mode 100644 packages/store-postgres/test/order-harness.ts delete mode 100644 packages/store-postgres/test/order-items-insert-batch.dialects.test.ts delete mode 100644 packages/store-postgres/test/order-lookup-indices.test.ts delete mode 100644 packages/store-postgres/test/order-notes-store-contract.dialects.test.ts delete mode 100644 packages/store-postgres/test/order-store-contract.dialects.test.ts delete mode 100644 packages/store-postgres/test/order-timeline-contract.dialects.test.ts delete mode 100644 packages/store-postgres/test/order-transition-contract.dialects.test.ts delete mode 100644 packages/store-postgres/test/outbox-dispatch.dialects.test.ts delete mode 100644 packages/store-postgres/test/parse-aggregate.test.ts delete mode 100644 packages/store-postgres/test/product-commerce-batch.dialects.test.ts delete mode 100644 packages/store-postgres/test/product-commerce-snapshot-batch.dialects.test.ts delete mode 100644 packages/store-postgres/test/product-commerce-store-contract.dialects.test.ts delete mode 100644 packages/store-postgres/test/refund-order-contract.dialects.test.ts delete mode 100644 packages/store-postgres/test/refund-race.pg.test.ts delete mode 100644 packages/store-postgres/test/reporting.contract.dialects.test.ts delete mode 100644 packages/store-postgres/test/reporting.seeded.test.ts delete mode 100644 packages/store-postgres/test/reserve-cart-line-crash.dialects.test.ts delete mode 100644 packages/store-postgres/test/resolve-reconciliation-race.pg.test.ts delete mode 100644 packages/store-postgres/test/restock-concurrency.pg.test.ts delete mode 100644 packages/store-postgres/test/rules-cas-race.pg.test.ts delete mode 100644 packages/store-postgres/test/rules-stores-contract.dialects.test.ts delete mode 100644 packages/store-postgres/test/session-contract.dialects.test.ts delete mode 100644 packages/store-postgres/test/settings.contract.dialects.test.ts delete mode 100644 packages/store-postgres/test/sku-rename-ledger.dialects.test.ts delete mode 100644 packages/store-postgres/test/sku-rename-race.pg.test.ts delete mode 100644 packages/store-postgres/test/ticking-clock.ts delete mode 100644 packages/store-postgres/test/variant-sku-rename-race.pg.test.ts delete mode 100644 packages/store-postgres/tsconfig.json delete mode 100644 packages/store-postgres/tsdown.config.ts delete mode 100644 packages/store-postgres/vitest.config.ts diff --git a/packages/plugin/tsconfig.json b/packages/plugin/tsconfig.json index 06e24e67..04b5d898 100644 --- a/packages/plugin/tsconfig.json +++ b/packages/plugin/tsconfig.json @@ -9,9 +9,7 @@ "references": [ { "path": "../admin-presentation" }, { "path": "../domain" }, - { "path": "../service" }, { "path": "../store-emdash" }, - { "path": "../store-postgres" }, { "path": "../payments-stripe" }, { "path": "../payments-x402" } ] diff --git a/packages/service/package.json b/packages/service/package.json deleted file mode 100644 index 3c9c561d..00000000 --- a/packages/service/package.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "name": "@otta-sh/service", - "version": "0.0.1", - "description": "Otta commerce service — thin Hono REST API mirroring the domain ports 1:1.", - "homepage": "https://github.com/UrumiAI/otta.sh#readme", - "bugs": { - "url": "https://github.com/UrumiAI/otta.sh/issues" - }, - "license": "MIT", - "repository": { - "type": "git", - "url": "git+https://github.com/UrumiAI/otta.sh.git", - "directory": "packages/service" - }, - "files": [ - "dist" - ], - "type": "module", - "exports": { - ".": "./src/index.ts", - "./app": "./src/app.ts", - "./worker": "./src/worker.ts" - }, - "publishConfig": { - "exports": { - ".": { - "types": "./dist/index.d.mts", - "default": "./dist/index.mjs" - }, - "./app": { - "types": "./dist/app.d.mts", - "default": "./dist/app.mjs" - }, - "./worker": { - "types": "./dist/worker.d.mts", - "default": "./dist/worker.mjs" - } - } - }, - "scripts": { - "build": "tsdown" - }, - "dependencies": { - "@hono/node-server": "catalog:", - "@otta-sh/domain": "workspace:*", - "@otta-sh/payments-stripe": "workspace:*", - "@otta-sh/payments-x402": "workspace:*", - "@otta-sh/store-postgres": "workspace:*", - "hono": "catalog:", - "zod": "catalog:" - }, - "devDependencies": { - "@types/node": "catalog:", - "tsdown": "catalog:", - "typescript": "catalog:", - "vitest": "catalog:" - } -} diff --git a/packages/service/src/app.ts b/packages/service/src/app.ts deleted file mode 100644 index e351e795..00000000 --- a/packages/service/src/app.ts +++ /dev/null @@ -1,279 +0,0 @@ -import type { - AddressStore, - CouponStore, - CustomerCredentialVerifier, - CustomerStore, - EmailSender, - EntitlementStore, - IdGen, - OrderNotesStore, - OrderStore, - PaymentEventStore, - PaymentGateway, - PaymentMethod, - ProductCommerceStore, - ReportingStore, - SessionStore, - SettingsStore, - ShippingRulesStore, - TaxRulesStore, -} from "@otta-sh/domain"; -import { Hono } from "hono"; -import type { MiddlewareHandler } from "hono"; -import { requireServiceToken } from "./auth.js"; -import { adminRoutes } from "./routes/admin.js"; -import { requireInternalToken } from "./routes/internal-auth.js"; -import { authRoutes } from "./routes/auth.js"; -import { reportsRoutes } from "./routes/reports.js"; -import { rulesAdminRoutes } from "./routes/rules-admin.js"; -import { settingsRoutes } from "./routes/settings.js"; -import { type CartRoutesDeps, cartRoutes, expireHoldsRoutes } from "./routes/carts.js"; -import { catalogRoutes } from "./routes/catalog.js"; -import { entitlementRoutes } from "./routes/entitlements.js"; -import { internalEmailRoutes } from "./routes/internal-emails.js"; -import { type InventoryDeps, inventoryRoutes } from "./routes/inventory.js"; -import { meRoutes } from "./routes/me.js"; -import { orderRoutes } from "./routes/orders.js"; -import { productCommerceRoutes } from "./routes/product-commerce.js"; -import { webhookRoutes } from "./routes/webhooks.js"; - -export type AppDeps = InventoryDeps & - CartRoutesDeps & { - productCommerce: ProductCommerceStore; - // Phase 4 (§7): order/payment/entitlement stores + the payment gateways. - orderStore: OrderStore; - // Admin-UX Increment 0: append-only order notes. - orderNotesStore: OrderNotesStore; - entitlementStore: EntitlementStore; - paymentEventStore: PaymentEventStore; - // Phase 6 (§6): shipping / tax / coupon rules stores. - shippingRules: ShippingRulesStore; - taxRules: TaxRulesStore; - couponStore: CouponStore; - // Phase 7 (§6): read-only reporting + operational settings stores. - reportingStore: ReportingStore; - settingsStore: SettingsStore; - idGen: IdGen; - gateways: Partial>; - /** Checkout hold TTL in ms; defaults to the domain's DEFAULT_CHECKOUT_TTL_MS. */ - checkoutTtlMs?: number; - // Phase 5 (§7): storefront customer identity, address book, email. - customerStore: CustomerStore; - addressStore: AddressStore; - sessionStore: SessionStore; - credentialVerifier: CustomerCredentialVerifier; - emailSender: EmailSender; - /** Storefront base URL for the emailed magic link (optional). */ - storefrontBaseUrl?: string; - /** - * SERVICE_API_TOKEN write gate (D9 / ADR-0007): when set, every non-GET/HEAD - * request must carry `X-Service-Token: ` (a dedicated header — - * NOT `Authorization: Bearer`, which is owned by customer session auth). - * Unset ⇒ fully open (exactly the pre-gate behavior — local dev and tests). - */ - serviceToken?: string; - }; - -/** - * Build the Hono app without listening (§0.6) so tests can mount it and the bin - * (`index.ts`) can serve it. The concrete stores/clock are injected — the app - * knows nothing about pg/sqlite. - */ -export function createApp(deps: AppDeps): Hono { - const app = new Hono(); - // The write gate is registered FIRST so no route — present or future — can - // be mounted in front of it. Exemptions (exact method+path, each with its - // OWN caller authentication — never an open hole): - // - POST /webhooks/stripe: called directly by Stripe (deliberately no - // plugin proxy — the sandbox bridge destroys the raw bytes the HMAC - // needs); authenticated by `Stripe-Signature` HMAC verification over the - // raw body inside settleOrder, with a freshness window and rotation-aware - // v1 checks. Stripe cannot carry our service token. Every other verb on - // the path stays gated. - app.use( - "*", - requireServiceToken(deps.serviceToken, [{ method: "POST", path: "/webhooks/stripe" }]), - ); - - // ADR-0010 — the AUTHORITATIVE admin/config guard. Passing the write gate - // above is NOT authorization: the gate exempts GET/HEAD (`auth.ts`) because it - // protects against unauthenticated WRITES by a machine caller, so a GET into - // any admin surface arrives with no credential at all unless a route checks - // one. Registering the check here, at the parent, BEFORE any `app.route(...)`, - // makes the whole `/admin/**`, `/reports/**` and `/settings` surface - // default-DENY: a route added later under those prefixes is closed even if its - // author forgets an inline guard. - // - // Parent-level and not inside a sub-app, deliberately. Hono merges a sub-app's - // middleware into the parent AT MOUNT TIME, so a blanket `app.use("/*")` in one - // sub-app only covers what is registered AFTER it — a SIBLING sub-app mounted - // at the same prefix earlier (`adminRoutes` and `rulesAdminRoutes` are both - // mounted at "/admin") is not covered. Sub-app and per-route guards stay as - // defense-in-depth; this one is the fail-safe (pinned by - // `test/admin-read-gate.test.ts`). - // - // A future route under these prefixes that needs looser auth must be mounted - // OUTSIDE them, never exempted here (ADR-0007 rejected exemption sprawl). - const adminGuard: MiddlewareHandler = async (c, next) => { - const denied = requireInternalToken(c, deps.internalToken); - if (denied !== null) return denied; - await next(); - }; - app.use("/admin/*", adminGuard); - app.use("/reports/*", adminGuard); - // Both forms on purpose: `/settings` is a leaf, and the exact-path - // registration must not depend on a Hono minor keeping "the wildcard also - // matches the bare prefix" (measured true on 4.12.x, pinned by the test). - app.use("/settings", adminGuard); - app.use("/settings/*", adminGuard); - - app.get("/health", (c) => c.json({ ok: true })); - app.route("/inventory", inventoryRoutes(deps)); - app.route( - "/products", - productCommerceRoutes({ - productCommerce: deps.productCommerce, - inventory: deps.store, - // Unlocks the operator's projection of the variants read (orphaned - // tombstones), exactly as it does on `GET /orders/:orderId`. Spread - // conditionally so an unconfigured server passes no key at all rather - // than an explicit `undefined` — the unlock is then unavailable, never - // open, matching the other sub-apps threaded below. - ...(deps.internalToken !== undefined ? { internalToken: deps.internalToken } : {}), - }), - ); - app.route("/catalog", catalogRoutes({ productCommerce: deps.productCommerce })); - app.route("/carts", cartRoutes(deps)); - // Internal (non-public) sweep trigger — self-interval or plugin-cron hits this. - app.route("/internal", expireHoldsRoutes(deps)); - - // Phase 4 (§7): checkout + order read + internal order-expiry (mounted at "/" - // with absolute paths: /checkout/orders, /orders/:id, /internal/expire-orders), - // the public Stripe webhook receiver, and the entitlement grant/check surface. - const orderDeps = { ...deps, checkoutTtlMs: deps.checkoutTtlMs }; - app.route("/", orderRoutes(orderDeps)); - app.route("/webhooks", webhookRoutes(orderDeps)); - // sessionStore + customerStore, for the /check session scope (ADR-0011). - // `...orderDeps` already carries both (they're required AppDeps fields), so - // this is redundant today — but listed explicitly anyway, matching - // EntitlementRoutesDeps' own doc: a future reader wiring this route from a - // narrower deps object (e.g. `productCommerceRoutes`' hand-built subset - // style) must not be able to drop them silently. - app.route( - "/entitlements", - entitlementRoutes({ - ...orderDeps, - sessionStore: deps.sessionStore, - customerStore: deps.customerStore, - }), - ); - - // Phase 5 (§7): storefront customer auth, the authenticated /me surface, the - // admin transition, and the outbox dispatcher trigger. - app.route( - "/auth", - authRoutes({ - credentialVerifier: deps.credentialVerifier, - customerStore: deps.customerStore, - sessionStore: deps.sessionStore, - orderStore: deps.orderStore, - emailSender: deps.emailSender, - clock: deps.clock, - ...(deps.storefrontBaseUrl !== undefined - ? { storefrontBaseUrl: deps.storefrontBaseUrl } - : {}), - }), - ); - app.route( - "/me", - meRoutes({ - sessionStore: deps.sessionStore, - customerStore: deps.customerStore, - orderStore: deps.orderStore, - addressStore: deps.addressStore, - }), - ); - app.route( - "/admin", - adminRoutes({ - orderStore: deps.orderStore, - orderNotesStore: deps.orderNotesStore, - // Admin-UX Increment 1: the customer-context read on the order detail. - customerStore: deps.customerStore, - addressStore: deps.addressStore, - sessionStore: deps.sessionStore, - // Admin-UX Increment 2: the Products console (view-only enumerate + detail). - productCommerce: deps.productCommerce, - inventoryStore: deps.store, - // ADR-0008: the refund endpoint selects the order's gateway to issue - // (Stripe) or record-only (x402/no-secret) and reads its capability flag. - gateways: deps.gateways, - // ADR-0008 reserve-before-issue: the loud-anomaly seam + clock for the - // impossible-by-construction "issued but unrecorded" residual. - paymentEventStore: deps.paymentEventStore, - clock: deps.clock, - internalToken: deps.internalToken, - }), - ); - // Phase 6 admin CRUD (shipping/tax/coupon config) — mounted at /admin too; no - // path collision with /admin/orders/:id/transition. - app.route( - "/admin", - rulesAdminRoutes({ - shippingRules: deps.shippingRules, - taxRules: deps.taxRules, - couponStore: deps.couponStore, - // Increment 3 closeout: the tax-class DELETE route's `deleteTaxClass` - // use-case needs the product-reference guard. - productCommerce: deps.productCommerce, - ...(deps.internalToken !== undefined ? { internalToken: deps.internalToken } : {}), - }), - ); - // Phase 7 (§6): read-only reports + operational settings. BOTH are admin - // surface — /reports/* reads expose merchant financial/operational data, and - // /settings carries a privileged write AND the read half of it — so both - // require the internal token (review J5; the read half is ADR-0010). The - // `internalToken` threaded here now feeds each sub-app's defense-in-depth - // guard; the parent-level guard above is what actually closes the prefixes. - app.route( - "/reports", - reportsRoutes({ - reportingStore: deps.reportingStore, - settingsStore: deps.settingsStore, - ...(deps.internalToken !== undefined ? { internalToken: deps.internalToken } : {}), - }), - ); - app.route( - "/settings", - settingsRoutes({ - settingsStore: deps.settingsStore, - ...(deps.internalToken !== undefined ? { internalToken: deps.internalToken } : {}), - }), - ); - app.route( - "/internal", - internalEmailRoutes({ - orderStore: deps.orderStore, - emailSender: deps.emailSender, - customerStore: deps.customerStore, - credentialVerifier: deps.credentialVerifier, - clock: deps.clock, - ...(deps.internalToken !== undefined ? { internalToken: deps.internalToken } : {}), - }), - ); - - // Consistent error envelope for anything thrown past the routes (e.g. a - // DB fault, or `ReservationCommitLostError` — commit/release of a - // reservation that existed but was lost). No internal message or stack is - // leaked to the client; the real error is logged server-side. NOTE: an - // UNKNOWN reservation on commit/release is mapped to a 404 at the route - // (`routes/inventory.ts`) before it ever reaches here — see - // `ReservationNotFoundError`'s port docblock for the one known asymmetry - // (`adjust`, reached via cart PATCH, still surfaces here as a 500). - app.onError((err, c) => { - console.error("[service] unhandled error:", err); - return c.json({ ok: false, error: "internal_error" }, 500); - }); - - return app; -} diff --git a/packages/service/src/auth.ts b/packages/service/src/auth.ts deleted file mode 100644 index 26456828..00000000 --- a/packages/service/src/auth.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { createHash, timingSafeEqual } from "node:crypto"; -import type { MiddlewareHandler } from "hono"; - -/** Constant-time shared-secret comparison: hash both sides to a fixed length - * first so `timingSafeEqual` applies to arbitrary token lengths — a plain - * `!==` would leak the match length/prefix through timing. Shared by the - * `X-Internal-Token` gate (routes/internal-auth.ts) and the `X-Service-Token` - * write gate. */ -export function tokenMatches(provided: string | undefined, expected: string): boolean { - if (provided === undefined) return false; - const a = createHash("sha256").update(provided).digest(); - const b = createHash("sha256").update(expected).digest(); - return timingSafeEqual(a, b); -} - -/** - * SERVICE_API_TOKEN write gate (D9 / ADR-0007), registered FIRST in `createApp` - * so every route — current and future — inherits it. Token unset ⇒ pass-through - * (exactly the pre-gate behavior). Token set ⇒ GET/HEAD stay open as the - * storefront read surface (`/health` is a GET, so it is open by the same rule); - * every other method on every path requires the `X-Service-Token: ` - * header, else 401. - * - * This blanket "GETs stay open" is the READ surface, not a promise that every - * GET is anonymous: `GET /entitlements/check` enforces its OWN route-level auth - * for the buyerRef scope (X-Internal-Token) so it is not an email existence - * oracle — see routes/entitlements.ts and ADR-0011. A GET being past this gate - * means only that the service token does not apply; the route may still demand a - * session or an internal token. - * - * The machine token lives in its OWN dedicated header (`X-Service-Token`), NOT - * `Authorization: Bearer` (ADR-0007): `Authorization: Bearer` is owned solely by - * customer session auth (routes/session-auth.ts, used by `/auth/logout` and the - * `/me/*` surface). Sharing the header would 401 those session routes at this - * gate — a customer's Bearer carries a SESSION token, not the service token — - * before session auth ever runs. The 401 is byte-identical to the - * `X-Internal-Token` gate (`{ok:false,error:"unauthorized"}`, no - * `WWW-Authenticate` challenge — a custom header has no registered scheme). - * Note: Hono serves HEAD via GET handlers, so HEAD is explicitly listed to keep - * it as open as the GET it delegates to. - * - * `exemptions` is an EXACT method+path allowlist for endpoints that carry - * their own cryptographic caller authentication and whose third-party caller - * cannot be given our service token (e.g. a payment provider's webhook - * receiver). Scoping to the method keeps every other verb on the same path - * gated. Every entry must document its own auth mechanism at the - * registration site (app.ts) — the default remains deny. - */ -export interface ServiceTokenExemption { - method: string; - path: string; -} - -export function requireServiceToken( - token: string | undefined, - exemptions: readonly ServiceTokenExemption[] = [], -): MiddlewareHandler { - return async (c, next) => { - if (token === undefined || token.length === 0) return next(); - if (c.req.method === "GET" || c.req.method === "HEAD") return next(); - if (exemptions.some((e) => e.method === c.req.method && e.path === c.req.path)) { - return next(); - } - - if (!tokenMatches(c.req.header("X-Service-Token"), token)) { - return c.json({ ok: false, error: "unauthorized" }, 401); - } - return next(); - }; -} diff --git a/packages/service/src/config.ts b/packages/service/src/config.ts deleted file mode 100644 index b83ac62f..00000000 --- a/packages/service/src/config.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * Pure env parsing shared by both entries (D4): `index.ts` feeds it - * `process.env`, `worker.ts` feeds it the per-event `env` binding. No IO, no - * process access here — everything is passed in. - */ - -export interface ServiceEnv { - CART_HOLD_TTL_MS?: string | undefined; - INTERNAL_API_TOKEN?: string | undefined; - SERVICE_API_TOKEN?: string | undefined; -} - -export interface ServiceConfig { - /** Hold TTL in ms; undefined ⇒ the domain default (15 min) applies. */ - ttlMs: number | undefined; - /** Shared secret for `/internal/*`; unset ⇒ those endpoints answer 503. */ - internalToken: string | undefined; - /** Shared secret for the write gate; unset ⇒ fully open (today's behavior). */ - serviceToken: string | undefined; -} - -/** Hold TTL (§5): default 15 min, configurable via CART_HOLD_TTL_MS. */ -export function parseHoldTtlMs(raw: string | undefined): number | undefined { - if (raw === undefined) return undefined; - const ttlMs = Number(raw); - if (!Number.isFinite(ttlMs) || ttlMs <= 0) { - throw new Error(`CART_HOLD_TTL_MS must be a positive number, got "${raw}"`); - } - return ttlMs; -} - -/** Resolve the service's env-derived config. Tokens pass through verbatim — - * the enforcement layers decide what unset/empty means (never silently open - * for `/internal/*`; open-by-default for the write gate, preserved behavior). */ -export function resolveServiceConfig(env: ServiceEnv): ServiceConfig { - return { - ttlMs: parseHoldTtlMs(env.CART_HOLD_TTL_MS), - internalToken: env.INTERNAL_API_TOKEN, - serviceToken: env.SERVICE_API_TOKEN, - }; -} - -/** - * Shared open-write-gate warning builder (#42). Returns the warning message - * when the `SERVICE_API_TOKEN` write gate is OPEN (token unset OR empty), and - * `undefined` when a token is set. Both entries call it: the Worker fires it - * once per isolate (worker.ts's `warnedOpenGate` flag), the Node bin once at - * boot (index.ts). Each passes its own remedy string (wrangler vs env). - * - * CANONICAL CONDITION: the `undefined || length === 0` test here MUST match - * `requireServiceToken` in src/auth.ts — that middleware is the source of - * truth for what "the gate is open" means (it passes through on exactly this - * condition). Keep the two in lockstep so the warning can never claim the gate - * is open while the middleware enforces it, or vice versa. - */ -export function openWriteGateWarning( - serviceToken: string | undefined, - remedy: string, -): string | undefined { - if (serviceToken !== undefined && serviceToken.length > 0) return undefined; - return `[service] SERVICE_API_TOKEN is unset — the write surface is OPEN. ${remedy}`; -} diff --git a/packages/service/src/email/senders.ts b/packages/service/src/email/senders.ts deleted file mode 100644 index 724a4e3a..00000000 --- a/packages/service/src/email/senders.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { renderEmail, type EmailSender, type SendEmailInput } from "@otta-sh/domain"; - -/** - * Concrete `EmailSender` adapters (Phase 5 §6/§7). The service sends email - * directly (the §6 draft ADR — not EmDash's `email:send`), so these live here, - * service-side. Both render via `renderEmail`; the transport differs. - * - * `FakeEmailSender` (in `@otta-sh/domain/testing`) remains the CI gate for the - * outbox contract (exactly-once enqueue + claim, at-least-once delivery — - * effectively-once only once a provider's `Idempotency-Key` dedupes it); these - * are the real transports a deployment picks. - */ - -/** Logs the rendered message — the dev/default transport, observable without any - * external service (an SMTP sink / provider adapter swaps in behind the port). */ -export class ConsoleEmailSender implements EmailSender { - #log: (line: string) => void; - - constructor(log: (line: string) => void = console.log) { - this.#log = log; - } - - async send(input: SendEmailInput): Promise { - const rendered = renderEmail(input.template, input.data); - this.#log( - `[email] to=${input.to} template=${input.template} key=${input.idempotencyKey} subject=${JSON.stringify(rendered.subject)}`, - ); - } -} - -export interface HttpEmailSenderOptions { - /** Transactional-email API endpoint that accepts a POST of the rendered mail. */ - apiUrl: string; - /** Bearer token for the provider API, if any. */ - apiKey?: string; - from: string; -} - -/** - * Posts the rendered email to a transactional-email HTTP API. Uses the global - * `fetch` (the service is a normal Node process — this is not the sandboxed - * plugin). `idempotencyKey` is forwarded so the provider can dedupe too (§6). - */ -export class HttpEmailSender implements EmailSender { - #opts: HttpEmailSenderOptions; - - constructor(opts: HttpEmailSenderOptions) { - this.#opts = opts; - } - - async send(input: SendEmailInput): Promise { - const rendered = renderEmail(input.template, input.data); - const headers: Record = { - "content-type": "application/json", - "Idempotency-Key": input.idempotencyKey, - }; - if (this.#opts.apiKey !== undefined) headers["authorization"] = `Bearer ${this.#opts.apiKey}`; - const res = await fetch(this.#opts.apiUrl, { - method: "POST", - headers, - body: JSON.stringify({ - from: this.#opts.from, - to: input.to, - subject: rendered.subject, - text: rendered.text, - html: rendered.html, - template: input.template, - }), - }); - if (!res.ok) { - throw new Error(`email transport failed with status ${res.status}`); - } - } -} diff --git a/packages/service/src/index.ts b/packages/service/src/index.ts deleted file mode 100644 index 8908aa1f..00000000 --- a/packages/service/src/index.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { serve } from "@hono/node-server"; -import { - type CartDeps, - dispatchOrderEmails, - expireHolds, - type PaymentGateway, - type PaymentMethod, -} from "@otta-sh/domain"; -import { - KyselyAddressStore, - KyselyCartStore, - KyselyCouponStore, - KyselyCredentialVerifier, - KyselyCustomerStore, - KyselyEntitlementStore, - KyselyInventoryStore, - KyselyOrderNotesStore, - KyselyOrderStore, - KyselyPaymentEventStore, - KyselyProductCommerceStore, - KyselyReportingStore, - KyselySessionStore, - KyselySettingsStore, - KyselyShippingRulesStore, - KyselyTaxRulesStore, - makePostgresDb, - makePostgresPool, - migrateToLatest, - uuidIdGen, -} from "@otta-sh/store-postgres"; -import { createApp } from "./app.js"; -import { openWriteGateWarning, resolveServiceConfig } from "./config.js"; -import { ConsoleEmailSender, HttpEmailSender } from "./email/senders.js"; -import { wireStripeGateway } from "./stripe-wiring.js"; -import { wireX402Gateway } from "./x402-wiring.js"; - -// Bin entry (§0.6): wire the real pg-backed stores and serve on PORT. -const connectionString = process.env.PG_CONNECTION_STRING; -if (connectionString === undefined) { - throw new Error("PG_CONNECTION_STRING is required to start @otta-sh/service"); -} - -const pool = makePostgresPool({ connectionString }); -const db = makePostgresDb(pool); -await migrateToLatest(db); - -const clock = { now: () => new Date() }; -const store = new KyselyInventoryStore({ db, idGen: uuidIdGen, clock }); -const productCommerce = new KyselyProductCommerceStore({ db, clock }); -const cartStore = new KyselyCartStore({ db, idGen: uuidIdGen, clock }); -const orderStore = new KyselyOrderStore({ db, idGen: uuidIdGen, clock }); -const orderNotesStore = new KyselyOrderNotesStore({ db, idGen: uuidIdGen, clock }); -const entitlementStore = new KyselyEntitlementStore({ db, idGen: uuidIdGen, clock }); -const paymentEventStore = new KyselyPaymentEventStore({ db, idGen: uuidIdGen }); -// Phase 6 (§6): shipping / tax / coupon rules stores. -const shippingRules = new KyselyShippingRulesStore({ db }); -const taxRules = new KyselyTaxRulesStore({ db }); -const couponStore = new KyselyCouponStore({ db, idGen: uuidIdGen, clock }); -// Phase 7 (§6): read-only reporting + operational settings (service-DB tier). -const reportingStore = new KyselyReportingStore({ db, dialect: "postgres" }); -const settingsStore = new KyselySettingsStore({ db, clock }); - -// Phase 5 (§4/§7): storefront customer identity, address book, sessions, magic- -// link verifier, and the email transport (the service sends directly — §6 ADR). -const customerStore = new KyselyCustomerStore({ db, idGen: uuidIdGen, clock }); -const addressStore = new KyselyAddressStore({ db, idGen: uuidIdGen, clock }); -const sessionStore = new KyselySessionStore({ db, idGen: uuidIdGen, clock }); -const credentialVerifier = new KyselyCredentialVerifier({ - db, - customerStore, - idGen: uuidIdGen, - clock, -}); -const emailApiUrl = process.env.EMAIL_API_URL; -const emailSender = - emailApiUrl !== undefined && emailApiUrl.length > 0 - ? new HttpEmailSender({ - apiUrl: emailApiUrl, - apiKey: process.env.EMAIL_API_KEY, - from: process.env.EMAIL_FROM ?? "no-reply@otta.local", - }) - : new ConsoleEmailSender(); -const storefrontBaseUrl = process.env.STOREFRONT_BASE_URL; - -// Payment gateways (§5). Secrets are SERVICE-ENV ONLY (CLAUDE.md) — never in the -// plugin / ctx.kv. A gateway is wired only when its secret is present. -const gateways: Partial> = {}; -// Stripe: STRIPE_WEBHOOK_SECRET enables the gateway; STRIPE_SECRET_KEY flips -// createIntent to REAL PaymentIntents (and enables refunds). Webhook secret -// without secret key ⇒ a loud boot warning about unpayable offline client -// secrets — a warning, never a throw (staging/e2e run without it). See -// src/stripe-wiring.ts. -const stripeGateway = wireStripeGateway(process.env); -if (stripeGateway !== undefined) { - gateways.stripe = stripeGateway; -} -// x402 (review G4): FAIL-CLOSED wiring. The only available facilitator is the -// offline TEST one, so `wireX402Gateway` throws at startup when x402 env is -// set without the explicit X402_ALLOW_TEST_FACILITATOR=true opt-in, and warns -// loudly (non-production) when it is. See src/x402-wiring.ts. -const x402Gateway = wireX402Gateway(process.env); -if (x402Gateway !== undefined) { - gateways.x402 = x402Gateway; -} - -// Env-derived config (config.ts, shared with the Worker entry): hold TTL -// (default 15 min via CART_HOLD_TTL_MS), the /internal/* shared secret -// (INTERNAL_API_TOKEN — unset ⇒ 503, never silently open), and the write-gate -// secret (SERVICE_API_TOKEN — unset ⇒ open, today's behavior). -const { ttlMs, internalToken, serviceToken } = resolveServiceConfig(process.env); - -// Open-write-gate warning parity with the Worker entry (#42): boot runs once, -// so this fires exactly once. Set token ⇒ no warning. -const openGateWarning = openWriteGateWarning( - serviceToken, - "Set SERVICE_API_TOKEN in the service environment so the X-Service-Token write gate is enforced.", -); -if (openGateWarning !== undefined) console.warn(openGateWarning); - -const app = createApp({ - store, - productCommerce, - cartStore, - orderStore, - orderNotesStore, - entitlementStore, - paymentEventStore, - shippingRules, - taxRules, - couponStore, - reportingStore, - settingsStore, - customerStore, - addressStore, - sessionStore, - credentialVerifier, - emailSender, - idGen: uuidIdGen, - gateways, - clock, - ttlMs, - checkoutTtlMs: ttlMs, - internalToken, - serviceToken, - ...(storefrontBaseUrl !== undefined ? { storefrontBaseUrl } : {}), -}); -const port = Number(process.env.PORT ?? 3000); -serve({ fetch: app.fetch, port }); -console.log(`@otta-sh/service listening on :${port}`); - -// Self-scheduled email outbox dispatcher + login-challenge prune (§5.8 + -// review round H1) — the Node convenience wiring (a Worker deployment drives -// POST /internal/dispatch-emails via the cron hook instead, which does both). -// Unref'd so it never keeps the process alive on its own. -const emailDispatchDeps = { orderStore, emailSender, customerStore, clock }; -const emailSweepMs = Number(process.env.EMAIL_DISPATCH_INTERVAL_MS ?? 30_000); -setInterval(() => { - void dispatchOrderEmails(emailDispatchDeps).catch((err: unknown) => { - console.error("[service] email dispatch failed:", err); - }); - void credentialVerifier.pruneChallenges(clock.now().toISOString()).catch((err: unknown) => { - console.error("[service] login-challenge prune failed:", err); - }); -}, emailSweepMs).unref(); - -// Self-scheduled sweep (§5) — the Node convenience wiring; a Worker deployment -// instead drives POST /internal/expire-holds via the plugin `cron` hook. Lazy -// on-read keeps correctness independent of this timer. Unref'd so it never -// keeps the process alive on its own. -const sweepDeps: CartDeps = { cartStore, inventoryStore: store, clock, ttlMs }; -const sweepMs = Number(process.env.HOLD_SWEEP_INTERVAL_MS ?? 60_000); -setInterval(() => { - void expireHolds(sweepDeps).catch((err: unknown) => { - console.error("[service] hold sweep failed:", err); - }); -}, sweepMs).unref(); diff --git a/packages/service/src/routes/admin.ts b/packages/service/src/routes/admin.ts deleted file mode 100644 index 27d03dbb..00000000 --- a/packages/service/src/routes/admin.ts +++ /dev/null @@ -1,1444 +0,0 @@ -import { - type AddressStore, - appendOrderNote, - cancelOrder, - type Clock, - computeRefundCeiling, - type CustomerStore, - getOrderCustomerContext, - getOrderTimeline, - idempotencyKey as toIdempotencyKey, - InvalidLowStockThresholdError, - type InventoryStore, - InvalidProductFieldError, - legalNextStates, - listOrderNotes, - type OrderCustomerContext, - type OrderTimeline, - type OrderListCursor, - type OrderListFilter, - orderId as toOrderId, - type OrderNote, - type OrderNotesStore, - type OrderState, - type OrderStore, - type PaymentEventStore, - type PaymentGateway, - type PaymentMethod, - type ProductCommerce, - type ProductCommerceStore, - productId as toProductId, - type ProductListCursor, - type ProductListFilter, - type ProductSummary, - recordFulfillment, - type RefundOrderFailure, - type RefundRecord, - refundOrder, - removeStock, - resolveReconciliation, - restock, - type SessionStore, - SkuConflictError, - SkuHeldStockError, - SkuStockConflictError, - sumCapturedPayments, - sumRefunds, - transitionOrder, - updateProductCommerceFields, - sku as toSku, - money as toMoney, - cents as toCents, - currency as toCurrency, -} from "@otta-sh/domain"; -import { Hono } from "hono"; -import { z } from "zod"; -import { - appendNoteBody, - cancelOrderBody, - editProductCommerceBody, - orderListFilterSchema, - orderPathParams, - ordersListQuery, - type OrdersListQuery, - orderStateEnum, - productListFilterSchema, - productPathParams, - productsListQuery, - type ProductsListQuery, - recordFulfillmentBody, - refundOrderBody, - resolveReconciliationBody, - stockMovementBody, - transitionBody, -} from "../schemas.js"; -import { serializeOrder, serializeOrderSummary } from "./orders.js"; -import { requireInternalToken } from "./internal-auth.js"; - -export interface AdminRoutesDeps { - orderStore: OrderStore; - /** Append-only order notes (admin-UX Increment 0). */ - orderNotesStore: OrderNotesStore; - // Customer context on the order detail (admin-UX Increment 1) — read-only. - customerStore: CustomerStore; - addressStore: AddressStore; - sessionStore: SessionStore; - // Admin Products console (admin-UX Increment 2) — view-only enumerate + detail. - productCommerce: ProductCommerceStore; - /** The detail leaf's single-sku stock read (`getOnHand`) — never used by the - * list, which must not N+1 into inventory per row (port doc). Also the - * commerce EDIT's create-if-absent inventory seed (PR 1a): an edit that - * leaves the product with a sku must leave it with an inventory row, or the - * restock endpoint below 409s NO_INVENTORY_ROW forever. */ - inventoryStore: InventoryStore; - /** Payment gateways keyed by method (ADR-0008) — the refund endpoint selects - * the order's gateway to issue (Stripe) or record-only (x402/no-secret). The - * gateway's `refundable` flag drives the admin capability display. */ - gateways: Partial>; - /** The loud-anomaly seam for the impossible-by-construction "gateway refund - * issued but its reserved ledger row could not be finalized" residual - * (ADR-0008, REFUND_UNRECORDED — the PAID_FLIP_LOST precedent). Wired so that - * residual records an anomaly carrying the provider refundRef; the refund flow - * also flags the order for reconciliation. */ - paymentEventStore?: PaymentEventStore; - /** Timestamp source for the anomaly record (paired with `paymentEventStore`). */ - clock?: Clock; - /** Reuses the existing service privileged auth (X-Internal-Token). Phase 5 - * introduces no separate admin identity (Risk 7): the internal token is the - * service's privileged mechanism; a real admin panel calls this with it. */ - internalToken?: string; -} - -/** - * Admin order-status transition (Phase 5 §7). The only customer-facing surface - * that can move an order is NONE — this endpoint requires the privileged - * (internal-token) auth. Legality is enforced in the domain (`transitionOrder`); - * a transition that also has a template enqueues exactly one email atomically - * with the flip (§5), drained by the dispatcher. - */ -export function adminRoutes(deps: AdminRoutesDeps): Hono { - const app = new Hono(); - - // Every handler below carries its own `requireInternalToken` call. Those stay - // as defense-in-depth, but they are no longer the fail-safe: the parent-level - // `app.use("/admin/*")` in `createApp` (ADR-0010) closes this whole prefix, so - // a route added here without an inline call is denied rather than silently - // public. - - // -- Admin Orders console: view-only list + detail (internal-token guarded) -- - - app.get("/orders", async (c) => { - const denied = requireInternalToken(c, deps.internalToken); - if (denied !== null) return denied; - - const rawQuery = c.req.query(); - const parsed = ordersListQuery.safeParse(rawQuery); - if (!parsed.success) - return c.json({ error: "invalid query", issues: parsed.error.issues }, 400); - const q = parsed.data; - - let filter: OrderListFilter; - let limit: number; - let cursorPos: OrderListCursor | null; - - if (q.cursor !== undefined) { - // Paged request: the opaque cursor carries the keyset POSITION plus the - // active filter (so filters survive paging) plus the page limit. Decoding - // MUST fail CLOSED to a 400 — a malformed/tampered/garbage token never - // 500s (MOD-1). The decoded filter is RE-VALIDATED through zod and the - // decoded limit RE-CLAMPED server-side (never trusted past max=100). - const decoded = decodeCursor(q.cursor); - if (decoded === null) return c.json({ error: "invalid cursor" }, 400); - const filterParsed = orderListFilterSchema.safeParse(decoded.filter); - const posParsed = cursorPosOf(decoded.pos); - if (!filterParsed.success || posParsed === null) { - return c.json({ error: "invalid cursor" }, 400); - } - filter = toFilter(filterParsed.data); - cursorPos = posParsed; - limit = clampLimit(decoded.limit, q.limit); - - // The token is authoritative for paging — but a request may ALSO spell - // the filter out beside it, and then the two can CONTRADICT each other. - // Resolving that silently in the token's favour is the defect: the - // request claims one predicate while the rows answer another, and - // nothing in the response says so. So a disagreement fails CLOSED. - // ABSENT params claim nothing — the cursor-alone request every client - // sends today is untouched. - if (hasOrderFilterParams(q)) { - const claimed = buildFilterFromQuery(q.states, q.from, q.to, q.search); - if (claimed === null) return c.json({ error: "invalid states filter" }, 400); - if (canonicalFilter(claimed) !== canonicalFilter(filter)) { - return c.json({ error: "cursor filter mismatch" }, 400); - } - } - // The page size rides in the token too, and disagrees the same way: a - // request asking for 50 rows while the token says 25 is the same - // contradiction in a different field. It shares the one mismatch code - // because it has the one remedy — drop the cursor and re-issue the first - // page from the parameters — and splitting it would buy a client a - // distinction it cannot act on differently. - // - // Compared against the EFFECTIVE limit, which is what the page will - // actually be. `clampLimit` prefers the token's value whenever it is a - // FINITE number and clamps it into range; the query's value is honored - // ONLY when the token's is missing or non-finite. So a token carrying - // 999_999 pages at 100 and a `?limit=50` beside it is a real - // disagreement (400), while a token carrying nothing usable pages at - // exactly the query's limit and agrees with it. - if (rawQuery.limit !== undefined && q.limit !== limit) { - return c.json({ error: "cursor filter mismatch" }, 400); - } - } else { - // First page: build the filter from the query string (CSV states → - // per-token validated enum array), validate the assembled filter, and take - // the already-clamped query limit. - const built = buildFilterFromQuery(q.states, q.from, q.to, q.search); - if (built === null) return c.json({ error: "invalid states filter" }, 400); - filter = built; - cursorPos = null; - limit = q.limit; - } - - // The page and its EXACT count, under one filter, in parallel (INC-23). - // `total` is the count of the whole filtered set — not of this page — so a - // console can caption "17 orders" on page 2 of 3 instead of the - // page-scoped hedge keyset paging otherwise forces (there is no running - // offset to derive one from, and a renderer must never invent one). - const [result, total] = await Promise.all([ - deps.orderStore.listOrders(filter, { cursor: cursorPos, limit }), - deps.orderStore.countOrders(filter), - ]); - const nextCursor = - result.nextCursor === null ? null : encodeCursor(result.nextCursor, filter, limit); - return c.json( - { ok: true, orders: result.orders.map(serializeOrderSummary), nextCursor, total }, - 200, - ); - }); - - app.get("/orders/:orderId", async (c) => { - const denied = requireInternalToken(c, deps.internalToken); - if (denied !== null) return denied; - - const params = orderPathParams.safeParse(c.req.param()); - if (!params.success) return c.json({ error: "invalid path parameter" }, 400); - const order = await deps.orderStore.getById(toOrderId(params.data.orderId)); - if (order === null) return c.json({ ok: false, reason: "ORDER_NOT_FOUND" }, 404); - // The transition buttons the console renders come straight from the domain - // state machine — the single source of truth (never a UI-side re-listing). - return c.json( - { - ok: true, - order: serializeOrder(order), - allowedTransitions: [...legalNextStates(order.state)], - }, - 200, - ); - }); - - // -- Admin Products console: view-only list + detail (admin-UX Increment 2) -- - // Mirrors the Orders console's shape 1:1 (internal-token guarded reads, the - // same opaque-cursor-carries-filter-and-limit encoding, MOD-1 fail-closed - // decode). The list carries per-row stock via the store's SINGLE LEFT JOIN — - // one statement per page, never an N+1 into stock per row (port doc) — where - // `onHand: null` means "no inventory row" (unknown), NOT zero. The detail - // leaf still reads the ONE opened product's stock via `InventoryStore. - // getOnHand`. - - app.get("/products", async (c) => { - const denied = requireInternalToken(c, deps.internalToken); - if (denied !== null) return denied; - - const rawQuery = c.req.query(); - const parsed = productsListQuery.safeParse(rawQuery); - if (!parsed.success) - return c.json({ error: "invalid query", issues: parsed.error.issues }, 400); - const q = parsed.data; - - let filter: ProductListFilter; - let limit: number; - let cursorPos: ProductListCursor | null; - - if (q.cursor !== undefined) { - // Paged request: the opaque cursor carries the keyset POSITION plus the - // active filter (so filters survive paging) plus the page limit. Decoding - // MUST fail CLOSED to a 400 — a malformed/tampered/garbage token never - // 500s (MOD-1, mirrors the Orders list). The decoded filter is - // RE-VALIDATED through zod and the decoded limit RE-CLAMPED server-side - // (never trusted past max=100). - const decoded = decodeProductCursor(q.cursor); - if (decoded === null) return c.json({ error: "invalid cursor" }, 400); - const filterParsed = productListFilterSchema.safeParse(decoded.filter); - const posParsed = productCursorPosOf(decoded.pos); - if (!filterParsed.success || posParsed === null) { - return c.json({ error: "invalid cursor" }, 400); - } - filter = toProductFilter(filterParsed.data); - cursorPos = posParsed; - limit = clampLimit(decoded.limit, q.limit); - - // Fails CLOSED on a cursor that disagrees with the query's own filter or - // limit — see the Orders list above for why, of which this is the exact - // mirror. `lowStockThreshold` is one of the axes compared, because it is - // one of the axes the token carries. - if (hasProductFilterParams(q)) { - if (canonicalFilter(buildProductFilterFromQuery(q)) !== canonicalFilter(filter)) { - return c.json({ error: "cursor filter mismatch" }, 400); - } - } - if (rawQuery.limit !== undefined && q.limit !== limit) { - return c.json({ error: "cursor filter mismatch" }, 400); - } - } else { - filter = buildProductFilterFromQuery(q); - cursorPos = null; - limit = q.limit; - } - - try { - // The page and its EXACT count, under one filter, in parallel (INC-23) — - // the same shape as the Orders list above; see its note. Sharing the - // filter is what lets the count describe the low-stock-filtered page - // once `filter.lowStockThreshold` is set (port doc). - const [result, total] = await Promise.all([ - deps.productCommerce.listProducts(filter, { cursor: cursorPos, limit }), - deps.productCommerce.countProducts(filter), - ]); - const nextCursor = - result.nextCursor === null ? null : encodeProductCursor(result.nextCursor, filter, limit); - return c.json( - { ok: true, products: result.products.map(serializeProductSummary), nextCursor, total }, - 200, - ); - } catch (err) { - // Defense-in-depth (port doc): both zod layers above already constrain - // `lowStockThreshold` to a non-negative integer, so this is normally - // unreachable — but an unmapped throw here would 500 a bad query - // instead of 400ing it, exactly the asymmetry the port doc calls out. - if (err instanceof InvalidLowStockThresholdError) { - return c.json({ error: "invalid query", issues: [{ message: err.message }] }, 400); - } - throw err; - } - }); - - app.get("/products/:productId", async (c) => { - const denied = requireInternalToken(c, deps.internalToken); - if (denied !== null) return denied; - - const params = productPathParams.safeParse(c.req.param()); - if (!params.success) return c.json({ error: "invalid path parameter" }, 400); - const product = await deps.productCommerce.getByProductId(toProductId(params.data.productId)); - if (product === null) { - // UNKNOWN id — genuinely never existed. Distinct from a soft-deleted - // row (see below): the two used to read identically as 404, which hid - // the tombstone from the admin (product lifecycle surfacing, port doc). - return c.json({ ok: false, reason: "PRODUCT_NOT_FOUND" }, 404); - } - // A single-sku read — never a per-row list join (port doc) — through - // `findOnHand`, which keeps "no inventory row" (`null`, unknown) apart from - // `0` ("out of stock") exactly as the LIST does. Both halves used to - // collapse to `0` here (`?? 0` inside `getOnHand`, plus a `sku === null ? 0` - // on this line), so one product could read `—` in the list and `0` on its - // own detail page, one click apart — and a detail view is the screen with - // the most context, so it is the last place that should be the one guessing. - // A skuless "create then price" row has nothing to look up at all: `null`, - // never a zero nobody counted. - // - // A soft-deleted row still resolves here (200, `deletedAt` non-null) — the - // detail is the HONEST read-only tombstone view, not a 404 masquerading as - // "never existed" (product lifecycle surfacing). Its `onHand` is read for - // informational value only; the write routes below (PATCH/restock/ - // remove-stock) remain blocked for a deleted row via their OWN not_found - // guards (`updateCommerceFields`'s guard order / `resolveProductSku`) — - // this GET is visibility only, never a path back to editability. - const onHand = product.sku === null ? null : await deps.inventoryStore.findOnHand(product.sku); - return c.json({ ok: true, product: serializeProductDetail(product, onHand) }, 200); - }); - - // -- Admin Products console: guarded commerce EDIT (admin-UX Increment 2) ----- - // The standalone product edit page's write (slice 2). A NON-GET, so the - // app-level X-Service-Token write gate covers it when the service secret is - // set; the route additionally requires the internal token (same double-gate as - // the order-transition write). Edits only the commerce-owned fields — never the - // CMS publish gate (`active`) or the sync watermark. Optimistic-concurrency: - // `expectedUpdatedAt` compare-and-set → a concurrent edit is a structured 409 - // STALE_EDIT the panel reloads on, never a silent last-writer-wins clobber. - app.patch("/products/:productId", async (c) => { - const denied = requireInternalToken(c, deps.internalToken); - if (denied !== null) return denied; - - const params = productPathParams.safeParse(c.req.param()); - if (!params.success) return c.json({ error: "invalid path parameter" }, 400); - const parsed = editProductCommerceBody.safeParse(await readJson(c)); - if (!parsed.success) { - return c.json({ error: "invalid request body", issues: parsed.error.issues }, 400); - } - const body = parsed.data; - - // A retry/double-submit dedupes when the client sends a stable - // Idempotency-Key; absent one, a deterministic fallback keyed by the target - // + the expected watermark keeps replays of THIS edit idempotent (a genuine - // second edit carries a fresher watermark ⇒ a distinct fallback key). - const header = c.req.header("Idempotency-Key"); - const key = - header !== undefined && header.length > 0 - ? header - : `admin:product-edit:${params.data.productId}:${body.expectedUpdatedAt}`; - - try { - const res = await updateProductCommerceFields( - { productCommerce: deps.productCommerce, inventory: deps.inventoryStore }, - { - productId: toProductId(params.data.productId), - ...(body.sku !== undefined ? { sku: toSku(body.sku) } : {}), - ...(body.price !== undefined - ? { price: toMoney(toCents(body.price.amount), toCurrency(body.price.currency)) } - : {}), - // No `title`: it is CMS-owned and the schema `.strict()`-rejects one - // (ADR-0013). The sync writes it via PUT /products/:id/commerce. - ...(body.taxClass !== undefined ? { taxClass: body.taxClass } : {}), - ...(body.compareAtPrice !== undefined - ? { - compareAtPrice: - body.compareAtPrice === null - ? null - : toMoney( - toCents(body.compareAtPrice.amount), - toCurrency(body.compareAtPrice.currency), - ), - } - : {}), - ...(body.unitCost !== undefined - ? { - unitCost: - body.unitCost === null - ? null - : toMoney(toCents(body.unitCost.amount), toCurrency(body.unitCost.currency)), - } - : {}), - ...(body.inventoryPolicy !== undefined ? { inventoryPolicy: body.inventoryPolicy } : {}), - ...(body.weightGrams !== undefined ? { weightGrams: body.weightGrams } : {}), - ...(body.lengthMm !== undefined ? { lengthMm: body.lengthMm } : {}), - ...(body.widthMm !== undefined ? { widthMm: body.widthMm } : {}), - ...(body.heightMm !== undefined ? { heightMm: body.heightMm } : {}), - ...(body.productKind !== undefined ? { productKind: body.productKind } : {}), - }, - toIdempotencyKey(key), - body.expectedUpdatedAt, - ); - if (res.ok) { - return c.json({ ok: true, updatedAt: res.product.updatedAt.toISOString() }, 200); - } - if (res.reason === "not_found") { - return c.json({ ok: false, reason: "PRODUCT_NOT_FOUND" }, 404); - } - if (res.reason === "stale") { - // The panel reloads the fresh detail; hand back the current watermark so - // a re-save can compare-and-set against it. - return c.json( - { - ok: false, - reason: "STALE_EDIT", - currentUpdatedAt: res.current.updatedAt.toISOString(), - }, - 409, - ); - } - // currency_mismatch - return c.json( - { - ok: false, - reason: "CURRENCY_MISMATCH", - currency: res.current.price?.currency ?? null, - }, - 409, - ); - } catch (err) { - if (err instanceof InvalidProductFieldError) { - return c.json({ ok: false, reason: "INVALID_FIELD", field: err.field }, 400); - } - if (err instanceof SkuConflictError) { - return c.json({ ok: false, reason: "SKU_TAKEN", sku: err.sku }, 409); - } - // The two RENAME refusals, in the same 409 shape as the collision above: - // a machine `reason` plus the operands an operator has to act on — the - // two skus, or the sku and how many holds still name it. The domain's own - // sentence stays server-side; the console composes the operator's copy - // from these fields, so there is exactly one place it is written. - if (err instanceof SkuStockConflictError) { - return c.json( - { ok: false, reason: "SKU_STOCK_CONFLICT", fromSku: err.fromSku, toSku: err.toSku }, - 409, - ); - } - if (err instanceof SkuHeldStockError) { - return c.json( - { ok: false, reason: "SKU_HELD_STOCK", sku: err.sku, liveHolds: err.liveHolds }, - 409, - ); - } - throw err; - } - }); - - // -- Admin Products console: merchant restock / stock removal (Increment 2) -- - // The invariant-critical stock-movement writes. NON-GETs (app-level - // X-Service-Token write gate covers them when the secret is set) that ALSO - // require the internal token — the same double-gate as the product edit. Each - // resolves the productId to its AUTHORITATIVE sku (never trusting a - // client-supplied one) and mirrors the port 1:1: restock is a commutative - // oversell-safe increment; remove-stock is a guarded decrement that can never - // drive on-hand below 0. Because a restock is ADDITIVE (not idempotent by - // nature like a state flip), the `Idempotency-Key` header is REQUIRED — there - // is no safe content-only fallback (two deliberate "+5" restocks must NOT - // collapse), so the plugin sends a stable per-submission key. - - app.post("/products/:productId/restock", async (c) => { - const denied = requireInternalToken(c, deps.internalToken); - if (denied !== null) return denied; - - const params = productPathParams.safeParse(c.req.param()); - if (!params.success) return c.json({ error: "invalid path parameter" }, 400); - const parsed = stockMovementBody.safeParse(await readJson(c)); - if (!parsed.success) { - return c.json({ error: "invalid request body", issues: parsed.error.issues }, 400); - } - const key = c.req.header("Idempotency-Key"); - if (key === undefined || key.length === 0) { - return c.json({ ok: false, reason: "MISSING_IDEMPOTENCY_KEY" }, 400); - } - - const skuResolved = await resolveProductSku(deps, params.data.productId); - if (skuResolved.status === "not_found") { - return c.json({ ok: false, reason: "PRODUCT_NOT_FOUND" }, 404); - } - if (skuResolved.status === "no_sku") return c.json({ ok: false, reason: "NO_SKU" }, 409); - - const res = await restock( - deps.inventoryStore, - toSku(skuResolved.sku), - parsed.data.qty, - toIdempotencyKey(key), - ); - if (res.ok) return c.json({ ok: true, onHand: res.onHand }, 200); - // UNKNOWN_SKU: the product exists but has no inventory row yet (priced but - // never seeded). A stock movement cannot create one — 409, like a - // conflict-with-current-state. - return c.json({ ok: false, reason: "NO_INVENTORY_ROW" }, 409); - }); - - app.post("/products/:productId/remove-stock", async (c) => { - const denied = requireInternalToken(c, deps.internalToken); - if (denied !== null) return denied; - - const params = productPathParams.safeParse(c.req.param()); - if (!params.success) return c.json({ error: "invalid path parameter" }, 400); - const parsed = stockMovementBody.safeParse(await readJson(c)); - if (!parsed.success) { - return c.json({ error: "invalid request body", issues: parsed.error.issues }, 400); - } - const key = c.req.header("Idempotency-Key"); - if (key === undefined || key.length === 0) { - return c.json({ ok: false, reason: "MISSING_IDEMPOTENCY_KEY" }, 400); - } - - const skuResolved = await resolveProductSku(deps, params.data.productId); - if (skuResolved.status === "not_found") { - return c.json({ ok: false, reason: "PRODUCT_NOT_FOUND" }, 404); - } - if (skuResolved.status === "no_sku") return c.json({ ok: false, reason: "NO_SKU" }, 409); - - const res = await removeStock( - deps.inventoryStore, - toSku(skuResolved.sku), - parsed.data.qty, - toIdempotencyKey(key), - ); - if (res.ok) return c.json({ ok: true, onHand: res.onHand }, 200); - if (res.reason === "INSUFFICIENT_STOCK") { - // Cannot remove more than is on hand — 409 with the current count so the - // panel can show it. Guarded in the domain (never drives on_hand < 0). - return c.json({ ok: false, reason: "INSUFFICIENT_STOCK", onHand: res.onHand }, 409); - } - return c.json({ ok: false, reason: "NO_INVENTORY_ROW" }, 409); // UNKNOWN_SKU - }); - - // -- Admin Orders console: customer context (admin-UX Increment 1) ----------- - // Read-only, internal-token guarded like the other admin GETs; mirrors the - // `getOrderCustomerContext` use-case 1:1. The response aggregates PII (email, - // address book, session metadata) — it is NEVER logged; failures reach the - // app-level onError which logs only the thrown error, not this body. - app.get("/orders/:orderId/customer-context", async (c) => { - const denied = requireInternalToken(c, deps.internalToken); - if (denied !== null) return denied; - - const params = orderPathParams.safeParse(c.req.param()); - if (!params.success) return c.json({ error: "invalid path parameter" }, 400); - const context = await getOrderCustomerContext( - { - orderStore: deps.orderStore, - customerStore: deps.customerStore, - addressStore: deps.addressStore, - sessionStore: deps.sessionStore, - }, - toOrderId(params.data.orderId), - ); - if (context === null) return c.json({ ok: false, reason: "ORDER_NOT_FOUND" }, 404); - return c.json({ ok: true, context: serializeCustomerContext(context) }, 200); - }); - - // -- Admin Orders console: order timeline / audit (admin-UX Increment 1) ----- - // Read-only, internal-token guarded like the other admin GETs; mirrors the - // `getOrderTimeline` use-case 1:1. Merges the durably-audited state-change - // events with the order's derived artifacts (creation, notes, fulfillment, - // cancellation, reconciliation resolution) into ONE chronological view. It - // surfaces no money and no PII beyond what the order detail + notes already - // show; `stateChangesAudited` flags a historical order whose transitions - // predate the audit table (a partial timeline). - app.get("/orders/:orderId/timeline", async (c) => { - const denied = requireInternalToken(c, deps.internalToken); - if (denied !== null) return denied; - - const params = orderPathParams.safeParse(c.req.param()); - if (!params.success) return c.json({ error: "invalid path parameter" }, 400); - const timeline = await getOrderTimeline( - { orderStore: deps.orderStore, orderNotesStore: deps.orderNotesStore }, - toOrderId(params.data.orderId), - ); - if (timeline === null) return c.json({ ok: false, reason: "ORDER_NOT_FOUND" }, 404); - return c.json({ ok: true, timeline: serializeTimeline(timeline) }, 200); - }); - - app.post("/orders/:orderId/transition", async (c) => { - const denied = requireInternalToken(c, deps.internalToken); - if (denied !== null) return denied; - - const params = orderPathParams.safeParse(c.req.param()); - if (!params.success) return c.json({ error: "invalid path parameter" }, 400); - const parsed = transitionBody.safeParse(await readJson(c)); - if (!parsed.success) return c.json({ error: "invalid request body" }, 400); - - const header = c.req.header("Idempotency-Key"); - const key = - header !== undefined && header.length > 0 - ? header - : `admin:transition:${params.data.orderId}:${parsed.data.toState}`; - const res = await transitionOrder( - { orderStore: deps.orderStore }, - { - orderId: toOrderId(params.data.orderId), - toState: parsed.data.toState, - idempotencyKey: toIdempotencyKey(key), - }, - ); - if (res.ok) { - return c.json( - { ok: true, transitioned: res.transitioned, order: serializeOrder(res.order) }, - 200, - ); - } - if (res.reason === "ORDER_NOT_FOUND") return c.json({ ok: false, reason: res.reason }, 404); - return c.json({ ok: false, reason: res.reason }, 409); // INVALID_TRANSITION - }); - - // -- Admin Orders console: resolve a reconciliation flag (admin-UX Increment 1) - // A NON-GET, so the app-level X-Service-Token write gate covers it when the - // service secret is set; the route additionally requires the internal token. - // Mirrors the port 1:1 — clears the flag + records the disposition, never - // touching state/line items (the snapshot invariant lives in the domain). - app.post("/orders/:orderId/resolve-reconciliation", async (c) => { - const denied = requireInternalToken(c, deps.internalToken); - if (denied !== null) return denied; - - const params = orderPathParams.safeParse(c.req.param()); - if (!params.success) return c.json({ error: "invalid path parameter" }, 400); - const parsed = resolveReconciliationBody.safeParse(await readJson(c)); - if (!parsed.success) return c.json({ error: "invalid request body" }, 400); - - // Idempotency (CLAUDE.md): the client `Idempotency-Key` header, or a stable - // fallback derived from the order id (a header-less double-submit dedupes on - // the guarded flip, not a fresh key each time). - const header = c.req.header("Idempotency-Key"); - const key = - header !== undefined && header.length > 0 - ? header - : `admin:resolve-reconciliation:${params.data.orderId}`; - const res = await resolveReconciliation( - { orderStore: deps.orderStore }, - { - orderId: toOrderId(params.data.orderId), - expectedFlag: parsed.data.expectedFlag, - outcome: parsed.data.outcome, - reason: parsed.data.reason, - resolvedBy: parsed.data.resolvedBy, - idempotencyKey: toIdempotencyKey(key), - }, - ); - if (res.ok) { - return c.json({ ok: true, resolved: res.resolved, order: serializeOrder(res.order) }, 200); - } - if (res.reason === "ORDER_NOT_FOUND") return c.json({ ok: false, reason: res.reason }, 404); - // Reconciliation-axis conflicts (like an INVALID_TRANSITION) → 409: - // NOT_IN_RECONCILIATION (never flagged) and RECONCILIATION_FLAG_CHANGED - // (the live flag differs from the one the admin reviewed — reload and - // re-review). The trimmed-empty guards → 400. - if (res.reason === "NOT_IN_RECONCILIATION" || res.reason === "RECONCILIATION_FLAG_CHANGED") - return c.json({ ok: false, reason: res.reason }, 409); - return c.json({ ok: false, reason: res.reason }, 400); // EMPTY_REASON / EMPTY_RESOLVER - }); - - // -- Admin Orders console: record shipping fulfillment (admin-UX Increment 1) - - // A NON-GET, so the app-level X-Service-Token write gate covers it when the - // service secret is set; the route additionally requires the internal token. - // Mirrors the port 1:1 — recording fulfillment ships the order - // (`processing → shipped`) and enqueues the shipped email (now carrying - // tracking), atomically. Legality (must be `processing`) lives in the domain. - app.post("/orders/:orderId/fulfillment", async (c) => { - const denied = requireInternalToken(c, deps.internalToken); - if (denied !== null) return denied; - - const params = orderPathParams.safeParse(c.req.param()); - if (!params.success) return c.json({ error: "invalid path parameter" }, 400); - const parsed = recordFulfillmentBody.safeParse(await readJson(c)); - if (!parsed.success) return c.json({ error: "invalid request body" }, 400); - - // Idempotency (CLAUDE.md): the client `Idempotency-Key` header, or a stable - // fallback derived from the order id (a header-less double-submit dedupes on - // the guarded flip, not a fresh key each time). - const header = c.req.header("Idempotency-Key"); - const key = - header !== undefined && header.length > 0 - ? header - : `admin:fulfillment:${params.data.orderId}`; - const res = await recordFulfillment( - { orderStore: deps.orderStore }, - { - orderId: toOrderId(params.data.orderId), - carrier: parsed.data.carrier, - trackingNumber: parsed.data.trackingNumber, - trackingUrl: parsed.data.trackingUrl ?? null, - shippedAt: parsed.data.shippedAt ?? null, - recordedBy: parsed.data.recordedBy, - idempotencyKey: toIdempotencyKey(key), - }, - ); - if (res.ok) { - return c.json({ ok: true, recorded: res.recorded, order: serializeOrder(res.order) }, 200); - } - if (res.reason === "ORDER_NOT_FOUND") return c.json({ ok: false, reason: res.reason }, 404); - // NOT_FULFILLABLE (the order is not in `processing`) → 409, like an - // INVALID_TRANSITION; the trimmed-empty guards → 400. - if (res.reason === "NOT_FULFILLABLE") return c.json({ ok: false, reason: res.reason }, 409); - return c.json({ ok: false, reason: res.reason }, 400); // EMPTY_CARRIER / _TRACKING_NUMBER / _RECORDER - }); - - // -- Admin Orders console: cancel an order WITH a structured reason ---------- - // (admin-UX Increment 1, "cancel with reason"). A NON-GET, so the app-level - // X-Service-Token write gate covers it when the service secret is set; the - // route additionally requires the internal token. Mirrors the port 1:1 — - // cancelling records the reason envelope AND drives the - // {pending,paid,processing} → cancelled transition AND enqueues the cancelled - // email, atomically. Legality (which states may cancel) lives in the domain, - // derived from the ONE state machine. The bare `POST .../transition {toState: - // "cancelled"}` above remains available for other callers/back-compat — a - // cancellation via that path carries no reason. - app.post("/orders/:orderId/cancel", async (c) => { - const denied = requireInternalToken(c, deps.internalToken); - if (denied !== null) return denied; - - const params = orderPathParams.safeParse(c.req.param()); - if (!params.success) return c.json({ error: "invalid path parameter" }, 400); - const parsed = cancelOrderBody.safeParse(await readJson(c)); - if (!parsed.success) return c.json({ error: "invalid request body" }, 400); - - // Idempotency (CLAUDE.md): the client `Idempotency-Key` header, or a stable - // fallback derived from the order id (a header-less double-submit dedupes on - // the guarded flip, not a fresh key each time). - const header = c.req.header("Idempotency-Key"); - const key = - header !== undefined && header.length > 0 ? header : `admin:cancel:${params.data.orderId}`; - const res = await cancelOrder( - { orderStore: deps.orderStore }, - { - orderId: toOrderId(params.data.orderId), - reason: parsed.data.reason, - detail: parsed.data.detail ?? null, - cancelledBy: parsed.data.cancelledBy, - idempotencyKey: toIdempotencyKey(key), - }, - ); - if (res.ok) { - return c.json({ ok: true, cancelled: res.cancelled, order: serializeOrder(res.order) }, 200); - } - if (res.reason === "ORDER_NOT_FOUND") return c.json({ ok: false, reason: res.reason }, 404); - // NOT_CANCELLABLE (the order's state cannot legally reach `cancelled`, or it - // was already cancelled without a reason on file) → 409, like an - // INVALID_TRANSITION/NOT_FULFILLABLE; the trimmed-empty guard → 400. - if (res.reason === "NOT_CANCELLABLE") return c.json({ ok: false, reason: res.reason }, 409); - return c.json({ ok: false, reason: res.reason }, 400); // EMPTY_CANCELLED_BY - }); - - // -- Admin Orders console: refunds (ADR-0008) -------------------------------- - // GET is internal-token guarded (a read): the ledger + the derived - // ceiling/remaining + the gateway's honest `refundable` capability, so the - // panel can show the right action (Stripe refund vs record-a-manual-refund) - // and the remaining-refundable amount. POST issues/records a refund — a - // NON-GET, so the app-level X-Service-Token write gate covers it too; it - // mirrors the `refundOrder` use-case 1:1 (ceiling + capability + gateway error - // taxonomy all live in the domain/adapter). - - app.get("/orders/:orderId/refunds", async (c) => { - const denied = requireInternalToken(c, deps.internalToken); - if (denied !== null) return denied; - - const params = orderPathParams.safeParse(c.req.param()); - if (!params.success) return c.json({ error: "invalid path parameter" }, 400); - const oid = toOrderId(params.data.orderId); - const order = await deps.orderStore.getById(oid); - if (order === null) return c.json({ ok: false, reason: "ORDER_NOT_FOUND" }, 404); - - const [payments, refunds] = await Promise.all([ - deps.orderStore.getCapturedPayments(oid), - deps.orderStore.listRefunds(oid), - ]); - const capturedTotal = sumCapturedPayments(payments); - const ceiling = computeRefundCeiling(capturedTotal, order.totals.total); - const refundedTotal = sumRefunds(refunds); - const remaining = Math.max(0, ceiling - refundedTotal); - // The gateway's HONEST capability (ADR-0008): `refundable` true ⇒ money moves - // via the provider; false (x402, or Stripe with no secretKey) ⇒ the admin - // records a manual/off-platform refund. Never a button that silently no-ops. - const gateway = order.paymentMethod === null ? undefined : deps.gateways[order.paymentMethod]; - return c.json( - { - ok: true, - refunds: refunds.map(serializeRefund), - currency: order.totals.currency, - capturedTotalCents: capturedTotal, - refundedTotalCents: refundedTotal, - ceilingCents: ceiling, - remainingCents: remaining, - paymentMethod: order.paymentMethod, - refundable: gateway?.refundable ?? false, - }, - 200, - ); - }); - - app.post("/orders/:orderId/refund", async (c) => { - const denied = requireInternalToken(c, deps.internalToken); - if (denied !== null) return denied; - - const params = orderPathParams.safeParse(c.req.param()); - if (!params.success) return c.json({ error: "invalid path parameter" }, 400); - const parsed = refundOrderBody.safeParse(await readJson(c)); - if (!parsed.success) return c.json({ error: "invalid request body" }, 400); - - // A refund is ADDITIVE (not idempotent by nature like a state flip), so the - // `Idempotency-Key` header is REQUIRED — two deliberate refunds must not - // collapse, and there is no safe content-only fallback (mirrors restock). - const key = c.req.header("Idempotency-Key"); - if (key === undefined || key.length === 0) { - return c.json({ ok: false, reason: "MISSING_IDEMPOTENCY_KEY" }, 400); - } - - const oid = toOrderId(params.data.orderId); - const order = await deps.orderStore.getById(oid); - if (order === null) return c.json({ ok: false, reason: "ORDER_NOT_FOUND" }, 404); - const gateway = order.paymentMethod === null ? undefined : deps.gateways[order.paymentMethod]; - if (gateway === undefined) { - // No gateway wired for the order's method — cannot even record a refund - // against it (the domain needs a gateway to declare capability). - return c.json({ ok: false, reason: "REFUND_GATEWAY_UNAVAILABLE" }, 409); - } - - const res = await refundOrder( - { - orderStore: deps.orderStore, - ...(deps.paymentEventStore !== undefined - ? { paymentEventStore: deps.paymentEventStore } - : {}), - ...(deps.clock !== undefined ? { clock: deps.clock } : {}), - }, - gateway, - { - orderId: oid, - amount: toCents(parsed.data.amountCents), - currency: toCurrency(parsed.data.currency), - reason: parsed.data.reason ?? null, - refundedBy: parsed.data.refundedBy, - idempotencyKey: toIdempotencyKey(key), - }, - ); - if (res.ok) { - return c.json( - { - ok: true, - recorded: res.recorded, - duplicate: res.duplicate, - fullyRefunded: res.fullyRefunded, - refund: serializeRefund(res.refund), - order: serializeOrder(res.order), - }, - 200, - ); - } - return c.json({ ok: false, reason: res.reason }, refundFailureStatus(res.reason)); - }); - - // -- Admin Orders console: append-only order notes (admin-UX Increment 0) ---- - // GET is internal-token guarded (a read); POST is additionally covered by the - // app-level X-Service-Token write gate (any non-GET) when the service secret is - // set — no per-route gate is needed here. - - app.get("/orders/:orderId/notes", async (c) => { - const denied = requireInternalToken(c, deps.internalToken); - if (denied !== null) return denied; - - const params = orderPathParams.safeParse(c.req.param()); - if (!params.success) return c.json({ error: "invalid path parameter" }, 400); - const notes = await listOrderNotes( - { orderNotesStore: deps.orderNotesStore }, - toOrderId(params.data.orderId), - ); - return c.json({ ok: true, notes: notes.map(serializeNote) }, 200); - }); - - app.post("/orders/:orderId/notes", async (c) => { - const denied = requireInternalToken(c, deps.internalToken); - if (denied !== null) return denied; - - const params = orderPathParams.safeParse(c.req.param()); - if (!params.success) return c.json({ error: "invalid path parameter" }, 400); - const parsed = appendNoteBody.safeParse(await readJson(c)); - if (!parsed.success) return c.json({ error: "invalid request body" }, 400); - - // Idempotency (CLAUDE.md): the client `Idempotency-Key` header, or a fallback - // derived from the order id (so a header-less double-submit still dedupes on - // a stable key rather than always inserting). - const header = c.req.header("Idempotency-Key"); - const key = - header !== undefined && header.length > 0 - ? header - : `admin:note:${params.data.orderId}:${parsed.data.author}:${parsed.data.body}`; - const res = await appendOrderNote( - { orderNotesStore: deps.orderNotesStore, orderStore: deps.orderStore }, - { - orderId: toOrderId(params.data.orderId), - author: parsed.data.author, - body: parsed.data.body, - idempotencyKey: toIdempotencyKey(key), - }, - ); - if (res.ok) { - return c.json({ ok: true, appended: res.appended, note: serializeNote(res.note) }, 201); - } - if (res.reason === "ORDER_NOT_FOUND") return c.json({ ok: false, reason: res.reason }, 404); - return c.json({ ok: false, reason: res.reason }, 400); // EMPTY_AUTHOR / EMPTY_BODY - }); - - return app; -} - -/** Wire shape of the order customer context (admin-UX Increment 1). Mirrors the - * domain shape 1:1: identity + linkage, the profile address book (NOT a - * per-order shipping snapshot — none exists in this domain), TOKEN-FREE - * session summaries, and the union-keyed order aggregates (`recentOrders` - * reuses the admin-list summary wire shape). */ -function serializeCustomerContext(context: OrderCustomerContext): Record { - return { - identity: { - customerId: context.identity.customerId, - buyerRef: context.identity.buyerRef, - email: context.identity.email, - displayName: context.identity.displayName, - emailVerifiedAt: context.identity.emailVerifiedAt, - linkage: context.identity.linkage, - }, - addresses: context.addresses.map((a) => ({ - id: a.id, - kind: a.kind, - name: a.name, - line1: a.line1, - line2: a.line2, - city: a.city, - region: a.region, - postalCode: a.postalCode, - country: a.country, - isDefault: a.isDefault, - createdAt: a.createdAt, - })), - sessions: context.sessions.map((s) => ({ - id: s.id, - createdAt: s.createdAt, - expiresAt: s.expiresAt, - revokedAt: s.revokedAt, - })), - orderCount: context.orderCount, - recentOrders: context.recentOrders.map(serializeOrderSummary), - }; -} - -/** Wire shape of the order timeline (admin-UX Increment 1, timeline slice). - * Mirrors the domain shape 1:1: a chronological list of discriminated entries - * (each `at` + `kind` + the kind's fields) plus `stateChangesAudited` (false ⇒ - * the order's transitions predate the audit table, so the state-change history - * is partial). Each entry is a plain structured record — the plugin renders it; - * no presentation strings on the wire. */ -function serializeTimeline(timeline: OrderTimeline): Record { - return { - orderId: timeline.orderId, - stateChangesAudited: timeline.stateChangesAudited, - entries: timeline.entries.map((e) => ({ ...e })), - }; -} - -/** Wire shape of a refund row (ADR-0008). Money is an integer minor-unit - * `amountCents` + an ISO-4217 currency string — never a float. `kind` is - * 'gateway' (money moved via the provider, `refundRef` set) or 'manual' - * (out-of-band record, `refundRef` null). */ -function serializeRefund(refund: RefundRecord): Record { - return { - id: refund.id, - orderId: refund.orderId, - amountCents: refund.amount, - currency: refund.currency, - kind: refund.kind, - gateway: refund.gateway, - refundRef: refund.refundRef, - reason: refund.reason, - refundedBy: refund.refundedBy, - status: refund.status, - createdAt: refund.createdAt, - }; -} - -/** Map a refund failure to an HTTP status (ADR-0008). Malformed input → 400; - * ceiling / capability / provider-divergence conflicts → 409; a definite - * provider rejection → 502; a transient transport failure → 503; the ambiguous - * timeout → 409 (the caller must RE-CHECK before retrying, never auto-retry). */ -function refundFailureStatus(reason: RefundOrderFailure): 400 | 404 | 409 | 502 | 503 { - switch (reason) { - case "ORDER_NOT_FOUND": - return 404; - case "EMPTY_REFUNDED_BY": - case "INVALID_AMOUNT": - return 400; - case "CURRENCY_MISMATCH": - case "NO_CAPTURED_PAYMENT": - case "REFUND_EXCEEDS_CAPTURED": - case "REFUND_EXCEEDS_TOTAL": - case "PROVIDER_ALREADY_REFUNDED": - case "REFUND_NOT_SUPPORTED": - case "GATEWAY_UNVERIFIED": - // The loud residual (ADR-0008, reserve-before-issue): a gateway refund - // issued but its reserved ledger row could not be finalized. A DISTINCT 409 - // (its own `reason` on the wire) so it is never conflated with a clean - // pre-issuance rejection — the money moved, an anomaly + reconciliation flag - // were recorded, and the operator must reconcile (never auto-retry). - case "REFUND_ISSUED_UNRECORDED": - return 409; - case "GATEWAY_TERMINAL": - return 502; - case "GATEWAY_RETRYABLE": - return 503; - } -} - -/** Wire shape of an order note (admin-UX Increment 0). Plain annotation — no - * money, no branded ids leaked beyond the string id. */ -function serializeNote(note: OrderNote): Record { - return { - id: note.id, - orderId: note.orderId, - author: note.author, - body: note.body, - createdAt: note.createdAt, - }; -} - -/** Wire shape of an admin Products-list row (view-only projection; admin-UX - * Increment 2). Money stays an integer minor unit + an ISO-4217 currency - * string, null exactly like the stored row (a "create then price" product - * may have neither sku nor price yet). Carries `onHand` from the store's - * single LEFT JOIN — a COUNT, never money (no cents, no currency), and never - * an N+1 per row (port doc). */ -function serializeProductSummary(summary: ProductSummary): Record { - return { - productId: summary.productId, - sku: summary.sku, - title: summary.title, - priceCents: summary.price?.amount ?? null, - currency: summary.price?.currency ?? null, - productKind: summary.productKind, - active: summary.active, - // Passed through UNCOERCED: `null` ("no inventory row" — unknown) must - // reach the client AS null, distinct from `0` ("out of stock"). A `?? 0` - // here would invent an out-of-stock claim for every unsynced product. - onHand: summary.onHand, - deletedAt: summary.deletedAt, - createdAt: summary.createdAt, - }; -} - -/** Wire shape of the admin Product detail (view-only; admin-UX Increment 2) — - * the FULL `ProductCommerce` row plus the single-sku `onHand` read (never a - * list-level join). Money as integer minor units + ISO-4217, exactly like - * `serializeOrder`'s money fields. - * - * `onHand` is `number | null` with the LIST's semantics (INC-23): `null` is - * "no inventory row / no sku" — unknown — and `0` is a known sku that is out - * of stock. The two must never be folded into each other in either direction. */ -function serializeProductDetail( - product: ProductCommerce, - onHand: number | null, -): Record { - return { - productId: product.productId, - sku: product.sku, - title: product.title, - priceCents: product.price?.amount ?? null, - currency: product.price?.currency ?? null, - taxClass: product.taxClass, - // Increment 2 slice 5. This is the INTERNAL-TOKEN admin detail, so unit - // cost (admin-only margin data) is intentionally serialized HERE — and - // ONLY here (never on the public `GET /products/:id/commerce`, never on the - // catalog view). Compare-at + inventory policy round-trip alongside it. - compareAtCents: product.compareAtPrice?.amount ?? null, - compareAtCurrency: product.compareAtPrice?.currency ?? null, - unitCostCents: product.unitCost?.amount ?? null, - unitCostCurrency: product.unitCost?.currency ?? null, - inventoryPolicy: product.inventoryPolicy, - weightGrams: product.weightGrams, - lengthMm: product.lengthMm, - widthMm: product.widthMm, - heightMm: product.heightMm, - productKind: product.productKind, - active: product.active, - deletedAt: product.deletedAt === null ? null : product.deletedAt.toISOString(), - onHand, - createdAt: product.createdAt.toISOString(), - updatedAt: product.updatedAt.toISOString(), - }; -} - -/** Resolve an admin productId to its AUTHORITATIVE sku for a stock movement — - * never trusting a client-supplied sku. A missing/soft-deleted product ⇒ - * `not_found` (404, mirrors the product detail's not-found rule); a skuless - * "create then price" product ⇒ `no_sku` (409, nothing to move stock against - * yet). */ -async function resolveProductSku( - deps: AdminRoutesDeps, - productId: string, -): Promise<{ status: "ok"; sku: string } | { status: "not_found" } | { status: "no_sku" }> { - const product = await deps.productCommerce.getByProductId(toProductId(productId)); - if (product === null || product.deletedAt !== null) return { status: "not_found" }; - if (product.sku === null) return { status: "no_sku" }; - return { status: "ok", sku: product.sku }; -} - -async function readJson(c: { req: { json(): Promise } }): Promise { - try { - return await c.req.json(); - } catch { - return undefined; - } -} - -const MAX_LIMIT = 100; -const DEFAULT_LIMIT = 25; - -/** Clamp a page limit into [1, 100] (MOD-1: a decoded cursor's limit is - * RE-CLAMPED, never honored past the max). Falls back to the query limit, then - * the default, for a missing/garbage value. */ -function clampLimit(decoded: unknown, queryLimit: number): number { - const raw = - typeof decoded === "number" && Number.isFinite(decoded) - ? decoded - : Number.isFinite(queryLimit) - ? queryLimit - : DEFAULT_LIMIT; - return Math.min(Math.max(Math.trunc(raw), 1), MAX_LIMIT); -} - -/** Build a domain filter from raw query params. CSV `states` are split and each - * token validated against the shared enum — an unknown token ⇒ null (→ 400). */ -function buildFilterFromQuery( - states: string | undefined, - from: string | undefined, - to: string | undefined, - search: string | undefined, -): OrderListFilter | null { - const filter: OrderListFilter = {}; - if (states !== undefined) { - const tokens = states - .split(",") - .map((s) => s.trim()) - .filter((s) => s.length > 0); - const parsed: OrderState[] = []; - for (const t of tokens) { - const r = orderStateEnum.safeParse(t); - if (!r.success) return null; - parsed.push(r.data); - } - if (parsed.length > 0) filter.states = parsed; - } - if (from !== undefined) filter.from = from; - if (to !== undefined) filter.to = to; - if (search !== undefined) filter.search = search; - return filter; -} - -/** - * Every order-list query param that is a FILTER axis — i.e. every one except the - * two paging controls. Written as an exhaustive key map rather than a chain of - * `||`s so that adding an axis to `ordersListQuery` without teaching the - * presence check about it is a COMPILE error, not a silently unguarded axis that - * a cursor request could then contradict for free. - */ -const ORDER_FILTER_PARAMS = { - states: true, - from: true, - to: true, - search: true, -} satisfies Record, true>; - -/** Did the request SPELL OUT any order filter axis? Presence, not value — an - * absent param claims nothing, so a cursor-alone request is never compared - * against (and never 400s on) the filter its token carries. */ -function hasOrderFilterParams(q: OrdersListQuery): boolean { - return (Object.keys(ORDER_FILTER_PARAMS) as (keyof typeof ORDER_FILTER_PARAMS)[]).some( - (key) => q[key] !== undefined, - ); -} - -/** The product-list twin of `buildFilterFromQuery`: the domain filter a raw - * query string asks for. Used by BOTH the first-page arm and the cursor arm's - * agreement check, so the two can never normalize a filter differently. */ -function buildProductFilterFromQuery(q: ProductsListQuery): ProductListFilter { - return toProductFilter({ - active: q.active === undefined ? undefined : q.active === "true", - deleted: q.deleted === undefined ? undefined : q.deleted === "true", - productKind: q.productKind, - search: q.search, - lowStockThreshold: q.lowStockThreshold, - }); -} - -/** `ORDER_FILTER_PARAMS` for the product list — exhaustive for the same reason. */ -const PRODUCT_FILTER_PARAMS = { - active: true, - deleted: true, - productKind: true, - search: true, - lowStockThreshold: true, -} satisfies Record, true>; - -/** `hasOrderFilterParams` for the product list. */ -function hasProductFilterParams(q: ProductsListQuery): boolean { - return (Object.keys(PRODUCT_FILTER_PARAMS) as (keyof typeof PRODUCT_FILTER_PARAMS)[]).some( - (key) => q[key] !== undefined, - ); -} - -/** - * A list filter rendered so that two filters can be compared as PREDICATES, not - * as JSON text. - * - * Each side arrives already normalized, by a DIFFERENT route: the token's filter - * through `*ListFilterSchema` + `to*Filter` (decode, re-validate, drop the - * `undefined` keys), the query's through `buildFilterFromQuery` / - * `buildProductFilterFromQuery` (the same builders the first-page arm uses, so - * the query side is normalized exactly once and identically in both arms). The - * two agree on SHAPE by construction. What is left is the gap between "the same - * predicate" and "the same spelling", and closing it is this function's whole - * job — an agreeing request must never 400 by accident: - * - key ORDER is irrelevant (sorted), - * - an absent axis and an `undefined` one are the same thing (dropped), - * - an OR-able array is a SET (sorted, deduped): `states=paid,cancelled` and - * `states=cancelled,paid,paid` select the same rows, - * - a window bound is an INSTANT, not a string: `...T00:00:00Z` and - * `...T00:00:00.000Z` are the same moment, - * - an axis whose value is its own default is dropped — see `isNoOpAxis`. - * Case is deliberately NOT folded: the store's own case-insensitivity is the - * store's business, and a token round-trips whatever the query said. - * - * FILE-LOCAL ON PURPOSE, FOR NOW. This and the `has*FilterParams` predicates - * serve the two list routes in this file. The rules-admin coupons list has the - * same cursor shape and the same unclosed gap; when it is closed, these should - * be LIFTED into a shared module and reused — a second copy would be free to - * drift on exactly the canonicalization details this exists to pin. - */ -function canonicalFilter(filter: OrderListFilter | ProductListFilter): string { - const entries = (Object.entries(filter) as [string, unknown][]) - .filter(([key, value]) => value !== undefined && !isNoOpAxis(key, value)) - .map(([key, value]): [string, unknown] => [key, canonicalFilterValue(key, value)]) - .toSorted(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); - return JSON.stringify(entries); -} - -/** - * Is this axis, at this value, indistinguishable from omitting it? - * - * `deleted: false` is: the tombstone axis is `deleted_at IS NULL` for EVERY - * value except `true` (`filter.deleted === true ? "is not" : "is"` in the store, - * and the port doc says so), so `?deleted=false` and no `deleted` at all issue - * the same SQL and select the same rows. Comparing them as distinct would 400 - * two spellings of one predicate — precisely the failure this gate exists to - * prevent, inverted. - * - * `active: false` is NOT: the store emits a real `active = 0` for it (an integer - * column, `filter.active ? 1 : 0`), so it and an omitted `active` are genuinely - * different predicates and must keep disagreeing. The asymmetry is the store's, - * not a tidying opportunity. - */ -function isNoOpAxis(key: string, value: unknown): boolean { - return key === "deleted" && value === false; -} - -function canonicalFilterValue(key: string, value: unknown): unknown { - if (Array.isArray(value)) return [...new Set(value as unknown[])].toSorted(); - if ((key === "from" || key === "to") && typeof value === "string") { - const ms = Date.parse(value); - return Number.isNaN(ms) ? value : new Date(ms).toISOString(); - } - return value; -} - -/** Narrow a validated-filter zod result back into the domain `OrderListFilter` - * (drops `undefined` keys so the shape is exact). */ -function toFilter(parsed: { - states?: OrderState[]; - from?: string; - to?: string; - search?: string; -}): OrderListFilter { - const filter: OrderListFilter = {}; - if (parsed.states !== undefined && parsed.states.length > 0) filter.states = parsed.states; - if (parsed.from !== undefined) filter.from = parsed.from; - if (parsed.to !== undefined) filter.to = parsed.to; - if (parsed.search !== undefined) filter.search = parsed.search; - return filter; -} - -/** The decoded cursor's `createdAt` must be a valid ISO-8601 datetime — the SAME - * check the query `from`/`to` bounds use — so a tampered/garbage position is a - * malformed cursor (→ 400), never a raw string that reaches the store's keyset - * comparison. */ -const cursorCreatedAt = z.string().datetime(); - -/** Validate a decoded cursor position shape — `{ createdAt: , id: - * }` — or null if malformed (→ 400). */ -function cursorPosOf(pos: unknown): OrderListCursor | null { - if (pos === null || typeof pos !== "object") return null; - const p = pos as { createdAt?: unknown; id?: unknown }; - if (typeof p.createdAt !== "string" || !cursorCreatedAt.safeParse(p.createdAt).success) { - return null; - } - if (typeof p.id !== "string" || p.id.length === 0 || p.id.length > 200) return null; - return { createdAt: p.createdAt, id: toOrderId(p.id) }; -} - -interface DecodedCursor { - pos: unknown; - filter: unknown; - limit: unknown; -} - -/** Encode the keyset position + active filter + limit into an opaque base64url - * token, so paging preserves the filter and clamped limit. */ -function encodeCursor(pos: OrderListCursor, filter: OrderListFilter, limit: number): string { - const payload = { pos: { createdAt: pos.createdAt, id: pos.id }, filter, limit }; - return toBase64Url(new TextEncoder().encode(JSON.stringify(payload))); -} - -/** Decode an opaque cursor token; returns null on ANY malformed/garbage input so - * the route answers 400 rather than 500 (MOD-1). */ -function decodeCursor(token: string): DecodedCursor | null { - try { - const json = new TextDecoder().decode(fromBase64Url(token)); - const parsed = JSON.parse(json) as unknown; - if (parsed === null || typeof parsed !== "object") return null; - const p = parsed as DecodedCursor; - return { pos: p.pos, filter: p.filter, limit: p.limit }; - } catch { - return null; - } -} - -/** Narrow a validated product-filter zod result back into the domain - * `ProductListFilter` (drops `undefined` keys so the shape is exact) — - * mirrors `toFilter`. */ -function toProductFilter(parsed: { - active?: boolean; - deleted?: boolean; - productKind?: "physical" | "digital"; - search?: string; - lowStockThreshold?: number; -}): ProductListFilter { - const filter: ProductListFilter = {}; - if (parsed.active !== undefined) filter.active = parsed.active; - if (parsed.deleted !== undefined) filter.deleted = parsed.deleted; - if (parsed.productKind !== undefined) filter.productKind = parsed.productKind; - if (parsed.search !== undefined) filter.search = parsed.search; - if (parsed.lowStockThreshold !== undefined) filter.lowStockThreshold = parsed.lowStockThreshold; - return filter; -} - -/** Validate a decoded product-cursor position shape — `{ createdAt: , productId: }` — or null if malformed - * (→ 400). Mirrors `cursorPosOf`. */ -function productCursorPosOf(pos: unknown): ProductListCursor | null { - if (pos === null || typeof pos !== "object") return null; - const p = pos as { createdAt?: unknown; productId?: unknown }; - if (typeof p.createdAt !== "string" || !cursorCreatedAt.safeParse(p.createdAt).success) { - return null; - } - if (typeof p.productId !== "string" || p.productId.length === 0 || p.productId.length > 200) { - return null; - } - return { createdAt: p.createdAt, productId: toProductId(p.productId) }; -} - -/** Encode the product-list keyset position + active filter + limit into an - * opaque base64url token — mirrors `encodeCursor`. */ -function encodeProductCursor( - pos: ProductListCursor, - filter: ProductListFilter, - limit: number, -): string { - const payload = { pos: { createdAt: pos.createdAt, productId: pos.productId }, filter, limit }; - return toBase64Url(new TextEncoder().encode(JSON.stringify(payload))); -} - -/** Decode an opaque product-list cursor token; returns null on ANY malformed/ - * garbage input so the route answers 400 rather than 500 (MOD-1). Mirrors - * `decodeCursor`. */ -function decodeProductCursor(token: string): DecodedCursor | null { - try { - const json = new TextDecoder().decode(fromBase64Url(token)); - const parsed = JSON.parse(json) as unknown; - if (parsed === null || typeof parsed !== "object") return null; - const p = parsed as DecodedCursor; - return { pos: p.pos, filter: p.filter, limit: p.limit }; - } catch { - return null; - } -} - -// Portable base64url (Node + workerd both provide btoa/atob + TextEncoder). -function toBase64Url(bytes: Uint8Array): string { - let bin = ""; - for (const b of bytes) bin += String.fromCharCode(b); - return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); -} - -function fromBase64Url(token: string): Uint8Array { - const b64 = token.replace(/-/g, "+").replace(/_/g, "/"); - const bin = atob(b64); // throws on invalid base64 ⇒ caught by decodeCursor ⇒ 400 - const out = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); - return out; -} diff --git a/packages/service/src/routes/auth.ts b/packages/service/src/routes/auth.ts deleted file mode 100644 index e4891b94..00000000 --- a/packages/service/src/routes/auth.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { - email as toEmail, - requestLogin, - verifyLogin, - type Clock, - type CustomerCredentialVerifier, - type CustomerStore, - type EmailSender, - type OrderStore, - type SessionStore, -} from "@otta-sh/domain"; -import { Hono } from "hono"; -import { loginRequestBody, loginVerifyBody } from "../schemas.js"; -import { bearerToken } from "./session-auth.js"; - -export interface AuthRoutesDeps { - credentialVerifier: CustomerCredentialVerifier; - customerStore: CustomerStore; - sessionStore: SessionStore; - orderStore: OrderStore; - emailSender: EmailSender; - clock: Clock; - /** Base URL of the storefront where the magic link lands (for the emailed - * link). Absent ⇒ the email carries the raw challengeId/token. */ - storefrontBaseUrl?: string; -} - -/** - * Storefront customer auth (Phase 5 §7). Magic-link only (§4 draft ADR): - * - `POST /auth/login/request` — issue a challenge and email the link. Returns - * an **identical** response whether or not an account exists (§9 Risk 4: no - * enumeration oracle) — a first-ever login creates the account on verify. - * - `POST /auth/login/verify` — redeem the token → a session token in the body - * (not a Set-Cookie; the plugin's first-party layer owns the cookie, §4). - * - `POST /auth/logout` — revoke the bearer session (idempotent). - */ -export function authRoutes(deps: AuthRoutesDeps): Hono { - const app = new Hono(); - - app.post("/login/request", async (c) => { - const parsed = loginRequestBody.safeParse(await readJson(c)); - if (!parsed.success) return c.json({ error: "invalid request body" }, 400); - let email; - try { - email = toEmail(parsed.data.email); - } catch { - return c.json({ error: "invalid email" }, 400); - } - const issued = await requestLogin({ credentialVerifier: deps.credentialVerifier }, { email }); - if (issued.ok) { - const { challengeId, token } = issued; - const loginUrl = - deps.storefrontBaseUrl === undefined - ? undefined - : `${deps.storefrontBaseUrl.replace(/\/$/, "")}/account/login?challengeId=${encodeURIComponent(challengeId)}&token=${encodeURIComponent(token)}`; - await deps.emailSender.send({ - to: email, - template: "customer-login-link", - data: { challengeId, token, ...(loginUrl !== undefined ? { loginUrl } : {}) }, - idempotencyKey: `login:${challengeId}`, - }); - } - // Identical response regardless of account existence AND regardless of - // rate limiting (review round H1): a THROTTLED issue sends no email and - // inserts no challenge, but the caller must not be able to tell — else - // the limiter itself becomes a probing oracle (§9 Risk 4). - return c.json({ ok: true, message: "If an account exists, we've sent a sign-in link." }, 200); - }); - - app.post("/login/verify", async (c) => { - const parsed = loginVerifyBody.safeParse(await readJson(c)); - if (!parsed.success) return c.json({ error: "invalid request body" }, 400); - const result = await verifyLogin(deps, { - challengeId: parsed.data.challengeId, - token: parsed.data.token, - }); - if (!result.ok) { - // A stale/invalid/consumed challenge → 401, never customer detail. - return c.json({ ok: false, reason: result.reason }, 401); - } - return c.json( - { ok: true, sessionToken: result.sessionToken, expiresAt: result.expiresAt }, - 200, - ); - }); - - app.post("/logout", async (c) => { - const token = bearerToken(c); - if (token !== null) await deps.sessionStore.revoke(token); - return c.json({ ok: true }, 200); - }); - - return app; -} - -async function readJson(c: { req: { json(): Promise } }): Promise { - try { - return await c.req.json(); - } catch { - return undefined; - } -} diff --git a/packages/service/src/routes/carts.ts b/packages/service/src/routes/carts.ts deleted file mode 100644 index a62ab2e3..00000000 --- a/packages/service/src/routes/carts.ts +++ /dev/null @@ -1,414 +0,0 @@ -import { - addLine, - type Cart, - type CartDeps, - type CartFailure, - type CartLine, - type CartStore, - type Clock, - createCart, - currency, - expireHolds, - type FulfillmentKind, - getCart, - type InventoryStore, - idempotencyKey, - productId as toProductId, - type ProductCommerceStore, - type ProductId, - removeLine, - sku, - updateLine, -} from "@otta-sh/domain"; -import { type Context, Hono } from "hono"; -import { tokenMatches } from "../auth.js"; -import { - addLineBody, - createCartBody, - linePathParams, - patchLineBody, - pathParams, -} from "../schemas.js"; - -export interface CartRoutesDeps { - store: InventoryStore; - cartStore: CartStore; - /** Resolves a line's fulfillment kind server-side (Phase 4 §6) — a digital - * product reserves nothing — and, since the add endpoint's SKU guard, the - * catalog the guard resolves a submitted sku against. Optional ONLY so - * `expireHoldsRoutes` can share the type: `cartRoutes` narrows it back to - * REQUIRED in its own signature, so the guard cannot be silently disabled by - * a call site that forgets to wire the store. */ - productCommerce?: ProductCommerceStore; - clock: Clock; - /** Hold TTL in ms; defaults to the domain's DEFAULT_HOLD_TTL_MS. */ - ttlMs?: number; - /** - * Shared secret for the internal endpoints (`X-Internal-Token` header). When - * unset, `/internal/*` is DISABLED (503) rather than open — the minimal - * auth'd-internal stance §6 requires; a fuller authn story is deferred. - */ - internalToken?: string; -} - -const DEFAULT_CURRENCY = "USD"; - -/** - * Cart routes — each a straight serialization of a cart use-case: validate → - * use-case → serialize. No status-code-as-logic for stock: `OUT_OF_STOCK` is a - * 200 typed body (mirroring `reserve`). Not-found is 404; a checked-out fence is - * 409. The `Idempotency-Key` header threads into the domain command. - */ -export function cartRoutes(deps: CartRoutesDeps & { productCommerce: ProductCommerceStore }): Hono { - const app = new Hono(); - const cartDeps: CartDeps = { - cartStore: deps.cartStore, - inventoryStore: deps.store, - clock: deps.clock, - ttlMs: deps.ttlMs, - }; - - app.post("/", async (c) => { - const parsed = createCartBody.safeParse(await readJson(c)); - if (!parsed.success) { - return c.json({ error: "invalid request body", issues: parsed.error.issues }, 400); - } - const cartId = await createCart(cartDeps, currency(parsed.data.currency ?? DEFAULT_CURRENCY)); - return c.json({ cartId }, 201); - }); - - app.get("/:cartId", async (c) => { - const params = pathParams.safeParse(c.req.param()); - if (!params.success) return c.json({ error: "invalid path parameter" }, 400); - const cart = await getCart(cartDeps, params.data.cartId); - if (cart === null) return c.json({ ok: false, reason: "CART_NOT_FOUND" }, 404); - return c.json({ ok: true, cart: serializeCart(cart) }, 200); - }); - - app.post("/:cartId/lines", async (c) => { - const key = requireKey(c); - if (key === null) return c.json({ error: "missing Idempotency-Key header" }, 400); - const params = pathParams.safeParse(c.req.param()); - if (!params.success) return c.json({ error: "invalid path parameter" }, 400); - const parsed = addLineBody.safeParse(await readJson(c)); - if (!parsed.success) { - return c.json({ error: "invalid request body", issues: parsed.error.issues }, 400); - } - // SECURITY — the add endpoint's SKU guard. `sku` and `productId` arrive as - // two INDEPENDENT client inputs (both editable on the storefront /cart/add - // POST). Checkout takes price/title/currency AND grants the digital - // entitlement from the productId's row, but stamps the order line's `sku` - // from the cart line (the client value). If the two may disagree, a caller - // pairs product A's productId (cheap / its entitlement) with product B's - // sku (pricey / a different good) and is charged A's price while reserving - // B's stock. - // - // Issue #80 closed the half of that where a product_commerce row existed - // and its sku differed. The rule is now stated positively rather than as a - // list of rejections — every add must RESOLVE its sku to a live, priced - // sellable unit OF THE NAMED PRODUCT (see `resolveSellableUnit`), and - // anything that does not resolve is rejected rather than reinterpreted. - // That closes three cases the old check let through: a productId with NO - // commerce row (waved through as "harmless"), a soft-deleted product's - // sku, and — with the row present — a sku belonging to another product. - // - // A live VARIANT's sku is resolved and then refused, deliberately, until - // order pricing resolves the sellable unit rather than the product row. - // The reasoning is on `resolveSellableUnit`; it is a correctness gate, not - // a policy, and nothing on this endpoint changes when it opens. - // - // A BARE ADD (no productId) IS LEFT EXACTLY AS IT WAS, and that is a - // decision, not an oversight. Resolving a bare sku means asking "which - // live sellable unit, across the whole catalog, holds this sku" — and - // `ProductCommerceStore` has no such lookup: every read on it is keyed by - // productId. A bare line is also unorderable by construction (both - // checkout paths reject a null productId with PRODUCT_NOT_PRICED before - // they price anything), so it can confer neither price nor entitlement and - // the spoof this guard exists to stop is not expressible through it. - // - // THE RESIDUAL RISK IS LARGER THAN "IT RESERVES STOCK", and it is worth - // naming precisely. Reserving is what every legitimate add does, so on its - // own that is a rate-limiting concern rather than this one. But the cart - // store's add upserts on `(cart_id, sku)` and its conflict update writes - // `product_id` from the incoming request unconditionally — so a BARE - // re-add of a sku already on the cart overwrites that line's product_id - // with null, downgrading a line this guard admitted into one checkout - // refuses. A bare add can therefore reach past its own line and damage a - // guarded one. Guarding it needs the same by-sku resolver the port does - // not have; the guard cannot invent one, and guessing with the admin - // list's case-insensitive search would resolve "sku-a" onto "SKU-A" and - // see no variants at all. Tracked separately as issue #235. - // - // NOT AN N+1, and not on the reserve path: the resolution is at most two - // keyed reads per REQUEST (never per line — an add carries exactly one), - // the product read alone answers the storefront's hot path, and nothing - // here reserves, seeds or otherwise touches inventory. - // - // REPLAY PARITY HOLDS IN THE REJECTED DIRECTION, which is the direction - // that matters here: the guard runs BEFORE `addLine`, so a refused add - // writes nothing at all, and a same-key retry of it meets the same guard - // against the same catalog and is refused identically rather than - // half-applied. It does NOT hold in the other direction, and that is not a - // regression to fix here: an add that succeeded and whose unit is LATER - // orphaned, soft-deleted or unpriced meets the guard first on a same-key - // retry and answers 409, where before it would have replayed the stored - // line. The catalog genuinely changed under the caller between the two - // requests, so a refusal is the honest answer — and the original line, and - // its hold, are untouched by it. - let productId: string | null = null; - let kind: FulfillmentKind = "physical"; - if (parsed.data.productId !== undefined) { - productId = parsed.data.productId; - const resolved = await resolveSellableUnit( - deps.productCommerce, - toProductId(parsed.data.productId), - parsed.data.sku, - ); - if (resolved.status === "unknown") { - return c.json({ ok: false, reason: "SKU_MISMATCH" }, 409); - } - if (resolved.status === "unpriced") { - // Live, correctly named, and nobody has priced it — a product synced - // but not yet priced ("create then price"), which is the only way to - // reach this today, since every variant is refused above whether it - // carries a price or not. Refused HERE and by name so a shopper is - // told at the Add button rather than at the last step, and so no - // stock is held for a line that could never have been bought. - return c.json({ ok: false, reason: "PRODUCT_NOT_PRICED" }, 409); - } - kind = resolved.productKind; - } - const res = await addLine( - cartDeps, - params.data.cartId, - sku(parsed.data.sku), - productId, - parsed.data.qty, - idempotencyKey(key), - kind, - ); - if (res.ok) return c.json({ ok: true, line: serializeLine(res.line) }, 200); - return failure(c, res.reason); - }); - - app.patch("/:cartId/lines/:lineId", async (c) => { - const key = requireKey(c); - if (key === null) return c.json({ error: "missing Idempotency-Key header" }, 400); - const params = linePathParams.safeParse(c.req.param()); - if (!params.success) return c.json({ error: "invalid path parameter" }, 400); - const parsed = patchLineBody.safeParse(await readJson(c)); - if (!parsed.success) { - return c.json({ error: "invalid request body", issues: parsed.error.issues }, 400); - } - const res = await updateLine( - cartDeps, - params.data.cartId, - params.data.lineId, - parsed.data.qty, - idempotencyKey(key), - ); - if (res.ok) return c.json({ ok: true, line: serializeLine(res.line) }, 200); - return failure(c, res.reason); - }); - - app.delete("/:cartId/lines/:lineId", async (c) => { - const key = requireKey(c); - if (key === null) return c.json({ error: "missing Idempotency-Key header" }, 400); - const params = linePathParams.safeParse(c.req.param()); - if (!params.success) return c.json({ error: "invalid path parameter" }, 400); - const res = await removeLine( - cartDeps, - params.data.cartId, - params.data.lineId, - idempotencyKey(key), - ); - if (res.ok) return c.json({ ok: true }, 200); - return failure(c, res.reason); - }); - - return app; -} - -/** - * Internal (non-public) sweep endpoint: reclaim globally-expired holds (§6). - * Guarded by a shared-secret `X-Internal-Token` header: 503 when no token is - * configured (endpoint disabled, never silently open), 401 on a mismatch. - */ -export function expireHoldsRoutes(deps: CartRoutesDeps): Hono { - const app = new Hono(); - const cartDeps: CartDeps = { - cartStore: deps.cartStore, - inventoryStore: deps.store, - clock: deps.clock, - ttlMs: deps.ttlMs, - }; - app.post("/expire-holds", async (c) => { - const token = deps.internalToken; - if (token === undefined || token.length === 0) { - return c.json({ ok: false, error: "internal endpoints disabled" }, 503); - } - if (!tokenMatches(c.req.header("X-Internal-Token"), token)) { - return c.json({ ok: false, error: "unauthorized" }, 401); - } - const reclaimed = await expireHolds(cartDeps); - return c.json({ ok: true, reclaimed }, 200); - }); - return app; -} - -function serializeCart(cart: Cart): { - cartId: string; - state: string; - /** The order this cart handed off to (issue #132), or null while it is - * `active`. Not a payment signal, and a null does NOT prove no order exists - * for the cart — see `CartStore.checkout`. */ - orderId: string | null; - currency: string; - lines: ReturnType[]; -} { - return { - cartId: cart.cartId, - state: cart.state, - orderId: cart.orderId, - currency: cart.currency, - lines: cart.lines.map(serializeLine), - }; -} - -/** Wire shape of a cart line — no price (Phase 3), no internal reservation state. */ -function serializeLine(line: CartLine): { - lineId: string; - sku: string; - productId: string | null; - qty: number; - reservationId: string | null; - expiresAt: string | null; -} { - return { - lineId: line.lineId, - sku: line.sku, - productId: line.productId, - qty: line.qty, - reservationId: line.reservationId, - expiresAt: line.expiresAt, - }; -} - -/** - * Resolve a submitted sku to ONE live sellable unit of ONE named product — the - * whole of the add endpoint's SKU guard. - * - * "Live sellable unit" is the port's own phrase and the port's own definition, - * spanning both tables: a `product_commerce` row that is not soft-deleted, and a - * `product_variants` row that is not orphaned. That is deliberately the SAME - * predicate the live-sku uniqueness indexes use (`WHERE deleted_at IS NULL` / - * `WHERE orphaned_at IS NULL`), which is what makes "one sku names one unit" - * true here rather than merely likely — and it is NOT the publish gate: `active` - * decides whether a storefront lists a product, not whether the sku on a request - * names a real thing, and the two must not be conflated in a security check. - * - * A DEAD unit therefore fails to resolve, by construction and without a special - * case: a soft-deleted product, and an orphaned variant that still holds its sku, - * its price and its stock, both simply are not live and neither is reachable. - * - * PRICED IS PART OF SELLABLE. A unit nobody has priced cannot be sold, and a - * unit priced at a row that is not its own is worse than unsold — so the guard - * refuses the unpriced case here rather than letting it travel to a checkout - * that would resolve the price from somewhere else. - * - * A LIVE, PRICED VARIANT IS RESOLVED AND THEN REFUSED, and that is the whole of - * the variant branch today. The resolution is real — it is what tells a live - * size apart from a spoof — but the answer is still `unknown`, because ORDER - * PRICING IS NOT VARIANT-AWARE: `createOrderFromCart` and `POST /checkout/quote` - * both read the snapshot price AND the snapshot title from the `product_commerce` - * row named by `productId`, and neither has any way to reach a variant. Letting a - * size into a cart therefore does not sell the size; it sells the parent's price - * under the parent's name, immutably, because an order line's snapshot is never - * rewritten. A cheap product with an expensive size is then the issue-#80 attack - * one level down, and a product whose sizes carry all the money has no price at - * all and cannot check out. - * - * So the branch stays closed until the thing that makes it safe exists. THE - * UN-GATING CRITERION, stated once: order pricing resolves the SELLABLE UNIT - * rather than the product row — snapshotting the variant's own price and its own - * title onto the line. On that day this branch returns `ok` and its pinned test - * flips from refusal to acceptance; nothing else here has to move. - * - * COST: one keyed read when the product's own sku matches — the storefront's - * hot path, and byte-for-byte the read this route already did — and a second - * only when it does not, which is the variant case. Both are per REQUEST, and an - * add carries exactly one line; there is no per-line loop here and there must - * never be one. - */ -async function resolveSellableUnit( - store: ProductCommerceStore, - productId: ProductId, - submittedSku: string, -): Promise< - { status: "ok"; productKind: FulfillmentKind } | { status: "unknown" } | { status: "unpriced" } -> { - const product = await store.getByProductId(productId); - if (product === null || product.deletedAt !== null) return { status: "unknown" }; - if (product.sku !== null && String(product.sku) === submittedSku) { - return product.price === null - ? { status: "unpriced" } - : { status: "ok", productKind: product.productKind }; - } - // The product's own sku is not the one submitted — so either this product - // sells through variants, or the sku belongs to somebody else entirely. - const variant = (await store.listVariants(productId)).find( - (row) => row.orphanedAt === null && row.sku !== null && String(row.sku) === submittedSku, - ); - if (variant === undefined) return { status: "unknown" }; - // BOTH ARMS RETURN `unknown` TODAY, and the lookup above is therefore - // SCAFFOLDING — say it plainly rather than let a reader hunt for the - // behavioural difference it does not make. It is held here, unobserved, for - // one reason: it keeps the flip to a single return statement, in the one - // place that already knows which rows are live and which sku was asked for. - // Deleting it would mean re-deriving all of that later, in a change whose - // risk is entirely about pricing. - // - // The refusal is deliberately the SAME token a spoof gets, so the endpoint - // publishes nothing about which sizes exist; and deliberately NOT `unpriced`, - // which would be a different and untrue statement — a priced size is priced, - // the price is simply one checkout cannot reach yet. - // - // When order pricing resolves the sellable unit, this return becomes - // return variant.price === null - // ? { status: "unpriced" } - // : { status: "ok", productKind: product.productKind }; - // — a size inheriting its product's fulfillment kind, since there is no - // per-variant kind on the port — and the scaffolding above becomes the thing - // that tells a live size apart from a spoof. - return { status: "unknown" }; -} - -function failure(c: Context, reason: CartFailure): Response { - const body = { ok: false as const, reason }; - switch (reason) { - case "OUT_OF_STOCK": - return c.json(body, 200); // typed body, not status-code-as-logic - case "CART_NOT_FOUND": - case "LINE_NOT_FOUND": - return c.json(body, 404); - case "CART_CHECKED_OUT": - case "LINE_CHECKED_OUT": - case "HOLD_EXPIRED": - // HOLD_EXPIRED: a late add replay whose hold the sweep already reaped — - // the line was not resurrected; the client adds again with a fresh key. - return c.json(body, 409); - } -} - -function requireKey(c: { req: { header(name: string): string | undefined } }): string | null { - const key = c.req.header("Idempotency-Key"); - return key === undefined || key.length === 0 ? null : key; -} - -async function readJson(c: { req: { json(): Promise } }): Promise { - try { - return await c.req.json(); - } catch { - return undefined; - } -} diff --git a/packages/service/src/routes/catalog.ts b/packages/service/src/routes/catalog.ts deleted file mode 100644 index 2929cae9..00000000 --- a/packages/service/src/routes/catalog.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { - listProductCommerceByIds, - productId, - type ProductCommerceStore, - type ProductCommerceView, -} from "@otta-sh/domain"; -import { Hono } from "hono"; -import { commerceBatchBody } from "../schemas.js"; - -export interface CatalogDeps { - productCommerce: ProductCommerceStore; -} - -/** - * Batch id cap (Phase 2 §6/§8 risk 6, pre-approved): a REQUEST-SIZE guard, - * not a pagination feature — no cursor semantics are invented beyond the - * port (ADR-0002 rule 2). Sized ≥ 2× the plugin's PLP page cap (48) so a - * single page render never needs to split into multiple batch calls. - */ -export const COMMERCE_BATCH_ID_CAP = 100; - -/** - * Catalog read routes — Phase 2 §6/§7 step 3. ONE endpoint, - * `POST /catalog/commerce/batch`, a 1:1 serialization of - * `ProductCommerceStore.listCommerceByIds`: known ids come back as items, - * missing/soft-deleted/commerce-incomplete ids are silently omitted (no - * per-id error entries, no 404 — "no status-code-as-logic"), `inStock` is - * computed by the store's single intra-DB join (§6 invariant — never a - * second inventory round trip), and money on the wire is an integer + - * ISO-4217 string. 400 only for schema failure / the id cap. - * - * Kept in its own file: a parallel Phase-4 branch adds its own routes — - * `app.ts`/`schemas.ts` edits stay minimal and additive. - */ -export function catalogRoutes(deps: CatalogDeps): Hono { - const app = new Hono(); - - app.post("/commerce/batch", async (c) => { - const parsed = commerceBatchBody.safeParse(await readJson(c)); - if (!parsed.success) { - return c.json({ error: "invalid request body", issues: parsed.error.issues }, 400); - } - const views = await listProductCommerceByIds( - deps.productCommerce, - parsed.data.productIds.map((id) => productId(id)), - ); - return c.json({ items: views.map(serializeView) }, 200); - }); - - return app; -} - -function serializeView(view: ProductCommerceView): Record { - return { - productId: view.productId, - sku: view.sku, - price: { amount: view.price.amount, currency: view.price.currency }, - inStock: view.inStock, - // The publish gate the plugin's join derives purchasability from - // (purchasable ⟺ present && active) — false for every row until the - // deferred afterPublish→activate wiring lands. - active: view.active, - }; -} - -async function readJson(c: { req: { json(): Promise } }): Promise { - try { - return await c.req.json(); - } catch { - return undefined; - } -} diff --git a/packages/service/src/routes/entitlements.ts b/packages/service/src/routes/entitlements.ts deleted file mode 100644 index e4d530e0..00000000 --- a/packages/service/src/routes/entitlements.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { - cents, - currency as toCurrency, - type CustomerStore, - orderId as toOrderId, - type SessionStore, - type SettleDeps, - settleOrder, - sku as toSku, - type X402Proof, -} from "@otta-sh/domain"; -import { Hono } from "hono"; -import { entitlementCheckQuery, x402ProofBody } from "../schemas.js"; -import { requireInternalToken } from "./internal-auth.js"; -import { type OrderServiceDeps, serializeOrder } from "./orders.js"; -import { resolveCustomer } from "./session-auth.js"; - -/** - * `entitlementRoutes` needs the session + customer stores (session scope of the - * `/check` oracle-close, ADR-0011) on top of `OrderServiceDeps`. Both are - * REQUIRED fields on `AppDeps`, satisfied by the spread at the app.ts mount site - * (`entitlementRoutes({ ...orderDeps, sessionStore, customerStore })`) — a future - * reader wiring this from a narrower deps object must pass them explicitly, like - * `productCommerceRoutes`' hand-built subset. - */ -export type EntitlementRoutesDeps = OrderServiceDeps & { - sessionStore: SessionStore; - customerStore: CustomerStore; -}; - -/** - * Entitlement routes (§6/§7): - * - `POST /entitlements/grant` — service-authenticated (`X-Internal-Token`); - * receives an x402 page-gate proof and runs `settleOrder(x402Gateway, - * {kind:"page_gate"})`, which verifies the proof server-side and grants the - * entitlement on success. - * - `GET /entitlements/check` — delivery authorization with PRESENCE-BASED scope - * precedence (issue #33 / ADR-0011), so it is no longer an unauthenticated - * existence oracle over an email: - * 1. `buyerRef` present anywhere ⇒ operator auth (`X-Internal-Token`; 503 - * when unconfigured, never silently open) — admin/support tooling only. - * 2. else `orderId` present ⇒ open bearer-capability check (the order id is - * an unguessable 122-bit UUID; a Bearer, if any, is ignored — with no - * email in the query there is no oracle). - * 3. else valid `Authorization: Bearer ` ⇒ session scope; the - * email is derived SERVER-SIDE from the session, never from the query. - * 4. else ⇒ 401. - */ -export function entitlementRoutes(deps: EntitlementRoutesDeps): Hono { - const app = new Hono(); - const settleDeps: SettleDeps = { - orderStore: deps.orderStore, - entitlementStore: deps.entitlementStore, - paymentEventStore: deps.paymentEventStore, - inventoryStore: deps.store, - couponStore: deps.couponStore, - clock: deps.clock, - }; - - app.post("/grant", async (c) => { - const denied = requireInternalToken(c, deps.internalToken); - if (denied !== null) return denied; - const gateway = deps.gateways.x402; - if (gateway === undefined) return c.json({ ok: false, error: "x402 not configured" }, 503); - - const parsed = x402ProofBody.safeParse(await readJson(c)); - if (!parsed.success) { - return c.json({ error: "invalid request body", issues: parsed.error.issues }, 400); - } - const proof: X402Proof = { - orderId: toOrderId(parsed.data.orderId), - transaction: parsed.data.transaction, - network: parsed.data.network, - payer: parsed.data.payer, - amount: cents(parsed.data.amount), - currency: toCurrency(parsed.data.currency), - signature: parsed.data.signature, - }; - const res = await settleOrder(settleDeps, gateway, { kind: "page_gate", proof }); - if (res.ok) { - // Deliberately the FULL `serializeOrder`, unlike the redacted - // `GET /orders/:orderId` (ADR-0010 §2 / PR D): this route already sits - // behind `requireInternalToken` above (a server-to-server POST), so it - // is not the unauthenticated capability-URL surface PR D locks down. - return c.json( - { ok: true, order: res.order === null ? null : serializeOrder(res.order) }, - 200, - ); - } - // A rejected proof (bad signature / malformed / mismatch) → 400; missing order → 404. - const status = res.reason === "ORDER_NOT_FOUND" ? 404 : 400; - return c.json({ ok: false, reason: res.reason }, status); - }); - - // Presence-based scope precedence closes the former email existence oracle - // (issue #33 / ADR-0011). The precedence is keyed on what the request - // CONTAINS, never on which scope it best "fits": the store ANDs orderId + - // buyerRef, so a shape-based "orderId ⇒ open" rule that forwarded the whole - // query would leave a residual "does order X belong to email Y" oracle. - app.get("/check", async (c) => { - const parsed = entitlementCheckQuery.safeParse(c.req.query()); - if (!parsed.success) { - return c.json({ error: "invalid query", issues: parsed.error.issues }, 400); - } - const sku = toSku(parsed.data.sku); - - // 1. buyerRef present anywhere ⇒ operator-only (X-Internal-Token). Gating - // at the parameter, not the shape: an accompanying orderId is still - // forwarded (ANDed), but only for an authenticated operator. - if (parsed.data.buyerRef !== undefined) { - const denied = requireInternalToken(c, deps.internalToken); - if (denied !== null) return denied; - const active = await deps.entitlementStore.check({ - orderId: parsed.data.orderId === undefined ? undefined : toOrderId(parsed.data.orderId), - buyerRef: parsed.data.buyerRef, - sku, - }); - return c.json({ ok: true, active }, 200); - } - - // 2. else orderId present ⇒ open bearer capability (unguessable order id). - if (parsed.data.orderId !== undefined) { - const active = await deps.entitlementStore.check({ - orderId: toOrderId(parsed.data.orderId), - sku, - }); - return c.json({ ok: true, active }, 200); - } - - // 3. else a valid customer session ⇒ session scope. The buyerRef is the - // session customer's own email (derived server-side, never the query), - // so a customer can only ever probe their own entitlements. - const customerId = await resolveCustomer(c, deps.sessionStore); - if (customerId !== null) { - const customer = await deps.customerStore.get(customerId); - if (customer !== null) { - const active = await deps.entitlementStore.check({ buyerRef: customer.email, sku }); - return c.json({ ok: true, active }, 200); - } - } - - // 4. else no credential for any scope ⇒ closed. - return c.json({ ok: false, error: "unauthorized" }, 401); - }); - - return app; -} - -async function readJson(c: { req: { json(): Promise } }): Promise { - try { - return await c.req.json(); - } catch { - return undefined; - } -} diff --git a/packages/service/src/routes/internal-auth.ts b/packages/service/src/routes/internal-auth.ts deleted file mode 100644 index 59346265..00000000 --- a/packages/service/src/routes/internal-auth.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { Context } from "hono"; -// The single timing-safe compare implementation lives in ../auth.js, shared by -// this X-Internal-Token guard and the SERVICE_API_TOKEN X-Service-Token write gate. -import { tokenMatches } from "../auth.js"; - -/** - * Guard an internal endpoint: 503 when no token is configured (disabled, never - * silently open), 401 on a mismatch, `null` when authorized (proceed). Returns a - * `Response` to short-circuit on failure. - */ -export function requireInternalToken(c: Context, expected: string | undefined): Response | null { - if (expected === undefined || expected.length === 0) { - return c.json({ ok: false, error: "internal endpoints disabled" }, 503); - } - if (!tokenMatches(c.req.header("X-Internal-Token"), expected)) { - return c.json({ ok: false, error: "unauthorized" }, 401); - } - return null; -} diff --git a/packages/service/src/routes/internal-emails.ts b/packages/service/src/routes/internal-emails.ts deleted file mode 100644 index 15547d1f..00000000 --- a/packages/service/src/routes/internal-emails.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { - dispatchOrderEmails, - type Clock, - type CustomerCredentialVerifier, - type CustomerStore, - type EmailSender, - type OrderStore, -} from "@otta-sh/domain"; -import { Hono } from "hono"; -import { requireInternalToken } from "./internal-auth.js"; - -export interface OutboxDispatchDeps { - orderStore: OrderStore; - emailSender: EmailSender; - customerStore: CustomerStore; - /** For the login-challenge prune (review round H1) — same maintenance tick. */ - credentialVerifier: CustomerCredentialVerifier; - clock: Clock; - internalToken?: string; -} - -/** - * The email/auth maintenance trigger (Phase 5 §8 5.8 + review round H1) — the - * Phase-3 hold-expiry-cron precedent, reused: a self-interval or plugin-cron - * POSTs here to (a) drain pending order-status emails and (b) prune consumed/ - * expired login challenges so `login_challenges` cannot grow unboundedly. - * Claims are atomic, so concurrent runs never double-send; a send failure is - * retried on the next tick; the prune is idempotent. - */ -export function internalEmailRoutes(deps: OutboxDispatchDeps): Hono { - const app = new Hono(); - app.post("/dispatch-emails", async (c) => { - const denied = requireInternalToken(c, deps.internalToken); - if (denied !== null) return denied; - const sent = await dispatchOrderEmails({ - orderStore: deps.orderStore, - emailSender: deps.emailSender, - customerStore: deps.customerStore, - clock: deps.clock, - }); - const prunedChallenges = await deps.credentialVerifier.pruneChallenges( - deps.clock.now().toISOString(), - ); - return c.json({ ok: true, sent, prunedChallenges }, 200); - }); - return app; -} diff --git a/packages/service/src/routes/inventory.ts b/packages/service/src/routes/inventory.ts deleted file mode 100644 index f817eb3b..00000000 --- a/packages/service/src/routes/inventory.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { - commit, - idempotencyKey, - type InventoryStore, - release, - reserve, - ReservationNotFoundError, - sku, -} from "@otta-sh/domain"; -import { Hono } from "hono"; -import { commitBody, releaseBody, reserveBody } from "../schemas.js"; - -export interface InventoryDeps { - store: InventoryStore; -} - -/** - * Inventory routes — each a straight serialization of the port method: - * validate → domain use-case → serialize the result to JSON. No - * status-code-as-logic: `OUT_OF_STOCK` is a 200 body (the port has no - * exception for it); 400 is only for schema/validation failure. `commit` and - * `release` are the one exception: an unknown `reservationId` is a typed - * `ReservationNotFoundError` mapped to a 404 here, matching the repo's - * `{ok:false,reason:…}` 404 convention (`carts.ts`, `rules-admin.ts`). - * Everything else — most notably `ReservationCommitLostError`, the loud - * "reservation existed but was lost" anomaly — rethrows and keeps its 500 via - * `app.ts`'s catch-all `onError`. - */ -export function inventoryRoutes(deps: InventoryDeps): Hono { - const app = new Hono(); - - app.post("/reserve", async (c) => { - const key = c.req.header("Idempotency-Key"); - if (key === undefined || key.length === 0) { - return c.json({ error: "missing Idempotency-Key header" }, 400); - } - const parsed = reserveBody.safeParse(await readJson(c)); - if (!parsed.success) { - return c.json({ error: "invalid request body", issues: parsed.error.issues }, 400); - } - const result = await reserve( - deps.store, - sku(parsed.data.sku), - parsed.data.qty, - idempotencyKey(key), - ); - return c.json(result, 200); - }); - - app.post("/commit", async (c) => { - const parsed = commitBody.safeParse(await readJson(c)); - if (!parsed.success) { - return c.json({ error: "invalid request body", issues: parsed.error.issues }, 400); - } - try { - await commit(deps.store, parsed.data.reservationId); - } catch (err) { - if (err instanceof ReservationNotFoundError) { - return c.json({ ok: false, reason: "RESERVATION_NOT_FOUND" }, 404); - } - throw err; - } - return c.json({ ok: true }, 200); - }); - - app.post("/release", async (c) => { - const parsed = releaseBody.safeParse(await readJson(c)); - if (!parsed.success) { - return c.json({ error: "invalid request body", issues: parsed.error.issues }, 400); - } - try { - await release(deps.store, parsed.data.reservationId); - } catch (err) { - if (err instanceof ReservationNotFoundError) { - return c.json({ ok: false, reason: "RESERVATION_NOT_FOUND" }, 404); - } - throw err; - } - return c.json({ ok: true }, 200); - }); - - return app; -} - -async function readJson(c: { req: { json(): Promise } }): Promise { - try { - return await c.req.json(); - } catch { - return undefined; - } -} diff --git a/packages/service/src/routes/me.ts b/packages/service/src/routes/me.ts deleted file mode 100644 index 2c5e3999..00000000 --- a/packages/service/src/routes/me.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { - orderId as toOrderId, - type Address, - type AddressStore, - type Customer, - type CustomerStore, - type OrderStore, - type SessionStore, -} from "@otta-sh/domain"; -import { type Context, Hono } from "hono"; -import { - addressPathParams, - createAddressBody, - orderPathParams, - updateAddressBody, -} from "../schemas.js"; -import { serializeOrder } from "./orders.js"; -import { resolveCustomer } from "./session-auth.js"; - -export interface MeRoutesDeps { - sessionStore: SessionStore; - customerStore: CustomerStore; - orderStore: OrderStore; - addressStore: AddressStore; -} - -/** - * Authenticated storefront-customer surface (Phase 5 §7). Every handler derives - * the customer id from the bearer session — never a request param — so the - * isolation is structural, not a filter a client can bypass (§4). A foreign - * order id returns **404, not 403** (headline case 1: don't leak existence). - */ -export function meRoutes(deps: MeRoutesDeps): Hono { - const app = new Hono(); - - app.get("/", async (c) => { - const customerId = await resolveCustomer(c, deps.sessionStore); - if (customerId === null) return unauthorized(c); - const customer = await deps.customerStore.get(customerId); - if (customer === null) return unauthorized(c); - return c.json({ ok: true, customer: serializeCustomer(customer) }, 200); - }); - - app.get("/orders", async (c) => { - const customerId = await resolveCustomer(c, deps.sessionStore); - if (customerId === null) return unauthorized(c); - const orders = await deps.orderStore.listForCustomer(customerId); - return c.json({ ok: true, orders: orders.map(serializeOrder) }, 200); - }); - - app.get("/orders/:orderId", async (c) => { - const customerId = await resolveCustomer(c, deps.sessionStore); - if (customerId === null) return unauthorized(c); - const params = orderPathParams.safeParse(c.req.param()); - if (!params.success) return c.json({ error: "invalid path parameter" }, 400); - const order = await deps.orderStore.getById(toOrderId(params.data.orderId)); - // NOT_FOUND (not FORBIDDEN) for a foreign or unknown order — no existence leak. - if (order === null || order.customerId !== customerId) { - return c.json({ ok: false, reason: "ORDER_NOT_FOUND" }, 404); - } - return c.json({ ok: true, order: serializeOrder(order) }, 200); - }); - - app.get("/addresses", async (c) => { - const customerId = await resolveCustomer(c, deps.sessionStore); - if (customerId === null) return unauthorized(c); - const addresses = await deps.addressStore.list(customerId); - return c.json({ ok: true, addresses: addresses.map(serializeAddress) }, 200); - }); - - app.post("/addresses", async (c) => { - const customerId = await resolveCustomer(c, deps.sessionStore); - if (customerId === null) return unauthorized(c); - const parsed = createAddressBody.safeParse(await readJson(c)); - if (!parsed.success) return c.json({ error: "invalid request body" }, 400); - const created = await deps.addressStore.create(customerId, { - kind: parsed.data.kind, - name: parsed.data.name, - line1: parsed.data.line1, - line2: parsed.data.line2 ?? null, - city: parsed.data.city, - region: parsed.data.region ?? null, - postalCode: parsed.data.postalCode, - country: parsed.data.country, - isDefault: parsed.data.isDefault ?? false, - }); - return c.json({ ok: true, address: serializeAddress(created) }, 201); - }); - - app.put("/addresses/:addressId", async (c) => { - const customerId = await resolveCustomer(c, deps.sessionStore); - if (customerId === null) return unauthorized(c); - const params = addressPathParams.safeParse(c.req.param()); - if (!params.success) return c.json({ error: "invalid path parameter" }, 400); - const parsed = updateAddressBody.safeParse(await readJson(c)); - if (!parsed.success) return c.json({ error: "invalid request body" }, 400); - const updated = await deps.addressStore.update(customerId, params.data.addressId, parsed.data); - if (updated === null) return c.json({ ok: false, reason: "ADDRESS_NOT_FOUND" }, 404); - return c.json({ ok: true, address: serializeAddress(updated) }, 200); - }); - - app.delete("/addresses/:addressId", async (c) => { - const customerId = await resolveCustomer(c, deps.sessionStore); - if (customerId === null) return unauthorized(c); - const params = addressPathParams.safeParse(c.req.param()); - if (!params.success) return c.json({ error: "invalid path parameter" }, 400); - const deleted = await deps.addressStore.delete(customerId, params.data.addressId); - if (!deleted) return c.json({ ok: false, reason: "ADDRESS_NOT_FOUND" }, 404); - return c.json({ ok: true }, 200); - }); - - return app; -} - -function unauthorized(c: Context): Response { - return c.json({ ok: false, error: "unauthorized" }, 401); -} - -function serializeCustomer(customer: Customer): Record { - return { - id: customer.id, - email: customer.email, - displayName: customer.displayName, - emailVerifiedAt: customer.emailVerifiedAt, - createdAt: customer.createdAt, - }; -} - -function serializeAddress(a: Address): Record { - return { - id: a.id, - kind: a.kind, - name: a.name, - line1: a.line1, - line2: a.line2, - city: a.city, - region: a.region, - postalCode: a.postalCode, - country: a.country, - isDefault: a.isDefault, - }; -} - -async function readJson(c: { req: { json(): Promise } }): Promise { - try { - return await c.req.json(); - } catch { - return undefined; - } -} diff --git a/packages/service/src/routes/orders.ts b/packages/service/src/routes/orders.ts deleted file mode 100644 index a7310de2..00000000 --- a/packages/service/src/routes/orders.ts +++ /dev/null @@ -1,445 +0,0 @@ -import { - type CartStore, - type Clock, - computeQuote, - type CouponStore, - type CreateOrderDeps, - type CreateOrderFailure, - createOrderFromCart, - type EntitlementStore, - type ExpireOrdersDeps, - expireOrders, - type IdGen, - idempotencyKey, - type InventoryStore, - type Order, - orderId as toOrderId, - type OrderStore, - type OrderSummary, - type PaymentGateway, - type PaymentIntentHandle, - type PaymentMethod, - type PaymentEventStore, - productId as toProductId, - type QuoteFailure, - type ShippingRulesStore, - type TaxRulesStore, - type TotalsLineInput, - type ProductCommerceStore, -} from "@otta-sh/domain"; -import { type Context, Hono } from "hono"; -import { tokenMatches } from "../auth.js"; -import { checkoutBody, orderPathParams, quoteBody } from "../schemas.js"; -import { requireInternalToken } from "./internal-auth.js"; - -/** Shared deps for every Phase-4 order/payment/entitlement route (§7). */ -export interface OrderServiceDeps { - store: InventoryStore; - cartStore: CartStore; - productCommerce: ProductCommerceStore; - orderStore: OrderStore; - entitlementStore: EntitlementStore; - paymentEventStore: PaymentEventStore; - // Phase 6: the totals-pipeline rules stores. - shippingRules: ShippingRulesStore; - taxRules: TaxRulesStore; - couponStore: CouponStore; - clock: Clock; - idGen: IdGen; - gateways: Partial>; - /** Checkout hold TTL in ms; defaults to the domain's DEFAULT_CHECKOUT_TTL_MS. */ - checkoutTtlMs?: number; - /** Shared secret for /internal/* + service-authenticated /entitlements/grant. */ - internalToken?: string; -} - -/** - * Order routes (§7): create-from-cart, order read (drives the redirect poll), and - * the internal order-expiry trigger. Each a straight serialization of a use-case. - * The canonical create endpoint is `POST /checkout/orders` — Phase 6 extends this - * exact route (never renamed `/checkout/complete`). - */ -export function orderRoutes(deps: OrderServiceDeps): Hono { - const app = new Hono(); - const createDeps: CreateOrderDeps = { - orderStore: deps.orderStore, - cartStore: deps.cartStore, - inventoryStore: deps.store, - productCommerce: deps.productCommerce, - shippingRules: deps.shippingRules, - taxRules: deps.taxRules, - couponStore: deps.couponStore, - clock: deps.clock, - idGen: deps.idGen, - gateways: deps.gateways, - ttlMs: deps.checkoutTtlMs, - }; - const expireDeps: ExpireOrdersDeps = { - orderStore: deps.orderStore, - inventoryStore: deps.store, - couponStore: deps.couponStore, - clock: deps.clock, - }; - - app.post("/checkout/orders", async (c) => { - const parsed = checkoutBody.safeParse(await readJson(c)); - if (!parsed.success) { - return c.json({ error: "invalid request body", issues: parsed.error.issues }, 400); - } - // Idempotency key (§9 decision 8): the client `Idempotency-Key` header, or a - // fallback derived from the cart id (the cart is single-use → checked_out). - const header = c.req.header("Idempotency-Key"); - const key = - header !== undefined && header.length > 0 ? header : `checkout:${parsed.data.cartId}`; - const res = await createOrderFromCart(createDeps, { - cartId: parsed.data.cartId, - idempotencyKey: idempotencyKey(key), - buyerRef: parsed.data.buyerRef, - paymentMethod: parsed.data.paymentMethod, - ...(parsed.data.shippingZoneId !== undefined - ? { shippingZoneId: parsed.data.shippingZoneId } - : {}), - ...(parsed.data.shippingMethodId !== undefined - ? { shippingMethodId: parsed.data.shippingMethodId } - : {}), - ...(parsed.data.couponCode !== undefined ? { couponCode: parsed.data.couponCode } : {}), - // ADR-0009: forward the optional ship-to. The domain validates + trims - // (bounded lengths) and snapshots it immutably onto the order; a logged-in - // checkout may have prefilled it from the profile book, but the order copies - // the SUBMITTED value, never a live pointer to the profile row. - ...(parsed.data.shippingAddress !== undefined - ? { shippingAddress: parsed.data.shippingAddress } - : {}), - }); - if (res.ok) { - return c.json( - { ok: true, order: serializeOrder(res.order), intent: serializeIntent(res.intent) }, - 201, - ); - } - return checkoutFailure(c, res.reason); - }); - - // Read-only totals preview (§6): cart + zone/method + coupon → breakdown. Does - // NOT redeem the coupon — safe to call repeatedly as the buyer edits selection. - app.post("/checkout/quote", async (c) => { - const parsed = quoteBody.safeParse(await readJson(c)); - if (!parsed.success) { - return c.json({ error: "invalid request body", issues: parsed.error.issues }, 400); - } - const cart = await deps.cartStore.get(parsed.data.cartId); - if (cart === null) return c.json({ ok: false, reason: "CART_NOT_FOUND" }, 404); - if (cart.lines.length === 0) return c.json({ ok: false, reason: "CART_EMPTY" }, 409); - - // Resolve each line's snapshot price + tax class (mirrors createOrderFromCart). - // Bulk-fetch every line's projection in ONE store round trip (kills the - // per-cart-line N+1); brand lazily per line below so a null line's - // PRODUCT_NOT_PRICED precedence is unchanged. - const pcById = await deps.productCommerce.getManyByProductId( - cart.lines - .map((line) => line.productId) - .filter((id): id is string => id !== null) - .map((id) => toProductId(id)), - ); - const lines: TotalsLineInput[] = []; - for (const line of cart.lines) { - if (line.productId === null) return c.json({ ok: false, reason: "PRODUCT_NOT_PRICED" }, 409); - const pc = pcById.get(toProductId(line.productId)) ?? null; - if (pc === null || pc.price === null) { - return c.json({ ok: false, reason: "PRODUCT_NOT_PRICED" }, 409); - } - if (pc.price.currency !== cart.currency) { - return c.json({ ok: false, reason: "CURRENCY_MISMATCH" }, 409); - } - lines.push({ - unitPriceCents: pc.price.amount, - qty: line.qty, - taxClassId: pc.taxClass ?? "standard", - }); - } - - const quote = await computeQuote( - { - shippingRules: deps.shippingRules, - taxRules: deps.taxRules, - couponStore: deps.couponStore, - clock: deps.clock, - }, - { - currency: cart.currency, - lines, - ...(parsed.data.shippingZoneId !== undefined ? { zoneId: parsed.data.shippingZoneId } : {}), - ...(parsed.data.shippingMethodId !== undefined - ? { methodId: parsed.data.shippingMethodId } - : {}), - ...(parsed.data.couponCode !== undefined ? { couponCode: parsed.data.couponCode } : {}), - }, - ); - if (!quote.ok) return quoteFailure(c, quote.reason); - const b = quote.breakdown; - return c.json( - { - ok: true, - breakdown: { - currency: b.currency, - subtotalCents: b.subtotalCents, - discountCents: b.discountCents, - shippingCents: b.shippingCents, - taxCents: b.taxCents, - totalCents: b.totalCents, - appliedCouponCode: b.appliedCouponCode ?? null, - }, - }, - 200, - ); - }); - - // Unauthenticated, capability-URL-only read (ADR-0010 §2 / PR D — guest - // "track my order" polling drives checkout; the order id alone is the only - // credential). A VALID `X-Internal-Token` unlocks the full admin-equivalent - // view (`serializeOrder`); anything else — absent, empty, or wrong — - // DEGRADES to the redacted `serializePublicOrder` view, never a 401/503: - // this route must keep working for a guest whether or not the internal - // token is even configured. That is why this checks `deps.internalToken` - // directly instead of calling `requireInternalToken` (which fails closed) - // and why the token is checked for presence before `tokenMatches` — - // `tokenMatches` takes a REQUIRED `expected: string`, so calling it with an - // unset/empty token would hash the empty string and (worse) invite a caller - // to "authenticate" with an empty `X-Internal-Token` against an unconfigured - // server. Mirrors the "empty token ⇒ treated as unset" rule at - // `auth.ts:52`. NOTE: `entitlements.ts`'s `/grant` also calls the full - // `serializeOrder` — that route sits behind `requireInternalToken` (a - // server-to-server POST), so it is unaffected by and intentionally - // untouched by this change. - app.get("/orders/:orderId", async (c) => { - const params = orderPathParams.safeParse(c.req.param()); - if (!params.success) return c.json({ error: "invalid path parameter" }, 400); - const order = await deps.orderStore.getById(toOrderId(params.data.orderId)); - if (order === null) return c.json({ ok: false, reason: "ORDER_NOT_FOUND" }, 404); - const authorized = - deps.internalToken !== undefined && - deps.internalToken.length > 0 && - tokenMatches(c.req.header("X-Internal-Token"), deps.internalToken); - return c.json( - { ok: true, order: authorized ? serializeOrder(order) : serializePublicOrder(order) }, - 200, - ); - }); - - app.post("/internal/expire-orders", async (c) => { - const denied = requireInternalToken(c, deps.internalToken); - if (denied !== null) return denied; - const expired = await expireOrders(expireDeps); - return c.json({ ok: true, expired }, 200); - }); - - return app; -} - -/** Wire shape of an order (§7) — totals from `order_totals`, snapshots from lines. - * Extended ADDITIVELY for the admin Orders console with `createdAt` + - * `customerId` (existing consumers ignore unknown fields). */ -export function serializeOrder(order: Order): Record { - return { - id: order.id, - state: order.state, - currency: order.currency, - paymentMethod: order.paymentMethod, - buyerRef: order.buyerRef, - customerId: order.customerId, - holdExpiresAt: order.holdExpiresAt, - createdAt: order.createdAt, - reconciliationFlag: order.reconciliationFlag, - // The admin disposition once the flag was resolved (admin-UX Increment 1); - // null while unflagged/unresolved. Additive — existing consumers ignore it. - reconciliationResolution: order.reconciliationResolution, - // The shipping fulfillment once recorded (admin-UX Increment 1); null until - // the order ships with tracking. Additive — existing consumers ignore it. - fulfillment: order.fulfillment, - // The structured cancellation once recorded via cancelOrder (admin-UX - // Increment 1, "cancel with reason"); null while never cancelled OR - // cancelled via the bare transition (no reason on file). Additive — - // existing consumers ignore it. - cancellation: order.cancellation, - // The immutable ship-to snapshot captured at checkout (ADR-0009); null for a - // historical order (predates capture) or a digital-only order. Additive — - // existing consumers ignore it. - shippingAddress: order.shippingAddress, - totals: { - currency: order.totals.currency, - subtotalCents: order.totals.subtotal, - discountCents: order.totals.discount, - shippingCents: order.totals.shipping, - taxCents: order.totals.tax, - totalCents: order.totals.total, - appliedCouponCode: order.totals.appliedCouponCode, - // ADR-0009 (admin display-only juxtaposition): the chosen shipping zone, - // read off the totals' method snapshot, so the admin can render the - // captured ship-to country NEXT TO the priced zone and spot a "domestic - // zone / foreign country" mismatch. No matching/validation — two facts, - // side by side. Null when no zone was selected. - shippingZoneId: shippingZoneIdOf(order.totals.shippingMethodSnapshot), - }, - lines: order.lines.map((l) => ({ - sku: l.sku, - title: l.title, - unitPriceCents: l.unitPrice, - currency: l.currency, - quantity: l.quantity, - fulfillmentKind: l.fulfillmentKind, - })), - }; -} - -/** - * Public (unauthenticated, capability-URL) projection of an order — ADR-0010 - * §2 / PR D. A **WHITELIST**, not a delete-list: every key is added here - * explicitly, so a future additive `Order` field is PRIVATE by default — the - * inverse of `serializeOrder`'s "additive — existing consumers ignore it" - * habit, which is exactly how `shippingAddress` became silently public under - * ADR-0009. If this is ever "simplified" into `{ ...serializeOrder(order), - * delete x }`, the next field added to `serializeOrder` leaks by default — - * don't. - * - * Omits `buyerRef`, `customerId`, `shippingAddress`, `reconciliationFlag`, - * `reconciliationResolution` ENTIRELY (never `null`): a client must not be - * able to distinguish "redacted" from "absent" and probe for the real shape. - * `fulfillment`/`cancellation` stay present but TRIMMED — the carrier/tracking - * info and the cancellation reason are a legitimate guest read, but - * `recordedBy`/`cancelledBy` (staff identity) and `recordedAt`/`detail` (an - * audit witness / free text) are not. - * - * A GUEST has no session, so `GET /me/orders/:orderId` is not a fallback for - * this read — until a dedicated order-confirmation page exists, an - * unauthenticated caller cannot see their own ship-to via this route. The - * full view remains behind a session (`GET /me/orders/:orderId`) or a valid - * `X-Internal-Token` (this same route, see below). The widening path, if the - * confirmation UX ever needs a shipping hint, is a DERIVED - * `shippingAddressSummary` (city + country + a masked postal code) — - * never reopen the raw `shippingAddress` snapshot on this route. - */ -export function serializePublicOrder(order: Order): Record { - return { - id: order.id, - state: order.state, - currency: order.currency, - paymentMethod: order.paymentMethod, - holdExpiresAt: order.holdExpiresAt, - createdAt: order.createdAt, - totals: { - currency: order.totals.currency, - subtotalCents: order.totals.subtotal, - discountCents: order.totals.discount, - shippingCents: order.totals.shipping, - taxCents: order.totals.tax, - totalCents: order.totals.total, - appliedCouponCode: order.totals.appliedCouponCode, - shippingZoneId: shippingZoneIdOf(order.totals.shippingMethodSnapshot), - }, - lines: order.lines.map((l) => ({ - sku: l.sku, - title: l.title, - unitPriceCents: l.unitPrice, - currency: l.currency, - quantity: l.quantity, - fulfillmentKind: l.fulfillmentKind, - })), - fulfillment: - order.fulfillment === null - ? null - : { - carrier: order.fulfillment.carrier, - trackingNumber: order.fulfillment.trackingNumber, - trackingUrl: order.fulfillment.trackingUrl, - shippedAt: order.fulfillment.shippedAt, - }, - cancellation: - order.cancellation === null - ? null - : { reason: order.cancellation.reason, cancelledAt: order.cancellation.cancelledAt }, - }; -} - -/** Wire shape of an admin Orders-list row (view-only projection). Money stays an - * integer minor unit + an ISO-4217 currency string; `reconciliationFlag` is the - * boolean list badge (the free-text detail lives only on the full order). */ -export function serializeOrderSummary(summary: OrderSummary): Record { - return { - id: summary.id, - state: summary.state, - currency: summary.currency, - buyerRef: summary.buyerRef, - customerId: summary.customerId, - paymentMethod: summary.paymentMethod, - createdAt: summary.createdAt, - totalCents: summary.total, - reconciliationFlag: summary.reconciliationFlag, - }; -} - -/** Read the chosen shipping zone id off `order_totals.shippingMethodSnapshot` - * (shape `{ zoneId, methodId }` — an opaque `unknown` on the model). Returns null - * when absent/malformed. Display-only (ADR-0009): never used for matching. */ -function shippingZoneIdOf(snapshot: unknown | null): string | null { - if (snapshot === null || typeof snapshot !== "object") return null; - const zoneId = (snapshot as { zoneId?: unknown }).zoneId; - return typeof zoneId === "string" ? zoneId : null; -} - -function serializeIntent(intent: PaymentIntentHandle): Record { - return { gateway: intent.gateway, intentId: intent.intentId, clientAction: intent.clientAction }; -} - -function checkoutFailure(c: Context, reason: CreateOrderFailure): Response { - const body = { ok: false as const, reason }; - switch (reason) { - case "CART_NOT_FOUND": - case "COUPON_NOT_FOUND": - return c.json(body, 404); - case "INVALID_SHIPPING_ADDRESS": - // Malformed input (a required ship-to field empty / over-length) — a 400, - // like the top-level zod parse failure, not a 409 conflict (ADR-0009). - return c.json(body, 400); - case "PAYMENT_INTENT_FAILED": - // The UPSTREAM gateway failed (down / rejecting), not the request — a 502, - // never a 409. The `pending` order row is intentionally kept: a same-key - // retry re-issues the SAME intent, and expireOrders sweeps it at TTL. - return c.json(body, 502); - case "CART_EMPTY": - case "CART_CHECKED_OUT": - case "RESERVATION_LOST": - case "PRODUCT_NOT_PRICED": - case "CURRENCY_MISMATCH": - case "SHIPPING_METHOD_NOT_FOUND": - case "SHIPPING_RATE_NOT_FOUND": - case "COUPON_NOT_ACTIVE": - case "COUPON_MIN_SUBTOTAL": - case "COUPON_EXHAUSTED": - case "COUPON_MAX_PER_CUSTOMER": - case "COUPON_CURRENCY_MISMATCH": - return c.json(body, 409); - } -} - -function quoteFailure(c: Context, reason: QuoteFailure): Response { - const body = { ok: false as const, reason }; - switch (reason) { - case "COUPON_NOT_FOUND": - return c.json(body, 404); - case "SHIPPING_METHOD_NOT_FOUND": - case "SHIPPING_RATE_NOT_FOUND": - case "COUPON_NOT_ACTIVE": - case "COUPON_MIN_SUBTOTAL": - case "COUPON_EXHAUSTED": - case "COUPON_CURRENCY_MISMATCH": - return c.json(body, 409); - } -} - -async function readJson(c: { req: { json(): Promise } }): Promise { - try { - return await c.req.json(); - } catch { - return undefined; - } -} diff --git a/packages/service/src/routes/product-commerce.ts b/packages/service/src/routes/product-commerce.ts deleted file mode 100644 index dc62ffde..00000000 --- a/packages/service/src/routes/product-commerce.ts +++ /dev/null @@ -1,581 +0,0 @@ -import { - activateProductCommerce, - cents, - currency, - deactivateProductCommerce, - deactivateProductVariant, - getProductCommerce, - idempotencyKey, - InvalidProductFieldError, - listProductVariants, - MissingProductIdError, - MissingVariantKeyError, - money, - productId, - softDeleteProductCommerce, - sku, - SkuConflictError, - SkuHeldStockError, - SkuStockConflictError, - updateProductVariantFields, - upsertProductCommerce, - upsertProductVariant, - type ProductCommerce, - type ProductCommerceDeps, - type ProductVariant, - type ProductVariantSummary, -} from "@otta-sh/domain"; -import { type Context, Hono } from "hono"; -import { tokenMatches } from "../auth.js"; -import { - deactivateProductVariantBody, - editProductVariantBody, - lifecycleProductCommerceBody, - upsertProductCommerceBody, - upsertProductVariantBody, -} from "../schemas.js"; - -// The domain use-case's own deps type is the single source of truth (N3); -// re-exported so existing importers keep working. -export type { ProductCommerceDeps }; - -/** - * The use-case deps, plus the one thing a ROUTE needs that a use-case does not: - * the shared secret that unlocks the operator's view of a read. - * - * OPTIONAL, and an unset or empty value means the unlock is UNAVAILABLE rather - * than open — the rule `auth.ts` states and `routes/orders.ts` follows on its - * own dual-projection read. It is also why the check below tests the configured - * token for presence BEFORE comparing: `tokenMatches` takes a required - * `expected`, so calling it with an unset token would hash the empty string and - * invite a caller to "authenticate" with an empty header against a server that - * configured none. - */ -export interface ProductCommerceRoutesDeps extends ProductCommerceDeps { - internalToken?: string; -} - -/** - * Product-commerce routes — 1:1 with the port (Phase 1 §7): `PUT`/`GET`/ - * `DELETE /products/:id/commerce`, the two publish-gate actions, and the - * variant surface (`GET /products/:id/variants` plus one route per variant - * WRITER — see the block above them). No status-code-as-logic beyond schema/ - * validation failures and the domain's own rejections, each a structured body - * carrying a machine code — `MISSING_PRODUCT_ID`, `MISSING_VARIANT_KEY`, the - * three sku refusals (`SKU_TAKEN`, `SKU_STOCK_CONFLICT`, `SKU_HELD_STOCK`) and - * the variant edit's compare-and-set outcomes (`VARIANT_NOT_FOUND`, - * `STALE_EDIT`, `CURRENCY_MISMATCH`); money on the wire is an integer + - * ISO-4217 string, and an absent price is `null` rather than zero. - */ -export function productCommerceRoutes(deps: ProductCommerceRoutesDeps): Hono { - const app = new Hono(); - - app.put("/:id/commerce", async (c) => { - const id = c.req.param("id"); - const key = c.req.header("Idempotency-Key"); - if (key === undefined || key.length === 0) { - return c.json({ error: "missing Idempotency-Key header" }, 400); - } - if (id.length === 0) { - return c.json({ error: "MISSING_PRODUCT_ID" }, 400); - } - const parsed = upsertProductCommerceBody.safeParse(await readJson(c)); - if (!parsed.success) { - return c.json({ error: "invalid request body", issues: parsed.error.issues }, 400); - } - const body = parsed.data; - - try { - const row = await upsertProductCommerce( - { productCommerce: deps.productCommerce, inventory: deps.inventory }, - { - productId: productId(id), - sku: body.sku !== undefined ? sku(body.sku) : undefined, - price: - body.price !== undefined - ? money(cents(body.price.amount), currency(body.price.currency)) - : undefined, - title: body.title, - taxClass: body.taxClass, - weightGrams: body.weightGrams, - lengthMm: body.lengthMm, - widthMm: body.widthMm, - heightMm: body.heightMm, - productKind: body.productKind, - contentUpdatedAt: body.contentUpdatedAt, - }, - idempotencyKey(key), - body.initialOnHand, - ); - return c.json(serialize(row), 200); - } catch (err) { - if (err instanceof MissingProductIdError) { - return c.json({ error: "MISSING_PRODUCT_ID" }, 400); - } - // Review F2: a live-SKU conflict is a structured 409, not an opaque - // 500 — the most likely real merchant input error deserves a shape - // the panel can render. - if (err instanceof SkuConflictError) { - return c.json({ ok: false, error: "SKU_TAKEN", sku: err.sku }, 409); - } - // A SKU RENAME the domain refuses, in the same shape: a machine code plus - // the operands the caller has to act on — both skus, or the sku and how - // many live holds still name it. Never the 500 an unmapped throw would be, - // and never the domain's internal sentence. - if (err instanceof SkuStockConflictError) { - return c.json( - { ok: false, error: "SKU_STOCK_CONFLICT", fromSku: err.fromSku, toSku: err.toSku }, - 409, - ); - } - if (err instanceof SkuHeldStockError) { - return c.json( - { ok: false, error: "SKU_HELD_STOCK", sku: err.sku, liveHolds: err.liveHolds }, - 409, - ); - } - throw err; - } - }); - - app.get("/:id/commerce", async (c) => { - const id = c.req.param("id"); - if (id.length === 0) { - return c.json({ error: "MISSING_PRODUCT_ID" }, 400); - } - const row = await getProductCommerce(deps.productCommerce, productId(id)); - return c.json(row === null ? null : serialize(row), 200); - }); - - app.delete("/:id/commerce", async (c) => { - const id = c.req.param("id"); - const key = c.req.header("Idempotency-Key"); - if (key === undefined || key.length === 0) { - return c.json({ error: "missing Idempotency-Key header" }, 400); - } - if (id.length === 0) { - return c.json({ error: "MISSING_PRODUCT_ID" }, 400); - } - await softDeleteProductCommerce(deps.productCommerce, productId(id), idempotencyKey(key)); - return c.json({ ok: true }, 200); - }); - - // The afterPublish→activate follow-up (Phase 1 §4/§6 step 7): a dedicated - // action route, not an extra PUT field — `upsert` deliberately never - // touches `active`/`deletedAt` (see the port doc / `UpsertProductCommerceInput`), - // so reactivation gets its own narrowly-scoped surface, mirroring the - // `/inventory/reserve|commit|release` action-route convention. The body - // carries only the ORDERING WATERMARK (`contentUpdatedAt`) the store gates - // on so a stale, out-of-order publish is a no-op (out-of-order delivery - // converges). - app.post("/:id/commerce/activate", async (c) => { - const id = c.req.param("id"); - const key = c.req.header("Idempotency-Key"); - if (key === undefined || key.length === 0) { - return c.json({ error: "missing Idempotency-Key header" }, 400); - } - if (id.length === 0) { - return c.json({ error: "MISSING_PRODUCT_ID" }, 400); - } - const parsed = lifecycleProductCommerceBody.safeParse(await readJson(c)); - if (!parsed.success) { - return c.json({ error: "invalid request body", issues: parsed.error.issues }, 400); - } - await activateProductCommerce( - deps.productCommerce, - productId(id), - idempotencyKey(key), - parsed.data.contentUpdatedAt, - ); - return c.json({ ok: true }, 200); - }); - - // The afterUnpublish→deactivate follow-up (Phase 1 §4/§6 step 7): the - // mirror of the activate route, closing the publish gate. A dedicated - // action route (not an extra PUT field) for the same reason activate is — - // `upsert` never touches `active`/`deletedAt`. The body carries only the - // ordering watermark (`contentUpdatedAt`) — see the activate route. - app.post("/:id/commerce/deactivate", async (c) => { - const id = c.req.param("id"); - const key = c.req.header("Idempotency-Key"); - if (key === undefined || key.length === 0) { - return c.json({ error: "missing Idempotency-Key header" }, 400); - } - if (id.length === 0) { - return c.json({ error: "MISSING_PRODUCT_ID" }, 400); - } - const parsed = lifecycleProductCommerceBody.safeParse(await readJson(c)); - if (!parsed.success) { - return c.json({ error: "invalid request body", issues: parsed.error.issues }, 400); - } - await deactivateProductCommerce( - deps.productCommerce, - productId(id), - idempotencyKey(key), - parsed.data.contentUpdatedAt, - ); - return c.json({ ok: true }, 200); - }); - - // -- Variants: one route per WRITER (ADR-0016) --------------------------- - // - // Four routes, and the shape of them is the decision: the CMS sync declares - // a variant's presence and name through `PUT`, the admin prices it through - // `PATCH`, the sync drops it through the `/deactivate` action, and everyone - // reads it through `GET`. `PUT` and `PATCH` are not two spellings of one - // upsert — they are the two writers ADR-0016 keeps apart, and their bodies - // (`upsertProductVariantBody` / `editProductVariantBody`, both `.strict()`) - // each REJECT the other's fields rather than dropping them, so crossing the - // line is a 400 an integrator can read and not a silent 200. - // - // The variant key travels in the PATH because it IS the identity: it is - // immutable, it is half the primary key, and there is no field on either - // body that could change it. `MISSING_VARIANT_KEY` mirrors the - // `MISSING_PRODUCT_ID` guard above — routing already forbids an empty - // segment, so the route-level check covers the whitespace-only case and the - // `catch` covers whatever an adapter decides is empty. - // - // THE WRITE REPLIES ARE NOT LIST ROWS, and the asymmetry is a decision. `PUT` - // and `PATCH` answer the row they just wrote, WITHOUT `inStock`: a write reply - // states what the write did, and the store returns the stored row — no stock - // is joined for it, so an `inStock` here could only be invented. Emitting a - // hardcoded `false` beside a size that has units would be worse than omitting - // it, and re-reading inventory to fill the field would put a second query on - // every write to serve a value the caller did not ask for. A caller that wants - // the stock signal reads the list, which joins it in the same statement. - - /** - * The variants read, in TWO PROJECTIONS off one route — exactly the shape - * `GET /orders/:orderId` already uses: the same URL answers the public view - * to anyone and the operator's view to a caller holding `X-Internal-Token`. - * - * ANONYMOUS ⇒ LIVE ROWS ONLY. The write gate covers non-GET verbs, so this is - * a storefront-reachable read, and the caller it exists for is the picker, - * which needs the sizes a shopper may buy and nothing else. An orphan is a - * size the merchant DISCONTINUED; publishing it here would put its name and - * its last price on an anonymous read — the shape of a catalogue somebody - * stopped selling, and what they used to charge — to serve a picker that must - * not render it. Same rule as the unit cost omitted from the commerce read - * beside this one. - * - * WITH THE TOKEN ⇒ EVERY ROW, orphans included and flagged by a non-null - * `orphanedAt`. Surfacing the tombstone is the whole point for an operator: it - * may still hold stock and still sit on live order lines, and hiding it is how - * units get stranded. It is also the only way `deactivate`'s effect is - * OBSERVABLE over HTTP at all — without this mode the transition can be - * driven and never seen, and a later console screen would have to either - * build on the public read (which lies to it by omission) or add its own - * route as a hidden prerequisite. - * - * The token is checked for presence before it is compared: unset or empty - * means the unlock is UNAVAILABLE, never open (see `ProductCommerceRoutesDeps`). - * A wrong token is not an error here — it simply does not unlock, and the - * caller gets the public projection, which is the same stance the order read - * takes and keeps this from becoming an oracle for whether a token exists. - */ - app.get("/:id/variants", async (c) => { - const id = c.req.param("id"); - if (id.length === 0) { - return c.json({ error: "MISSING_PRODUCT_ID" }, 400); - } - const rows = await listProductVariants(deps.productCommerce, productId(id)); - const authorized = - deps.internalToken !== undefined && - deps.internalToken.length > 0 && - tokenMatches(c.req.header("X-Internal-Token"), deps.internalToken); - const visible = authorized ? rows : rows.filter((row) => row.orphanedAt === null); - // An unknown product, one that has declared no variants, and — on the - // public projection — one whose every size is orphaned are all `[]`: - // absence, never a 404. The first of those is the state of the live catalog. - return c.json({ variants: visible.map(serializeVariantSummary) }, 200); - }); - - // The CMS-SYNC channel. Writes presence + the display-name cache and NOTHING - // commercial; it never refuses presence and never raises a sku conflict (the - // commerce database does not get a vote on whether a size exists), so the - // only refusals here are the two identity ones. - app.put("/:id/variants/:variantKey", async (c) => { - const id = c.req.param("id"); - const variantKey = c.req.param("variantKey"); - const key = c.req.header("Idempotency-Key"); - if (key === undefined || key.length === 0) { - return c.json({ error: "missing Idempotency-Key header" }, 400); - } - if (id.length === 0) { - return c.json({ error: "MISSING_PRODUCT_ID" }, 400); - } - if (variantKey.trim().length === 0) { - return c.json({ error: "MISSING_VARIANT_KEY" }, 400); - } - const parsed = upsertProductVariantBody.safeParse(await readJson(c)); - if (!parsed.success) { - return c.json({ error: "invalid request body", issues: parsed.error.issues }, 400); - } - const body = parsed.data; - try { - const row = await upsertProductVariant( - deps.productCommerce, - { - productId: productId(id), - variantKey, - ...(body.title !== undefined ? { title: body.title } : {}), - ...(body.contentUpdatedAt !== undefined - ? { contentUpdatedAt: body.contentUpdatedAt } - : {}), - }, - idempotencyKey(key), - ); - return c.json(serializeVariant(row), 200); - } catch (err) { - return variantIdentityFailure(c, err); - } - }); - - // The guarded ADMIN edit: sku + price under a compare-and-set. Every typed - // outcome the port defines gets the envelope its neighbours already use — - // the three sku refusals in the same `{ ok: false, error, …operands }` 409 - // the upsert above answers with, and the three non-`ok` results mapped the - // way the admin console's own product edit maps them (404 not-found, 409 - // stale carrying the fresh watermark, 409 currency carrying the currency the - // row is anchored to). Nothing here is a 500. - app.patch("/:id/variants/:variantKey", async (c) => { - const id = c.req.param("id"); - const variantKey = c.req.param("variantKey"); - const key = c.req.header("Idempotency-Key"); - if (key === undefined || key.length === 0) { - return c.json({ error: "missing Idempotency-Key header" }, 400); - } - if (id.length === 0) { - return c.json({ error: "MISSING_PRODUCT_ID" }, 400); - } - if (variantKey.trim().length === 0) { - return c.json({ error: "MISSING_VARIANT_KEY" }, 400); - } - const parsed = editProductVariantBody.safeParse(await readJson(c)); - if (!parsed.success) { - return c.json({ error: "invalid request body", issues: parsed.error.issues }, 400); - } - const body = parsed.data; - try { - const res = await updateProductVariantFields( - { productCommerce: deps.productCommerce, inventory: deps.inventory }, - { - productId: productId(id), - variantKey, - ...(body.sku !== undefined ? { sku: sku(body.sku) } : {}), - ...(body.price !== undefined - ? { price: money(cents(body.price.amount), currency(body.price.currency)) } - : {}), - // No `title`: CMS-owned, and the body `.strict()`-rejects one. - }, - idempotencyKey(key), - body.expectedUpdatedAt, - ); - if (res.ok) return c.json(serializeVariant(res.variant), 200); - if (res.reason === "not_found") { - // Unknown key OR an orphaned row: an edit is neither a create nor a - // resurrection — the way back is the CMS re-declaring the key. - return c.json({ ok: false, error: "VARIANT_NOT_FOUND" }, 404); - } - if (res.reason === "stale") { - return c.json( - { - ok: false, - error: "STALE_EDIT", - currentUpdatedAt: res.current.updatedAt.toISOString(), - }, - 409, - ); - } - // currency_mismatch. `currency` is THE VARIANT'S OWN stored currency and - // only that — it is read off the row the store handed back, so it is - // `null` in the archetypal case, a FIRST pricing refused because it - // disagreed with the PRODUCT's currency rather than with anything this - // row holds. That is not a gap to paper over with the product's - // currency: the field states what this row is anchored to, `null` means - // "nothing yet", and a console renders the conflict from the product it - // already has on screen. Absent is null, never a coerced string, and - // never the other row's value smuggled in under this name. - return c.json( - { ok: false, error: "CURRENCY_MISMATCH", currency: res.current.price?.currency ?? null }, - 409, - ); - } catch (err) { - if (err instanceof InvalidProductFieldError) { - return c.json({ ok: false, error: "INVALID_FIELD", field: err.field }, 400); - } - if (err instanceof SkuConflictError) { - return c.json({ ok: false, error: "SKU_TAKEN", sku: err.sku }, 409); - } - if (err instanceof SkuStockConflictError) { - return c.json( - { ok: false, error: "SKU_STOCK_CONFLICT", fromSku: err.fromSku, toSku: err.toSku }, - 409, - ); - } - if (err instanceof SkuHeldStockError) { - return c.json( - { ok: false, error: "SKU_HELD_STOCK", sku: err.sku, liveHolds: err.liveHolds }, - 409, - ); - } - return variantIdentityFailure(c, err); - } - }); - - // The ORPHAN transition — deactivation, NEVER deletion: the row keeps its - // sku, its price and its inventory, because an orphan may still hold stock - // and still sit on live order lines. An unknown key is a no-op, not a 404 - // (no row is minted either way), so this answers `{ ok: true }` uniformly, - // exactly like the product-level deactivate above. - app.post("/:id/variants/:variantKey/deactivate", async (c) => { - const id = c.req.param("id"); - const variantKey = c.req.param("variantKey"); - const key = c.req.header("Idempotency-Key"); - if (key === undefined || key.length === 0) { - return c.json({ error: "missing Idempotency-Key header" }, 400); - } - if (id.length === 0) { - return c.json({ error: "MISSING_PRODUCT_ID" }, 400); - } - if (variantKey.trim().length === 0) { - return c.json({ error: "MISSING_VARIANT_KEY" }, 400); - } - const parsed = deactivateProductVariantBody.safeParse(await readJson(c)); - if (!parsed.success) { - return c.json({ error: "invalid request body", issues: parsed.error.issues }, 400); - } - try { - await deactivateProductVariant( - deps.productCommerce, - productId(id), - variantKey, - idempotencyKey(key), - parsed.data.contentUpdatedAt, - ); - } catch (err) { - return variantIdentityFailure(c, err); - } - return c.json({ ok: true }, 200); - }); - - return app; -} - -/** The two identity refusals every variant writer shares, mapped to the 400 - * `MissingProductIdError` already has — `MissingVariantKeyError`'s docblock - * names this mapping as the one it was waiting for, since a row minted under - * an empty key could never be addressed, edited or deactivated again. Anything - * else rethrows and keeps its 500. */ -function variantIdentityFailure(c: Context, err: unknown): Response { - if (err instanceof MissingProductIdError) { - return c.json({ error: "MISSING_PRODUCT_ID" }, 400); - } - if (err instanceof MissingVariantKeyError) { - return c.json({ error: "MISSING_VARIANT_KEY" }, 400); - } - throw err; -} - -/** - * Wire shape of one variant, for both the list and the two write replies. - * - * `price` is an integer minor-unit amount plus an ISO-4217 string, and ABSENT - * IS ABSENT: a variant with no price serializes `null` — never `0`, never a - * zero-amount object. A cleared price (a resurrect whose currency no longer - * matched the product's) is exactly that state, and rendering it as zero would - * turn "nobody has priced this size" into "this size is free". - * - * `idempotencyKey` and `contentUpdatedAt` never cross this wire, matching the - * narrowing `ProductVariantSummary` already applies to them: both are write-path - * bookkeeping, and projecting them invites a caller to branch on machinery it - * does not own. `updatedAt` stays — it is the compare-and-set watermark a later - * edit must pass back. - */ -function serializeVariant(row: ProductVariant | ProductVariantSummary): Record { - return { - productId: row.productId, - variantKey: row.variantKey, - sku: row.sku, - price: row.price === null ? null : { amount: row.price.amount, currency: row.price.currency }, - title: row.title, - // The orphan tombstone — a state a console must render distinctly rather - // than hide, because an orphan may still hold units and sit on live orders. - orphanedAt: row.orphanedAt === null ? null : row.orphanedAt.toISOString(), - createdAt: row.createdAt.toISOString(), - updatedAt: row.updatedAt.toISOString(), - }; -} - -/** - * The LIST row: the shape above plus the stock signal the same statement joined. - * - * `onHand` IS DELIBERATELY NOT PROJECTED, and `inStock` stands in for it — the - * same decision, and the same reason, as `unitCost`'s omission from the commerce - * `GET` above. The write gate covers non-GET verbs only, so `GET - * /products/:id/variants` is a storefront-reachable read, and an exact per-sku - * stock count is operational data a buyer must not be handed. `inStock` is the - * coarse display signal the catalog batch already publishes - * (`ProductCommerceView.inStock`: `on_hand > 0` at read time, a join miss - * reading false) — enough to grey out a size in a picker, and not a number - * anyone can inventory the warehouse with. It is a PURCHASABILITY signal, not a - * count, which is why folding the port's "unknown" (`null`) into `false` is - * correct here and would be wrong on any surface that renders the number: a - * size whose stock nobody knows is not one to offer. An admin surface that needs - * the count reads it behind the internal token, where cost already lives. - * - * IT IS A STOCK SIGNAL ONLY, AND IT IS NOT PURCHASABILITY ON ITS OWN. `inStock` - * reads `true` for a stocked size of a product that is unpublished, or even - * soft-deleted — this projection knows about the variant row and its units, and - * nothing about the row above it. Purchasability has always been a JOIN in this - * codebase (`purchasable ⟺ commerce !== null && commerce.active`), decided by - * the plugin and not by a store projection, and that is unchanged one level - * down: a caller renders a size as buyable only when its PARENT's `active` says - * the product is, and this field says the size has units. Reading `inStock` - * alone offers sizes of products nobody has published. - */ -function serializeVariantSummary(row: ProductVariantSummary): Record { - return { - ...serializeVariant(row), - inStock: row.onHand !== null && row.onHand > 0, - }; -} - -function serialize(row: ProductCommerce): Record { - return { - productId: row.productId, - sku: row.sku, - price: row.price === null ? null : { amount: row.price.amount, currency: row.price.currency }, - title: row.title, - taxClass: row.taxClass, - // Increment 2 slice 5: compare-at (display data) + inventory policy round- - // trip on this raw commerce read. `unitCost` is DELIBERATELY OMITTED — this - // GET is NOT behind the internal token (the write gate only covers non-GET - // verbs), so it is a storefront-reachable read path, and unit cost is - // admin-only margin data that must never leak to a buyer. Cost is served - // ONLY by the internal-token admin product detail. Pinned by a test. - compareAt: - row.compareAtPrice === null - ? null - : { amount: row.compareAtPrice.amount, currency: row.compareAtPrice.currency }, - inventoryPolicy: row.inventoryPolicy, - weightGrams: row.weightGrams, - lengthMm: row.lengthMm, - widthMm: row.widthMm, - heightMm: row.heightMm, - productKind: row.productKind, - active: row.active, - deletedAt: row.deletedAt === null ? null : row.deletedAt.toISOString(), - contentUpdatedAt: row.contentUpdatedAt, - createdAt: row.createdAt.toISOString(), - updatedAt: row.updatedAt.toISOString(), - }; -} - -async function readJson(c: { req: { json(): Promise } }): Promise { - try { - return await c.req.json(); - } catch { - return undefined; - } -} diff --git a/packages/service/src/routes/reports.ts b/packages/service/src/routes/reports.ts deleted file mode 100644 index 3d0573c4..00000000 --- a/packages/service/src/routes/reports.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { - getLowStockReport, - getOrdersByStatusReport, - getRevenueReport, - getTopProductsReport, - type PeriodBucket, - type ReportingStore, - ReportRangeTooWideError, - type SettingsStore, -} from "@otta-sh/domain"; -import type { Context } from "hono"; -import { Hono } from "hono"; -import { - lowStockQuery, - ordersByStatusQuery, - reportRevenueQuery, - topProductsQuery, -} from "../schemas.js"; -import { requireInternalToken } from "./internal-auth.js"; - -export interface ReportsDeps { - reportingStore: ReportingStore; - settingsStore: SettingsStore; - /** Admin read guard — reports expose merchant financial/operational data - * (revenue, order counts, inventory levels), NOT public storefront data, so - * every /reports/* read requires the internal token like other admin surface - * (review J5). Unset ⇒ 503 (disabled), never silently open. */ - internalToken?: string; -} - -/** - * Phase 7 read-only reporting endpoints (§6), 1:1 with `ReportingStore`. All - * money is serialized as integer minor units + an ISO-4217 currency string — no - * floats on the wire. The three date-ranged endpoints reject a `from`/`to` window - * wider than 400 days with a `400` + structured error (the domain use-case throws - * `ReportRangeTooWideError`; a plugin-side date-picker cap is only a UX nicety). - * Every endpoint is admin-guarded by the internal token (review J5) — this is - * merchant-sensitive data, not public catalog data. - */ -export function reportsRoutes(deps: ReportsDeps): Hono { - const app = new Hono(); - - // Admin guard on EVERY /reports/* read (merchant financial/operational data). - // Defense-in-depth: the AUTHORITATIVE guard is the parent-level - // `app.use("/reports/*")` in `createApp` (ADR-0010), which also covers any - // sibling sub-app mounted at this prefix — something this one cannot. - app.use("/*", async (c, next) => { - const denied = requireInternalToken(c, deps.internalToken); - if (denied !== null) return denied; - await next(); - }); - - app.get("/revenue", async (c) => { - const parsed = reportRevenueQuery.safeParse({ - from: c.req.query("from"), - to: c.req.query("to"), - interval: c.req.query("interval"), - }); - if (!parsed.success) return invalidQuery(c, parsed.error.issues); - try { - const buckets = await getRevenueReport( - deps.reportingStore, - { from: parsed.data.from, to: parsed.data.to }, - parsed.data.interval, - ); - return c.json({ ok: true, buckets: buckets.map(serializeBucket) }, 200); - } catch (err) { - return mapRangeError(c, err); - } - }); - - app.get("/orders-by-status", async (c) => { - const parsed = ordersByStatusQuery.safeParse({ - from: c.req.query("from"), - to: c.req.query("to"), - }); - if (!parsed.success) return invalidQuery(c, parsed.error.issues); - try { - const counts = await getOrdersByStatusReport(deps.reportingStore, { - from: parsed.data.from, - to: parsed.data.to, - }); - return c.json({ ok: true, counts }, 200); - } catch (err) { - return mapRangeError(c, err); - } - }); - - app.get("/top-products", async (c) => { - const parsed = topProductsQuery.safeParse({ - from: c.req.query("from"), - to: c.req.query("to"), - metric: c.req.query("metric"), - limit: c.req.query("limit"), - }); - if (!parsed.success) return invalidQuery(c, parsed.error.issues); - try { - const products = await getTopProductsReport( - deps.reportingStore, - { from: parsed.data.from, to: parsed.data.to }, - parsed.data.metric, - parsed.data.limit, - ); - return c.json({ ok: true, products }, 200); - } catch (err) { - return mapRangeError(c, err); - } - }); - - // Low-stock has no date range (an inventory snapshot); threshold defaults from - // SettingsStore when omitted (§4.2). - app.get("/low-stock", async (c) => { - const parsed = lowStockQuery.safeParse({ threshold: c.req.query("threshold") }); - if (!parsed.success) return invalidQuery(c, parsed.error.issues); - const rows = await getLowStockReport( - { reportingStore: deps.reportingStore, settingsStore: deps.settingsStore }, - parsed.data.threshold, - ); - return c.json({ ok: true, rows }, 200); - }); - - return app; -} - -/** - * One revenue bucket on the wire. `refundedCents` sits ALONGSIDE `revenueCents` - * — never subtracted from it — in the same integer minor units and the same - * `currency`, because the two answer different questions and netting them would - * make a refunded sale indistinguishable from one that never happened. - * - * The key is emitted UNCONDITIONALLY, zero included: `0` is the fact "nothing - * came back in this bucket", and a client tells that apart from "this service - * predates the field" by the key's presence, never by its value. - */ -function serializeBucket(b: PeriodBucket): Record { - return { - bucketStart: b.bucketStart, - currency: b.currency, - revenueCents: b.revenueCents, - refundedCents: b.refundedCents, - }; -} - -function invalidQuery(c: Context, issues: unknown): Response { - return c.json({ ok: false, error: "invalid query", issues }, 400); -} - -/** A too-wide range is a structured 400 (never a silent clamp); anything else - * rethrows to the app's 500 error envelope. */ -function mapRangeError(c: Context, err: unknown): Response { - if (err instanceof ReportRangeTooWideError) { - return c.json({ ok: false, error: "range_too_wide", maxDays: 400, message: err.message }, 400); - } - throw err; -} diff --git a/packages/service/src/routes/rules-admin.ts b/packages/service/src/routes/rules-admin.ts deleted file mode 100644 index ebd13492..00000000 --- a/packages/service/src/routes/rules-admin.ts +++ /dev/null @@ -1,623 +0,0 @@ -import { - cents, - currency as toCurrency, - deleteTaxClass, - type CouponListCursor, - type CouponListFilter, - type CouponStore, - type CouponSummary, - type ProductCommerceStore, - type ShippingRulesStore, - type TaxRulesStore, -} from "@otta-sh/domain"; -import { Hono } from "hono"; -import { z } from "zod"; -import { - couponBody, - couponCodePathParams, - couponIdPathParams, - couponListFilterSchema, - couponsListQuery, - couponUpdateBody, - methodCurrencyPathParams, - methodPathParams, - rateIdPathParams, - shippingMethodBody, - shippingMethodUpdateBody, - shippingRateBody, - shippingRateUpdateBody, - shippingZoneBody, - shippingZoneUpdateBody, - taxClassBody, - taxClassPathParams, - taxClassUpdateBody, - taxRateBody, - taxRateUpdateBody, - zonePathParams, -} from "../schemas.js"; -import { requireInternalToken } from "./internal-auth.js"; - -export interface RulesAdminDeps { - shippingRules: ShippingRulesStore; - taxRules: TaxRulesStore; - couponStore: CouponStore; - // Increment 3 closeout: the tax-class DELETE route composes the - // `deleteTaxClass` use-case, whose in-use guard spans BOTH the tax-config - // aggregate (`taxRules`) and the product aggregate (`productCommerce`). - productCommerce: ProductCommerceStore; - internalToken?: string; -} - -/** - * Phase 6 admin CRUD for shipping / tax / coupon config (§6). Each endpoint is a - * 1:1 serialization of a store method; EVERY endpoint — reads and writes alike — - * requires the privileged internal token (same mechanism as the Phase-5 admin - * transition and the `/reports/*` reads), because this is merchant config, not - * public catalog data. The GET reads in particular expose coupon amounts, caps, - * usage limits and live `usesCount`, so they must not be reachable ungated: the - * app-level SERVICE_API_TOKEN write gate exempts GET/HEAD, so it does NOT cover - * them. There are therefore NO inline `requireInternalToken` calls in this file — - * the parent-level guard in `createApp` is authoritative (ADR-0010) and the - * blanket guard below is its sub-app-local backstop; one guard, no drift. Money - * on the wire is integer minor units, branded via `cents()`/`currency()` at the - * boundary; rates are integer basis points. - */ -export function rulesAdminRoutes(deps: RulesAdminDeps): Hono { - const app = new Hono(); - - // Defense-in-depth guard on EVERY route in this sub-app — reads included - // (merchant shipping/tax/coupon config). The AUTHORITATIVE guard is the - // parent-level `app.use("/admin/*")` in `createApp` (ADR-0010); this one keeps - // the sub-app closed if it is ever mounted somewhere that lacks it. It is NOT - // a substitute: Hono merges sub-app middleware into the parent at mount time, - // so this never covers `adminRoutes`, the sibling sub-app mounted at "/admin" - // before it. - app.use("/*", async (c, next) => { - const denied = requireInternalToken(c, deps.internalToken); - if (denied !== null) return denied; - await next(); - }); - - // -- Shipping -------------------------------------------------------------- - app.post("/shipping/zones", async (c) => { - const parsed = shippingZoneBody.safeParse(await readJson(c)); - if (!parsed.success) return c.json({ error: "invalid request body" }, 400); - const zone = await deps.shippingRules.createZone({ - id: parsed.data.id, - name: parsed.data.name, - regions: parsed.data.regions ?? null, - }); - return c.json({ ok: true, zone }, 201); - }); - - app.get("/shipping/zones", async (c) => { - return c.json({ ok: true, zones: await deps.shippingRules.listZones() }, 200); - }); - - app.post("/shipping/zones/:zoneId/methods", async (c) => { - const params = zonePathParams.safeParse(c.req.param()); - if (!params.success) return c.json({ error: "invalid path parameter" }, 400); - const parsed = shippingMethodBody.safeParse(await readJson(c)); - if (!parsed.success) return c.json({ error: "invalid request body" }, 400); - const method = await deps.shippingRules.createMethod({ - id: parsed.data.id, - zoneId: params.data.zoneId, - name: parsed.data.name, - type: parsed.data.type, - }); - return c.json({ ok: true, method }, 201); - }); - - app.get("/shipping/zones/:zoneId/methods", async (c) => { - const params = zonePathParams.safeParse(c.req.param()); - if (!params.success) return c.json({ error: "invalid path parameter" }, 400); - return c.json( - { ok: true, methods: await deps.shippingRules.listMethods(params.data.zoneId) }, - 200, - ); - }); - - app.post("/shipping/methods/:methodId/rates", async (c) => { - const params = methodPathParams.safeParse(c.req.param()); - if (!params.success) return c.json({ error: "invalid path parameter" }, 400); - const parsed = shippingRateBody.safeParse(await readJson(c)); - if (!parsed.success) return c.json({ error: "invalid request body" }, 400); - const rate = await deps.shippingRules.createRate({ - methodId: params.data.methodId, - currency: toCurrency(parsed.data.currency), - amountCents: cents(parsed.data.amountCents), - minSubtotalCents: - parsed.data.minSubtotalCents === null || parsed.data.minSubtotalCents === undefined - ? null - : cents(parsed.data.minSubtotalCents), - }); - return c.json({ ok: true, rate }, 201); - }); - - app.get("/shipping/methods/:methodId/rates", async (c) => { - const params = methodPathParams.safeParse(c.req.param()); - if (!params.success) return c.json({ error: "invalid path parameter" }, 400); - const cur = c.req.query("currency"); - if (cur === undefined) return c.json({ error: "currency query is required" }, 400); - const rate = await deps.shippingRules.getRate(params.data.methodId, toCurrency(cur)); - if (rate === null) return c.json({ ok: false, reason: "NOT_FOUND" }, 404); - return c.json({ ok: true, rate }, 200); - }); - - // -- Tax ------------------------------------------------------------------- - app.post("/tax/classes", async (c) => { - const parsed = taxClassBody.safeParse(await readJson(c)); - if (!parsed.success) return c.json({ error: "invalid request body" }, 400); - const cls = await deps.taxRules.createClass({ id: parsed.data.id, name: parsed.data.name }); - return c.json({ ok: true, taxClass: cls }, 201); - }); - - app.get("/tax/classes", async (c) => { - return c.json({ ok: true, classes: await deps.taxRules.listClasses() }, 200); - }); - - app.post("/tax/rates", async (c) => { - const parsed = taxRateBody.safeParse(await readJson(c)); - if (!parsed.success) return c.json({ error: "invalid request body" }, 400); - const rate = await deps.taxRules.createRate({ - id: parsed.data.id, - taxClassId: parsed.data.taxClassId, - zoneId: parsed.data.zoneId, - rateBps: parsed.data.rateBps, - appliesToShipping: parsed.data.appliesToShipping ?? false, - }); - return c.json({ ok: true, rate }, 201); - }); - - app.get("/tax/rates", async (c) => { - const zoneId = c.req.query("zoneId"); - if (zoneId === undefined) return c.json({ error: "zoneId query is required" }, 400); - return c.json({ ok: true, rates: await deps.taxRules.listRatesForZone(zoneId) }, 200); - }); - - // -- Coupons --------------------------------------------------------------- - app.post("/coupons", async (c) => { - const parsed = couponBody.safeParse(await readJson(c)); - if (!parsed.success) - return c.json({ error: "invalid request body", issues: parsed.error.issues }, 400); - const d = parsed.data; - const coupon = await deps.couponStore.create({ - id: d.id, - code: d.code, - type: d.type, - amountCents: nn(d.amountCents), - rateBps: d.rateBps ?? null, - capCents: nn(d.capCents), - currency: d.currency === null || d.currency === undefined ? null : toCurrency(d.currency), - minSubtotalCents: nn(d.minSubtotalCents), - startsAt: d.startsAt ?? null, - expiresAt: d.expiresAt ?? null, - maxUses: d.maxUses ?? null, - maxUsesPerCustomer: d.maxUsesPerCustomer ?? null, - }); - return c.json({ ok: true, coupon: serializeCoupon(coupon) }, 201); - }); - - // Admin Coupons console: view-only list (admin-UX Increment 3, "coupon - // enumerate + coupon list"). Mirrors the Products console list's shape 1:1 - // (internal-token guarded, the same opaque-cursor-carries-filter-and-limit - // encoding, MOD-1 fail-closed decode) — see admin.ts's `GET /products`. - // Mounted at "/admin/coupons" (this router mounts at "/admin"); no path - // collision with `GET /coupons/:code` below (a different shape) or with - // `POST /coupons/:couponId/*` writes. - app.get("/coupons", async (c) => { - const parsed = couponsListQuery.safeParse(c.req.query()); - if (!parsed.success) - return c.json({ error: "invalid query", issues: parsed.error.issues }, 400); - const q = parsed.data; - - let filter: CouponListFilter; - let limit: number; - let cursorPos: CouponListCursor | null; - - if (q.cursor !== undefined) { - // Paged request: the opaque cursor carries the keyset POSITION plus the - // active filter (so filters survive paging) plus the page limit. Decoding - // MUST fail CLOSED to a 400 — a malformed/tampered/garbage token never - // 500s (MOD-1, mirrors the Products list). The decoded filter is - // RE-VALIDATED through zod and the decoded limit RE-CLAMPED server-side - // (never trusted past max=100). - // - // NOT yet at parity with the Orders/Products lists: those now fail CLOSED - // when a request carries a cursor AND query filter/limit params that - // disagree with the token's, while this arm still takes the predicate - // SOLELY from the token and ignores the query's `search`/`limit`. Closing - // it means lifting `canonicalFilter` / `has*FilterParams` out of - // `admin.ts` and reusing them here — not copying them, which would let - // the two canonicalizations drift apart. - const decoded = decodeCouponCursor(q.cursor); - if (decoded === null) return c.json({ error: "invalid cursor" }, 400); - const filterParsed = couponListFilterSchema.safeParse(decoded.filter); - const posParsed = couponCursorPosOf(decoded.pos); - if (!filterParsed.success || posParsed === null) { - return c.json({ error: "invalid cursor" }, 400); - } - filter = toCouponFilter(filterParsed.data); - cursorPos = posParsed; - limit = clampLimit(decoded.limit, q.limit); - } else { - filter = toCouponFilter({ search: q.search }); - cursorPos = null; - limit = q.limit; - } - - // The page and its EXACT count, under one filter, in parallel (INC-23) — - // the same shape as the Orders/Products lists in `admin.ts`; `total` counts - // the whole filtered set, never this page. - const [result, total] = await Promise.all([ - deps.couponStore.listCoupons(filter, { cursor: cursorPos, limit }), - deps.couponStore.countCoupons(filter), - ]); - const nextCursor = - result.nextCursor === null ? null : encodeCouponCursor(result.nextCursor, filter, limit); - return c.json( - { ok: true, coupons: result.coupons.map(serializeCouponSummary), nextCursor, total }, - 200, - ); - }); - - app.get("/coupons/:code", async (c) => { - const params = couponCodePathParams.safeParse(c.req.param()); - if (!params.success) return c.json({ error: "invalid path parameter" }, 400); - const coupon = await deps.couponStore.findByCode(params.data.code); - if (coupon === null) return c.json({ ok: false, reason: "NOT_FOUND" }, 404); - return c.json({ ok: true, coupon: serializeCoupon(coupon) }, 200); - }); - - // -- Shipping UPDATE/DELETE (admin-UX Increment 3) -------------------------- - // Every mutation is a NON-GET, so the global write gate (X-Service-Token, - // app.ts) covers it in addition to the internal-token guard above. - - app.put("/shipping/zones/:zoneId", async (c) => { - const params = zonePathParams.safeParse(c.req.param()); - if (!params.success) return c.json({ error: "invalid path parameter" }, 400); - const parsed = shippingZoneUpdateBody.safeParse(await readJson(c)); - if (!parsed.success) return c.json({ error: "invalid request body" }, 400); - const res = await deps.shippingRules.updateZone(params.data.zoneId, { - name: parsed.data.name, - // Presence is enforced by the schema's refine (omission ⇒ 400); the `?? - // null` only appeases `z.unknown()`'s optional-looking inferred type — - // JSON cannot carry `undefined`, so it never fires at runtime. - regions: parsed.data.regions ?? null, - }); - if (res.ok) return c.json({ ok: true, zone: res.zone }, 200); - return c.json({ ok: false, reason: "NOT_FOUND" }, 404); - }); - - app.delete("/shipping/zones/:zoneId", async (c) => { - const params = zonePathParams.safeParse(c.req.param()); - if (!params.success) return c.json({ error: "invalid path parameter" }, 400); - const res = await deps.shippingRules.deleteZone(params.data.zoneId); - if (res.ok) return c.json({ ok: true }, 200); - if (res.reason === "not_found") return c.json({ ok: false, reason: "NOT_FOUND" }, 404); - return c.json({ ok: false, reason: "IN_USE_BY_METHODS" }, 409); - }); - - app.put("/shipping/methods/:methodId", async (c) => { - const params = methodPathParams.safeParse(c.req.param()); - if (!params.success) return c.json({ error: "invalid path parameter" }, 400); - const parsed = shippingMethodUpdateBody.safeParse(await readJson(c)); - if (!parsed.success) return c.json({ error: "invalid request body" }, 400); - const res = await deps.shippingRules.updateMethod(params.data.methodId, { - name: parsed.data.name, - type: parsed.data.type, - }); - if (res.ok) return c.json({ ok: true, method: res.method }, 200); - return c.json({ ok: false, reason: "NOT_FOUND" }, 404); - }); - - app.delete("/shipping/methods/:methodId", async (c) => { - const params = methodPathParams.safeParse(c.req.param()); - if (!params.success) return c.json({ error: "invalid path parameter" }, 400); - const res = await deps.shippingRules.deleteMethod(params.data.methodId); - if (res.ok) return c.json({ ok: true }, 200); - if (res.reason === "not_found") return c.json({ ok: false, reason: "NOT_FOUND" }, 404); - return c.json({ ok: false, reason: "IN_USE_BY_RATES" }, 409); - }); - - app.put("/shipping/methods/:methodId/rates/:currency", async (c) => { - const params = methodCurrencyPathParams.safeParse(c.req.param()); - if (!params.success) return c.json({ error: "invalid path parameter" }, 400); - const parsed = shippingRateUpdateBody.safeParse(await readJson(c)); - if (!parsed.success) return c.json({ error: "invalid request body" }, 400); - const res = await deps.shippingRules.updateRate( - params.data.methodId, - toCurrency(params.data.currency), - { - amountCents: cents(parsed.data.amountCents), - // Required-nullable on the wire (schema doc): null clears, a number sets - // — omission was already a 400 at the boundary. - minSubtotalCents: - parsed.data.minSubtotalCents === null ? null : cents(parsed.data.minSubtotalCents), - }, - cents(parsed.data.expectedAmountCents), - ); - if (res.ok) return c.json({ ok: true, rate: res.rate }, 200); - if (res.reason === "not_found") return c.json({ ok: false, reason: "NOT_FOUND" }, 404); - return c.json({ ok: false, reason: "STALE", current: res.current }, 409); - }); - - app.delete("/shipping/methods/:methodId/rates/:currency", async (c) => { - const params = methodCurrencyPathParams.safeParse(c.req.param()); - if (!params.success) return c.json({ error: "invalid path parameter" }, 400); - const res = await deps.shippingRules.deleteRate( - params.data.methodId, - toCurrency(params.data.currency), - ); - if (res.ok) return c.json({ ok: true }, 200); - return c.json({ ok: false, reason: "NOT_FOUND" }, 404); - }); - - // -- Tax UPDATE/DELETE ------------------------------------------------------ - - // Increment 3 closeout (#72 gap-audit finding): `TaxRulesStore` had no - // rename at all, and `deleteTaxClass` (contract-tested since Increment 2 - // slice 5) had no route. Both land together here. - - app.put("/tax/classes/:classId", async (c) => { - const params = taxClassPathParams.safeParse(c.req.param()); - if (!params.success) return c.json({ error: "invalid path parameter" }, 400); - const parsed = taxClassUpdateBody.safeParse(await readJson(c)); - if (!parsed.success) return c.json({ error: "invalid request body" }, 400); - const res = await deps.taxRules.updateClass(params.data.classId, { name: parsed.data.name }); - if (res.ok) return c.json({ ok: true, taxClass: res.class }, 200); - return c.json({ ok: false, reason: "NOT_FOUND" }, 404); - }); - - app.delete("/tax/classes/:classId", async (c) => { - const params = taxClassPathParams.safeParse(c.req.param()); - if (!params.success) return c.json({ error: "invalid path parameter" }, 400); - const res = await deleteTaxClass( - { taxRules: deps.taxRules, productCommerce: deps.productCommerce }, - params.data.classId, - ); - if (res.ok) return c.json({ ok: true }, 200); - if (res.reason === "not_found") return c.json({ ok: false, reason: "NOT_FOUND" }, 404); - if (res.reason === "in_use_by_products") { - return c.json({ ok: false, reason: "IN_USE_BY_PRODUCTS", count: res.count }, 409); - } - return c.json({ ok: false, reason: "IN_USE_BY_RATES", count: res.count }, 409); - }); - - app.put("/tax/rates/:rateId", async (c) => { - const params = rateIdPathParams.safeParse(c.req.param()); - if (!params.success) return c.json({ error: "invalid path parameter" }, 400); - const parsed = taxRateUpdateBody.safeParse(await readJson(c)); - if (!parsed.success) return c.json({ error: "invalid request body" }, 400); - const res = await deps.taxRules.updateRate( - params.data.rateId, - // `appliesToShipping` is REQUIRED on the wire (schema doc) — no silent - // default here; omission was already a 400 at the boundary. - { rateBps: parsed.data.rateBps, appliesToShipping: parsed.data.appliesToShipping }, - parsed.data.expectedRateBps, - ); - if (res.ok) return c.json({ ok: true, rate: res.rate }, 200); - if (res.reason === "not_found") return c.json({ ok: false, reason: "NOT_FOUND" }, 404); - return c.json({ ok: false, reason: "STALE", current: res.current }, 409); - }); - - app.delete("/tax/rates/:rateId", async (c) => { - const params = rateIdPathParams.safeParse(c.req.param()); - if (!params.success) return c.json({ error: "invalid path parameter" }, 400); - const res = await deps.taxRules.deleteRate(params.data.rateId); - if (res.ok) return c.json({ ok: true }, 200); - return c.json({ ok: false, reason: "NOT_FOUND" }, 404); - }); - - // -- Coupon UPDATE/DELETE --------------------------------------------------- - - app.put("/coupons/:couponId", async (c) => { - const params = couponIdPathParams.safeParse(c.req.param()); - if (!params.success) return c.json({ error: "invalid path parameter" }, 400); - const parsed = couponUpdateBody.safeParse(await readJson(c)); - if (!parsed.success) - return c.json({ error: "invalid request body", issues: parsed.error.issues }, 400); - // Increment 3 closeout (#75 review finding): `couponUpdateBody` accepts - // null `amountCents`/`rateBps` unconditionally on the wire — the - // "can't blank the coupon's economic value" rule lived ONLY in the - // plugin (`coupons-page.ts`'s `parseEconomics`), so a direct API caller - // could blank a live coupon's discount. `type` (which axis is required) - // is NOT on the edit body — it is the coupon's immutable kind, stored on - // the record — so this is necessarily fetch-then-validate: read the - // coupon to learn its `type`, THEN validate the parsed body against it, - // 400ing before any write. A coupon deleted between this read and the - // `update()` call below still surfaces as the pre-existing 404 (`update` - // is itself not_found-safe), so the extra read adds no new race. - const existing = await deps.couponStore.findById(params.data.couponId); - if (existing === null) return c.json({ ok: false, reason: "NOT_FOUND" }, 404); - const d = parsed.data; - const amountCents = nn(d.amountCents); - const rateBps = d.rateBps ?? null; - if (existing.type === "fixed_amount" && amountCents === null) { - return c.json( - { error: "amountCents is required and cannot be null for a fixed_amount coupon" }, - 400, - ); - } - if (existing.type === "percentage" && rateBps === null) { - return c.json( - { error: "rateBps is required and cannot be null for a percentage coupon" }, - 400, - ); - } - const res = await deps.couponStore.update(params.data.couponId, { - amountCents, - rateBps, - capCents: nn(d.capCents), - minSubtotalCents: nn(d.minSubtotalCents), - startsAt: d.startsAt ?? null, - expiresAt: d.expiresAt ?? null, - maxUses: d.maxUses ?? null, - maxUsesPerCustomer: d.maxUsesPerCustomer ?? null, - }); - if (res.ok) return c.json({ ok: true, coupon: serializeCoupon(res.coupon) }, 200); - return c.json({ ok: false, reason: "NOT_FOUND" }, 404); - }); - - app.delete("/coupons/:couponId", async (c) => { - const params = couponIdPathParams.safeParse(c.req.param()); - if (!params.success) return c.json({ error: "invalid path parameter" }, 400); - const res = await deps.couponStore.delete(params.data.couponId); - if (res.ok) return c.json({ ok: true }, 200); - if (res.reason === "not_found") return c.json({ ok: false, reason: "NOT_FOUND" }, 404); - return c.json({ ok: false, reason: "IN_USE_BY_REDEMPTIONS" }, 409); - }); - - return app; -} - -/** Brand an optional non-null minor-unit number as `Cents`, else null. */ -function nn(v: number | null | undefined): ReturnType | null { - return v === null || v === undefined ? null : cents(v); -} - -function serializeCoupon(coupon: import("@otta-sh/domain").CouponRecord): Record { - return { - id: coupon.id, - code: coupon.code, - type: coupon.type, - amountCents: coupon.amountCents, - rateBps: coupon.rateBps, - capCents: coupon.capCents, - currency: coupon.currency, - minSubtotalCents: coupon.minSubtotalCents, - maxUses: coupon.maxUses, - maxUsesPerCustomer: coupon.maxUsesPerCustomer, - usesCount: coupon.usesCount, - }; -} - -async function readJson(c: { req: { json(): Promise } }): Promise { - try { - return await c.req.json(); - } catch { - return undefined; - } -} - -/** Wire shape of an admin Coupons-list row (view-only projection; admin-UX - * Increment 3). Serializes the FULL `CouponSummary` — every `CouponRecord` - * field plus `createdAt` — a small, header-only table has nothing expensive - * to trim off the list projection (unlike `serializeProductSummary`, which - * deliberately narrows `ProductCommerce`). `startsAt`/`expiresAt` are - * DELIBERATELY carried here even though the sibling `serializeCoupon` omits - * them (PR #74 review): the console list renders the validity window, so - * dropping them would force the UI into a per-row detail fetch — the exact - * N+1 this projection exists to prevent. `usesCount` doubles as the redeemed - * indicator — already a plain column, no correlated-EXISTS join. */ -function serializeCouponSummary(summary: CouponSummary): Record { - return { - id: summary.id, - code: summary.code, - type: summary.type, - amountCents: summary.amountCents, - rateBps: summary.rateBps, - capCents: summary.capCents, - currency: summary.currency, - minSubtotalCents: summary.minSubtotalCents, - startsAt: summary.startsAt, - expiresAt: summary.expiresAt, - maxUses: summary.maxUses, - maxUsesPerCustomer: summary.maxUsesPerCustomer, - usesCount: summary.usesCount, - createdAt: summary.createdAt, - }; -} - -const MAX_LIMIT = 100; -const DEFAULT_LIMIT = 25; - -/** Clamp a page limit into [1, 100] (MOD-1: a decoded cursor's limit is - * RE-CLAMPED, never honored past the max) — mirrors `admin.ts`'s `clampLimit`. - * Falls back to the query limit, then the default, for a missing/garbage - * value. */ -function clampLimit(decoded: unknown, queryLimit: number): number { - const raw = - typeof decoded === "number" && Number.isFinite(decoded) - ? decoded - : Number.isFinite(queryLimit) - ? queryLimit - : DEFAULT_LIMIT; - return Math.min(Math.max(Math.trunc(raw), 1), MAX_LIMIT); -} - -/** Narrow a validated coupon-filter zod result back into the domain - * `CouponListFilter` (drops `undefined` keys so the shape is exact) — - * mirrors `toProductFilter`. */ -function toCouponFilter(parsed: { search?: string }): CouponListFilter { - const filter: CouponListFilter = {}; - if (parsed.search !== undefined) filter.search = parsed.search; - return filter; -} - -/** The decoded cursor's `createdAt` must be a valid ISO-8601 datetime — mirrors - * `cursorCreatedAt` in admin.ts. */ -const couponCursorCreatedAt = z.string().datetime(); - -/** Validate a decoded coupon-cursor position shape — `{ createdAt: , couponId: }` — or null if malformed - * (→ 400). Mirrors `productCursorPosOf`. */ -function couponCursorPosOf(pos: unknown): CouponListCursor | null { - if (pos === null || typeof pos !== "object") return null; - const p = pos as { createdAt?: unknown; couponId?: unknown }; - if (typeof p.createdAt !== "string" || !couponCursorCreatedAt.safeParse(p.createdAt).success) { - return null; - } - if (typeof p.couponId !== "string" || p.couponId.length === 0 || p.couponId.length > 200) { - return null; - } - return { createdAt: p.createdAt, couponId: p.couponId }; -} - -interface DecodedCouponCursor { - pos: unknown; - filter: unknown; - limit: unknown; -} - -/** Encode the coupon-list keyset position + active filter + limit into an - * opaque base64url token — mirrors `encodeProductCursor`. */ -function encodeCouponCursor( - pos: CouponListCursor, - filter: CouponListFilter, - limit: number, -): string { - const payload = { pos: { createdAt: pos.createdAt, couponId: pos.couponId }, filter, limit }; - return toBase64Url(new TextEncoder().encode(JSON.stringify(payload))); -} - -/** Decode an opaque coupon-list cursor token; returns null on ANY malformed/ - * garbage input so the route answers 400 rather than 500 (MOD-1). Mirrors - * `decodeProductCursor`. */ -function decodeCouponCursor(token: string): DecodedCouponCursor | null { - try { - const json = new TextDecoder().decode(fromBase64Url(token)); - const parsed = JSON.parse(json) as unknown; - if (parsed === null || typeof parsed !== "object") return null; - const p = parsed as DecodedCouponCursor; - return { pos: p.pos, filter: p.filter, limit: p.limit }; - } catch { - return null; - } -} - -// Portable base64url (Node + workerd both provide btoa/atob + TextEncoder) — -// mirrors admin.ts's `toBase64Url`/`fromBase64Url`. -function toBase64Url(bytes: Uint8Array): string { - let bin = ""; - for (const b of bytes) bin += String.fromCharCode(b); - return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); -} - -function fromBase64Url(token: string): Uint8Array { - const b64 = token.replace(/-/g, "+").replace(/_/g, "/"); - const bin = atob(b64); // throws on invalid base64 ⇒ caught by decodeCouponCursor ⇒ 400 - const out = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); - return out; -} diff --git a/packages/service/src/routes/session-auth.ts b/packages/service/src/routes/session-auth.ts deleted file mode 100644 index fc5b1a51..00000000 --- a/packages/service/src/routes/session-auth.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { CustomerId, SessionStore } from "@otta-sh/domain"; -import type { Context } from "hono"; - -/** Extract the `Authorization: Bearer ` value, or null. */ -export function bearerToken(c: Context): string | null { - const header = c.req.header("authorization") ?? ""; - const match = /^Bearer\s+(.+)$/i.exec(header); - return match === null ? null : match[1]!; -} - -/** - * Resolve the authenticated customer purely from the bearer session token - * (Phase 5 §4) — **never** a `customerId` in the request body/query. This is the - * concrete mechanism behind "sees only own orders": every `/me/*` handler is - * given exactly one identity, and it's the one `SessionStore.validate` derives. - * Returns null when unauthenticated (the caller replies 401). - */ -export async function resolveCustomer( - c: Context, - sessionStore: SessionStore, -): Promise { - const token = bearerToken(c); - if (token === null) return null; - return sessionStore.validate(token); -} diff --git a/packages/service/src/routes/settings.ts b/packages/service/src/routes/settings.ts deleted file mode 100644 index d396f509..00000000 --- a/packages/service/src/routes/settings.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { - getSettings, - idempotencyKey as toIdempotencyKey, - InvalidSettingsError, - type SettingsStore, - updateSettings, -} from "@otta-sh/domain"; -import { Hono } from "hono"; -import { settingsBody } from "../schemas.js"; -import { requireInternalToken } from "./internal-auth.js"; - -export interface SettingsRoutesDeps { - settingsStore: SettingsStore; - /** Admin guard for BOTH verbs — the read as much as the write (ADR-0010); - * unset ⇒ 503 (disabled), never silently open. */ - internalToken?: string; -} - -/** - * Phase 7 settings endpoints (§6). `GET` reads the service-DB operational tier; - * `PUT` is a privileged admin write carrying an `Idempotency-Key`, zod-validated, - * invalid values → `400` + structured error (never clamped). Secrets live ONLY in - * service env and are never part of this surface — no secret-shaped field is read - * or returned here. - * - * BOTH verbs require the internal token (ADR-0010): the read half of a privileged - * write is admin surface too, and the app-level SERVICE_API_TOKEN write gate - * exempts GET/HEAD, so it does NOT cover the read. The authoritative guard is - * registered at the parent (`createApp`, `app.use("/settings")`); the blanket - * guard below is defense-in-depth for the sub-app on its own. - */ -export function settingsRoutes(deps: SettingsRoutesDeps): Hono { - const app = new Hono(); - - // "*", not "/*": the only routes here are at "/" (the sub-app's root). "/*" - // DOES match the root on 4.12.x (measured), so this is forward-compatibility - // caution rather than a fix — "*" is unambiguous and cannot drift on a minor. - app.use("*", async (c, next) => { - const denied = requireInternalToken(c, deps.internalToken); - if (denied !== null) return denied; - await next(); - }); - - app.get("/", async (c) => { - const settings = await getSettings(deps.settingsStore); - return c.json({ ok: true, settings }, 200); - }); - - app.put("/", async (c) => { - const key = c.req.header("Idempotency-Key"); - if (key === undefined || key.length === 0) { - return c.json({ ok: false, error: "Idempotency-Key header is required" }, 400); - } - - const parsed = settingsBody.safeParse(await readJson(c)); - if (!parsed.success) { - return c.json({ ok: false, error: "validation_error", issues: parsed.error.issues }, 400); - } - - try { - const settings = await updateSettings(deps.settingsStore, parsed.data, toIdempotencyKey(key)); - return c.json({ ok: true, settings }, 200); - } catch (err) { - if (err instanceof InvalidSettingsError) { - return c.json( - { ok: false, error: "validation_error", field: err.field, message: err.message }, - 400, - ); - } - throw err; - } - }); - - return app; -} - -async function readJson(c: { req: { json(): Promise } }): Promise { - try { - return await c.req.json(); - } catch { - return undefined; - } -} diff --git a/packages/service/src/routes/webhooks.ts b/packages/service/src/routes/webhooks.ts deleted file mode 100644 index 8c484047..00000000 --- a/packages/service/src/routes/webhooks.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { type SettleDeps, type SettleResult, settleOrder } from "@otta-sh/domain"; -import { type Context, Hono } from "hono"; -import type { OrderServiceDeps } from "./orders.js"; - -/** - * Public Stripe webhook receiver (§7). Consumes the **raw body** (no JSON - * re-parse before verification) and reads `Stripe-Signature`. Runs - * `settleOrder(stripeGateway, {kind:"webhook"})`. Returns **200 after - * dedupe/settle** (so Stripe stops retrying) and **400 only on signature/parse - * failure**. An amount/currency mismatch is a recorded anomaly, not retryable → - * 200. - */ -export function webhookRoutes(deps: OrderServiceDeps): Hono { - const app = new Hono(); - const settleDeps: SettleDeps = { - orderStore: deps.orderStore, - entitlementStore: deps.entitlementStore, - paymentEventStore: deps.paymentEventStore, - inventoryStore: deps.store, - couponStore: deps.couponStore, - clock: deps.clock, - }; - - app.post("/stripe", async (c) => { - const gateway = deps.gateways.stripe; - if (gateway === undefined) return c.json({ ok: false, error: "stripe not configured" }, 503); - - // RAW bytes — the HMAC is verified over exactly these, never a re-serialized body. - const body = new Uint8Array(await c.req.arrayBuffer()); - const signature = c.req.header("stripe-signature") ?? ""; - const res = await settleOrder(settleDeps, gateway, { - kind: "webhook", - body, - headers: { "stripe-signature": signature }, - }); - return webhookResponse(c, res); - }); - - return app; -} - -function webhookResponse(c: Context, res: SettleResult): Response { - if (res.ok) return c.json({ ok: true }, 200); - switch (res.reason) { - case "INVALID_SIGNATURE": - case "MALFORMED": - case "UNKNOWN_EVENT": - return c.json({ ok: false, reason: res.reason }, 400); - case "ORDER_NOT_FOUND": - return c.json({ ok: false, reason: res.reason }, 404); - case "AMOUNT_MISMATCH": - case "RECEIPT_REBOUND": - // Recorded as an anomaly (§5); retrying will never fix it → 200 so Stripe stops. - return c.json({ ok: false, reason: res.reason }, 200); - } -} diff --git a/packages/service/src/schemas.ts b/packages/service/src/schemas.ts deleted file mode 100644 index d52db6de..00000000 --- a/packages/service/src/schemas.ts +++ /dev/null @@ -1,884 +0,0 @@ -import { MAX_LOW_STOCK_THRESHOLD } from "@otta-sh/domain"; -import { z } from "zod"; - -// Wire-level qty caps (service-hardening plan §4). Two different numbers, -// deliberately: `/inventory/reserve` is the raw inventory primitive (a -// machine caller, behind the write gate when configured) and is aligned with -// the admin `stockMovementBody` cap below; cart lines are the shopper-facing, -// anonymous-internet-caller surface and get a much tighter bound. Both are -// WIRE-ONLY (zod) — the domain enforces the positive-integer bound too -// (defense-in-depth; `domain/src/inventory/use-cases.ts`) — this caps the -// wire value and makes "how much may one request ask for" an explicit, tested -// part of the contract instead of an accident of IEEE-754 (today `qty: 1e9` / -// `Number.MAX_SAFE_INTEGER` is a "valid" request that only the store's -// arithmetic rejects). -// -// IMPORTANT — this is NOT a rate limit and does not fix junk-`failed`- -// reservation-row amplification: that is bound by request COUNT, not qty -// magnitude (10,000 requests at qty:9,999 each mint as many failed rows as -// one request at qty:1e9). See the follow-up issue for rate-limiting -// `POST /inventory/reserve` and `POST /carts/:id/lines`: -// https://github.com/UrumiAI/otta.sh/issues/91 -export const CART_LINE_MAX_QTY = 10_000; -export const RESERVE_MAX_QTY = 1_000_000_000; - -// Zod request bodies mirroring the inventory port 1:1 (§0.6). `Idempotency-Key` -// travels as a header, not in the body. -export const reserveBody = z.object({ - sku: z.string().min(1), - qty: z.number().int().positive().max(RESERVE_MAX_QTY), -}); - -export const commitBody = z.object({ - reservationId: z.string().min(1), -}); - -export const releaseBody = z.object({ - reservationId: z.string().min(1), -}); - -// Cart bodies (§6). Money is intentionally absent — a cart line snapshots no -// price (that is an order invariant, Phase 4). -export const createCartBody = z.object({ - currency: z - .string() - .regex(/^[A-Z]{3}$/) - .optional(), -}); - -export const addLineBody = z.object({ - sku: z.string().min(1), - qty: z.number().int().positive().max(CART_LINE_MAX_QTY), - // Phase 4: the product this line references. Optional for backward-compat with - // bare Phase-3 adds; REQUIRED to later check out (an order needs a priced - // product). When present it is the subject of the route's SKU GUARD, not a - // hint: the service resolves `sku` against THIS product's live sellable units - // and refuses the add if it does not name one, and it reads the fulfillment - // kind from the same row (server-authoritative) so a digital line reserves - // nothing. See `routes/carts.ts` for why a bare add is deliberately left - // unguarded — and why that is not the hole it looks like. - productId: z.string().min(1).max(200).optional(), -}); - -export const patchLineBody = z.object({ - qty: z.number().int().positive().max(CART_LINE_MAX_QTY), -}); - -// Path-parameter sanity (N3): ids are opaque tokens — non-empty, bounded, and -// free of whitespace/control characters. Routing guarantees non-empty; the -// bound and charset keep garbage out of the store layer. -const idParam = z - .string() - .min(1) - .max(200) - .regex(/^[\x21-\x7e]+$/); - -export const pathParams = z.object({ cartId: idParam }); -export const linePathParams = z.object({ cartId: idParam, lineId: idParam }); - -// Phase 4 (§7). Checkout, order read, the x402 page-gate proof, and the -// entitlement check. -/** - * ADR-0009: the optional shipping address a checkout submits. Required fields are - * non-empty; `line2`/`region`/`email`/`phone` are optional. Bounds mirror the - * domain's `ORDER_ADDRESS_MAX_LENGTHS` (the domain re-validates + trims — this is - * the wire's first line of defense, the domain the authoritative guard). No - * address→zone matching (ADR-0009 §5): `country` is a free string. - */ -export const shippingAddressBody = z.object({ - name: z.string().min(1).max(200), - line1: z.string().min(1).max(200), - line2: z.string().max(200).nullish(), - city: z.string().min(1).max(120), - region: z.string().max(120).nullish(), - postalCode: z.string().min(1).max(32), - country: z.string().min(1).max(100), - email: z.string().max(320).nullish(), - phone: z.string().max(64).nullish(), -}); - -export const checkoutBody = z.object({ - cartId: idParam, - paymentMethod: z.enum(["stripe", "x402"]), - // Email/session claim token — the pre-Phase-5 entitlement key (§6). - buyerRef: z.string().min(1).max(320), - // Phase 6: optional shipping selection + coupon (absent ⇒ zero shipping/tax). - shippingZoneId: idParam.optional(), - shippingMethodId: idParam.optional(), - couponCode: z.string().min(1).max(200).optional(), - // ADR-0009: optional ship-to snapshot captured at checkout (absent ⇒ none — - // capture is optional this slice; required-for-physical is a later flip). - shippingAddress: shippingAddressBody.optional(), -}); - -export const orderPathParams = z.object({ orderId: idParam }); - -// Phase 6 (§6): read-only totals preview — no coupon redemption, safe to repeat. -export const quoteBody = z.object({ - cartId: idParam, - shippingZoneId: idParam.optional(), - shippingMethodId: idParam.optional(), - couponCode: z.string().min(1).max(200).optional(), -}); - -// Phase 6 admin CRUD bodies (mirror the store ports 1:1). Money = integer minor -// units; rates = integer basis points; never a float. -export const shippingZoneBody = z.object({ - id: idParam, - name: z.string().min(1).max(200), - regions: z.unknown().optional(), -}); - -export const shippingMethodBody = z.object({ - id: idParam, - name: z.string().min(1).max(200), - type: z.enum(["flat_rate", "free_shipping"]), -}); - -export const shippingRateBody = z.object({ - currency: z.string().regex(/^[A-Z]{3}$/), - amountCents: z.number().int().nonnegative(), - minSubtotalCents: z.number().int().nonnegative().nullable().optional(), -}); - -export const taxClassBody = z.object({ - id: idParam, - name: z.string().min(1).max(200), -}); - -export const taxRateBody = z.object({ - id: idParam, - taxClassId: idParam, - zoneId: idParam, - rateBps: z.number().int().min(0).max(100_000), - appliesToShipping: z.boolean().optional(), -}); - -export const couponBody = z.object({ - id: idParam, - code: z.string().min(1).max(200), - type: z.enum(["fixed_amount", "percentage"]), - amountCents: z.number().int().nonnegative().nullable().optional(), - rateBps: z.number().int().min(0).max(100_000).nullable().optional(), - capCents: z.number().int().nonnegative().nullable().optional(), - currency: z - .string() - .regex(/^[A-Z]{3}$/) - .nullable() - .optional(), - minSubtotalCents: z.number().int().nonnegative().nullable().optional(), - startsAt: z.string().min(1).max(64).nullable().optional(), - expiresAt: z.string().min(1).max(64).nullable().optional(), - maxUses: z.number().int().nonnegative().nullable().optional(), - maxUsesPerCustomer: z.number().int().nonnegative().nullable().optional(), -}); - -export const zonePathParams = z.object({ zoneId: idParam }); -export const methodPathParams = z.object({ methodId: idParam }); -export const couponCodePathParams = z.object({ code: z.string().min(1).max(200) }); - -// Phase 6 admin UPDATE/DELETE bodies (admin-UX Increment 3 — the missing -// UPDATE/DELETE capability). Mirror the store ports 1:1; money = integer minor -// units, rates = integer basis points, never a float. Identity fields are the -// path param, never the body (a zone/method/rate/coupon id is immutable). - -// Shipping zone edit — LWW, no CAS (structural, money-free); `id` is the path. -// `regions` is REQUIRED-PRESENT (PR #71 review, reviewer B finding 1): the -// port's `UpdateShippingZoneInput.regions` is a required full-replace field, so -// an OMITTED key must be a 400 — never a silent wipe-to-null. Pass an explicit -// `null` to clear. (`z.unknown()` alone treats an absent key as valid, hence -// the presence refine.) -export const shippingZoneUpdateBody = z - .object({ - name: z.string().min(1).max(200), - regions: z.unknown(), - }) - .refine((o) => Object.hasOwn(o, "regions"), { - message: "regions is required (pass null to clear)", - }); - -// Shipping method edit — LWW; `zoneId` is immutable identity, never edited here. -export const shippingMethodUpdateBody = z.object({ - name: z.string().min(1).max(200), - type: z.enum(["flat_rate", "free_shipping"]), -}); - -// Shipping rate edit — OPTIMISTIC CAS on the money-bearing `amountCents`: -// `expectedAmountCents` is the price the admin read; the store compare-and-sets -// on it (a concurrent edit surfaces as a 409 stale reload, never a silent -// clobber). `(methodId, currency)` is the rate's identity — both path params. -// `minSubtotalCents` is REQUIRED (nullable, not optional — PR #71 review, -// reviewer B finding 1): the port's `UpdateShippingRateInput.minSubtotalCents` -// is a required full-replace field, so an omitted key is a 400 — never a silent -// clear of the free-shipping threshold. Send `null` explicitly to clear it. -export const shippingRateUpdateBody = z.object({ - amountCents: z.number().int().nonnegative(), - minSubtotalCents: z.number().int().nonnegative().nullable(), - expectedAmountCents: z.number().int().nonnegative(), -}); - -// Tax rate edit — OPTIMISTIC CAS on the money-bearing `rateBps` -// (`expectedRateBps` = the rate the admin read). `(taxClassId, zoneId)` identity -// is immutable; the rate is addressed by its `id` path param. -// `appliesToShipping` is REQUIRED (PR #71 review, reviewer B finding 1): the -// port's `UpdateTaxRateInput.appliesToShipping` is a required full-replace -// field, so an omitted key is a 400 — never a silent flip of the shipping-tax -// behavior to false. (The CREATE body's optional-default-false is different: -// there is no prior value to clobber at creation.) -export const taxRateUpdateBody = z.object({ - rateBps: z.number().int().min(0).max(100_000), - appliesToShipping: z.boolean(), - expectedRateBps: z.number().int().min(0).max(100_000), -}); - -// Coupon edit — LWW (documented exception to "prefer CAS", see the port doc). -// `code`/`type`/`currency` are immutable identity/kind and are NOT editable; a -// full replacement of the economics/window (undefined ⇒ cleared to null, the -// LWW set-semantics). Addressed by `couponId` (the path param). -// DELIBERATELY all-optional (the one intentional omit-⇒-null partial, PR #71 -// review): every field here is nullable in the port — "absent" and "null" both -// mean "this coupon axis is unset" (a fixed-amount coupon has no rateBps, no -// window ⇒ no window), so omit-⇒-clear IS the full-replace semantics, unlike -// the zone/rate bodies above where an omitted required field would silently -// destroy meaningful config. -export const couponUpdateBody = z.object({ - amountCents: z.number().int().nonnegative().nullable().optional(), - rateBps: z.number().int().min(0).max(100_000).nullable().optional(), - capCents: z.number().int().nonnegative().nullable().optional(), - minSubtotalCents: z.number().int().nonnegative().nullable().optional(), - startsAt: z.string().min(1).max(64).nullable().optional(), - expiresAt: z.string().min(1).max(64).nullable().optional(), - maxUses: z.number().int().nonnegative().nullable().optional(), - maxUsesPerCustomer: z.number().int().nonnegative().nullable().optional(), -}); - -// Tax class rename — LWW, no CAS (structural, money-free; mirrors -// `shippingZoneUpdateBody`); `id` is the path. The class id is the referent -// rates/products point at, so a rename never orphans anything. -export const taxClassUpdateBody = z.object({ - name: z.string().min(1).max(200), -}); - -export const rateIdPathParams = z.object({ rateId: idParam }); -export const taxClassPathParams = z.object({ classId: idParam }); -export const couponIdPathParams = z.object({ couponId: idParam }); -export const methodCurrencyPathParams = z.object({ - methodId: idParam, - currency: z.string().regex(/^[A-Z]{3}$/), -}); - -// Admin Coupons console: view-only list query (admin-UX Increment 3). Mirrors -// `productsListQuery`'s shape: `limit` coerced + clamped to 1..100 (default -// 25), `cursor` the opaque base64url keyset token. `search` is the ONLY filter -// axis (coupons have no soft-delete/publish-gate/kind axis to mirror -// `deleted`/`active`/`productKind` — port doc) and matches `code` EXACTLY, -// case-insensitively (never a substring). -export const couponsListQuery = z.object({ - search: z.string().min(1).max(200).optional(), - cursor: z.string().min(1).max(1000).optional(), - limit: z.coerce.number().int().min(1).max(100).optional().default(25), -}); - -/** Validates the FILTER object carried inside a decoded opaque coupon-list - * cursor (MOD-1: re-validate the decoded filter through zod before trusting - * it) — mirrors `productListFilterSchema`. */ -export const couponListFilterSchema = z.object({ - search: z.string().min(1).max(200).optional(), -}); - -export type CouponsListQuery = z.infer; -export type CouponListFilterParsed = z.infer; - -// The x402 facilitator SettleResponse proof forwarded by the page layer (§6). -// Money on the wire is an integer minor unit + an ISO-4217 string (never a float). -export const x402ProofBody = z.object({ - orderId: idParam, - transaction: z.string().min(1).max(200), - network: z.string().min(1).max(64), - payer: z.string().min(1).max(200), - amount: z.number().int().nonnegative(), - currency: z.string().regex(/^[A-Z]{3}$/), - signature: z.string().min(1).max(4096), -}); - -// Scope selection (which of orderId / buyerRef / session applies) is resolved in -// the route, which — unlike a schema — can see the auth headers. `sku` is the -// only always-required field; the presence-based precedence + per-scope auth -// live in routes/entitlements.ts (see ADR-0011). -export const entitlementCheckQuery = z.object({ - orderId: z.string().min(1).max(200).optional(), - buyerRef: z.string().min(1).max(320).optional(), - sku: z.string().min(1).max(200), -}); - -export type ReserveBody = z.infer; -export type CommitBody = z.infer; -export type ReleaseBody = z.infer; - -// Product-commerce (Phase 1 §7). Money on the wire is an integer + an -// ISO-4217 string — never a float (DEVELOPMENT.md §4). Every commercial -// field is optional: "create then price" (plan §1 case 3) — a bare sync -// upsert may carry only the product_id. -// -// DELIBERATELY NOT `.strict()`, and it deliberately KEEPS `title` — the -// asymmetry with `editProductCommerceBody` below is intent, not an oversight -// somebody should tidy up. This is the CMS content sync's own channel and the -// ONE writer of `product_commerce.title` that ADR-0013 sanctions -// (`adr/0013-product-title-is-cms-owned.md`); it is also a -// forward-compatibility surface for integrators, so an unknown key here is -// tolerated rather than rejected. -export const upsertProductCommerceBody = z.object({ - sku: z.string().min(1).optional(), - price: z - .object({ - amount: z.number().int().nonnegative(), - currency: z.string().regex(/^[A-Z]{3}$/), - }) - .optional(), - // Phase 4 §4: the title an order line snapshots at purchase time. THE SOLE - // WRITE CHANNEL for `product_commerce.title` (ADR-0013) — the CMS content - // sync posts it here on every save/publish. Absent from the admin PATCH - // below, on purpose. - title: z.string().min(1).max(500).nullable().optional(), - taxClass: z.string().nullable().optional(), - weightGrams: z.number().int().nullable().optional(), - lengthMm: z.number().int().nullable().optional(), - widthMm: z.number().int().nullable().optional(), - heightMm: z.number().int().nullable().optional(), - productKind: z.enum(["physical", "digital"]).optional(), - // Initial stock (Phase 1 §8 Risk 4) — a create-if-absent seed; never a - // restock path. OPERATIONALLY: it lands ONLY on the save that first carries - // the product's sku. Since PR 1a a sku-bearing save ALWAYS seeds a row - // (`initialOnHand ?? 0`), so by the time a later save supplies a figure the - // row already exists and `ON CONFLICT (sku) DO NOTHING` discards it — - // silently, and by design: the seed must never clobber a live or - // already-decremented count. Send it with the first sku-bearing save; add - // stock after that through `POST /admin/products/:id/restock`. - initialOnHand: z.number().int().nonnegative().optional(), - // Sync-ordering watermark (review S1): the CMS content's own updatedAt, - // carried by content:afterSave syncs; a strictly-older value is a stale - // no-op at the store. Panel saves omit it (last-writer-wins). - // STRICT format (review F1): exactly `Date.toISOString()` output — - // fixed-width UTC, so lexicographic comparison IS chronological. The - // field feeds a raw text comparison in SQL; one garbage high-sorting - // value (e.g. "ZZZZ") stored once would make every future legitimate - // sync a stale no-op forever (panel saves preserve, never heal, the - // watermark), so anything else is a 400 at the boundary. - contentUpdatedAt: z - .string() - .regex( - /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/, - "contentUpdatedAt must be a Date.toISOString()-format UTC timestamp", - ) - .optional(), -}); - -// Publish-gate lifecycle actions (the afterPublish→activate / -// afterUnpublish→deactivate follow-ups). `contentUpdatedAt` is the CMS -// content's own `updatedAt` at publish/unpublish time — the ORDERING WATERMARK -// the store gates on so a stale, out-of-order lifecycle POST is a no-op -// (activate/deactivate are opposing flips on the same `active` flag delivered -// by independent fire-and-forget hooks). REQUIRED and STRICT — exactly -// `Date.toISOString()` output (same rationale as `upsert`'s contentUpdatedAt, -// review F1: it feeds a raw lexicographic SQL comparison; a garbage -// high-sorting value would wedge the gate). EmDash's publish()/unpublish() -// always carry it, so it is never legitimately absent. -export const lifecycleProductCommerceBody = z.object({ - contentUpdatedAt: z - .string() - .regex( - /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/, - "contentUpdatedAt must be a Date.toISOString()-format UTC timestamp", - ), -}); - -export type LifecycleProductCommerceBody = z.infer; - -// Standalone admin product EDIT (admin-UX Increment 2, slice 2). A SUBSET of -// `upsertProductCommerceBody` — the commerce-owned, merchant-editable fields -// only — plus the REQUIRED optimistic-concurrency watermark `expectedUpdatedAt` -// (the `updatedAt` the admin read on the detail; the store compare-and-sets on -// it, so a concurrent edit surfaces as a 409 stale reload rather than a silent -// clobber). Deliberately OMITS `active` (the CMS publish gate — edited by -// publishing the document, not here), `title` (CMS-owned; see `.strict()` -// below), `contentUpdatedAt` / `initialOnHand` (sync + create-time concerns), -// and `productId` (the path param). Money stays an integer minor-units + -// ISO-4217 pair; `price.amount` is `.positive()` here (a $0 edit is rejected — -// the domain's `price > 0` rule, mirrored so the boundary 400s before the -// use-case throws). -// -// `.strict()` IS DELIBERATE — DO NOT REMOVE IT AS NOISE. Zod's default object -// behaviour STRIPS an unknown key, so simply dropping `title` from this schema -// would make a stale client's rename vanish silently behind a 200 — the failure -// mode most likely to be misread as "it saved". Rejecting is the honest answer: -// title is CMS-owned and `upsertProductCommerceBody` above is its one channel -// (ADR-0013, `adr/0013-product-title-is-cms-owned.md`). Nothing fails when -// `.strict()` is deleted; things merely start passing silently — which is why -// the guard is pinned by a test that asserts the STORED title is unchanged, not -// just the status code (`packages/service/test/admin-product-edit-http.test.ts`). -// Known cost, accepted: an OLD plugin bundle that still sends `title` now 400s -// on EVERY edit, not only title edits. Moot for `sites/staging`, where the -// plugin and the site deploy from one build. -export const editProductCommerceBody = z - .object({ - expectedUpdatedAt: z - .string() - .regex( - /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/, - "expectedUpdatedAt must be a Date.toISOString()-format UTC timestamp", - ), - sku: z.string().min(1).optional(), - price: z - .object({ - amount: z.number().int().positive(), - currency: z.string().regex(/^[A-Z]{3}$/), - }) - .optional(), - taxClass: z.string().nullable().optional(), - // Increment 2 slice 5: compare-at / cost are money (integer minor units + - // ISO-4217), NON-NEGATIVE (unlike `price`, a $0 compare-at / cost is a - // meaningful "cleared to zero"), nullable to CLEAR. Currency integrity (share - // the product's price currency; no mixed-currency edit) is the domain + - // store's atomic concern, not re-checked here. - compareAtPrice: z - .object({ amount: z.number().int().nonnegative(), currency: z.string().regex(/^[A-Z]{3}$/) }) - .nullable() - .optional(), - unitCost: z - .object({ amount: z.number().int().nonnegative(), currency: z.string().regex(/^[A-Z]{3}$/) }) - .nullable() - .optional(), - weightGrams: z.number().int().nonnegative().nullable().optional(), - lengthMm: z.number().int().nonnegative().nullable().optional(), - widthMm: z.number().int().nonnegative().nullable().optional(), - heightMm: z.number().int().nonnegative().nullable().optional(), - productKind: z.enum(["physical", "digital"]).optional(), - // Out-of-stock policy — `"deny"` is the ONLY accepted value this slice (the - // wire enum is the boundary that keeps an `allow_backorder` from ever - // reaching the no-oversell reserve path; widening it is a future slice + ADR). - inventoryPolicy: z.enum(["deny"]).optional(), - }) - .strict(); - -export type EditProductCommerceBody = z.infer; - -// -- Variants: one wire body per WRITER, never one per row -------------------- -// -// The two variant write bodies below are the wire half of ADR-0016's two-writer -// split (`adr/0016-variant-title-is-cms-owned.md`), and the split is the reason -// there are two of them rather than one merged body with optional fields: the -// CMS sync owns the variant's presence and its display name, the admin owns its -// sku and price, and NEITHER may reach the other's column. The port makes that -// uncrossable in TypeScript (`UpsertProductVariantInput` has no `sku`/`price`; -// `UpdateProductVariantFieldsInput` has no `title`); these schemas make it -// uncrossable over HTTP, which is the layer a stale client actually reaches. -// -// BOTH ARE `.strict()`, for `editProductCommerceBody`'s reason restated one -// level down: zod's default object behaviour STRIPS an unknown key, so a -// declare that sent `price`, or an edit that sent `title`, would come back 200 -// with the field silently discarded — the failure mode most likely to be -// misread as "it saved". A 400 is the honest answer, and it names the field. - -// The CMS-sync DECLARE (`PUT /products/:id/variants/:variantKey`). Carries the -// display-name cache and the ordering watermark, and NOTHING COMMERCIAL. The -// variant key is the identity and travels in the PATH, never here — there is no -// field that could re-key a row (ADR-0016: a re-key is unrepresentable, not -// merely discouraged). -export const upsertProductVariantBody = z - .object({ - // `undefined` PRESERVES the stored cache; an explicit `null` CLEARS it (a - // repeater row whose name sub-field is empty) — the same grain as - // `upsertProductCommerceBody.title`, and the same single-writer rule. - title: z.string().min(1).max(500).nullable().optional(), - // The CMS content's own `updatedAt` — ONE watermark for both presence - // transitions (declare and deactivate). STRICT `Date.toISOString()` format - // for `upsertProductCommerceBody.contentUpdatedAt`'s reason: it feeds a raw - // lexicographic comparison in SQL, so one garbage high-sorting value stored - // once would wedge every later sync as a stale no-op. - contentUpdatedAt: z - .string() - .regex( - /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/, - "contentUpdatedAt must be a Date.toISOString()-format UTC timestamp", - ) - .optional(), - }) - .strict(); - -export type UpsertProductVariantBody = z.infer; - -// The guarded ADMIN edit (`PATCH /products/:id/variants/:variantKey`) — the -// exact mirror of `editProductCommerceBody`, one level down: the commerce-owned -// fields plus the REQUIRED compare-and-set watermark. Deliberately OMITS -// `title` (CMS-owned) and any field that could move `orphanedAt` (the presence -// axis is a transition, not a field). `price.amount` is `.positive()`, matching -// the domain's own `price > 0` rule so the boundary 400s before the use-case -// throws — an absent price is expressed by leaving the field unset, never by -// sending zero. -export const editProductVariantBody = z - .object({ - expectedUpdatedAt: z - .string() - .regex( - /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/, - "expectedUpdatedAt must be a Date.toISOString()-format UTC timestamp", - ), - sku: z.string().min(1).optional(), - price: z - .object({ - amount: z.number().int().positive(), - currency: z.string().regex(/^[A-Z]{3}$/), - }) - .optional(), - }) - .strict(); - -export type EditProductVariantBody = z.infer; - -// The ORPHAN transition (`POST /products/:id/variants/:variantKey/deactivate`). -// The watermark is REQUIRED, because presence has two opposing transitions -// arriving as independent fire-and-forget POSTs and only the watermark orders -// them — the same reason `lifecycleProductCommerceBody` requires one. -// -// Written out rather than ALIASED to that body, and `.strict()` like its two -// variant siblings. The two are identical today and are still not the same -// schema: they gate different transitions on different tables, so a field added -// to the publish gate must not silently become part of the orphan transition's -// contract. And the alias inherited the lifecycle body's non-strict behaviour, -// which left this one route quietly STRIPPING an unknown key while the declare -// and the edit beside it answered 400 — the same "it saved" failure both of -// those are `.strict()` to prevent. -export const deactivateProductVariantBody = z - .object({ - contentUpdatedAt: z - .string() - .regex( - /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/, - "contentUpdatedAt must be a Date.toISOString()-format UTC timestamp", - ), - }) - .strict(); - -export type DeactivateProductVariantBody = z.infer; - -// Admin Products console: merchant restock / stock removal (admin-UX Increment -// 2). `qty` is a positive integer count of whole units — NOT a money field, but -// held to the same integer discipline (no floats). The domain enforces the -// positive-integer bound too (defense-in-depth); this bounds the wire value and -// caps it well below the safe-integer ceiling. -export const stockMovementBody = z.object({ - qty: z.number().int().positive().max(1_000_000_000), -}); - -export type StockMovementBody = z.infer; - -// Catalog batch read (Phase 2 §6): ids are opaque tokens, same charset/bound -// discipline as the cart path params; the array-length cap is the endpoint's -// request-size guard (COMMERCE_BATCH_ID_CAP in routes/catalog.ts — kept in -// sync by the route's own 400 test). -export const commerceBatchBody = z.object({ - productIds: z - .array( - z - .string() - .min(1) - .max(200) - .regex(/^[\x21-\x7e]+$/), - ) - .max(100), -}); - -// Phase 5 (§7): customer auth, /me, address book, admin transition. -export const loginRequestBody = z.object({ - email: z.string().min(3).max(320), -}); - -export const loginVerifyBody = z.object({ - challengeId: idParam, - token: z.string().min(1).max(400), -}); - -/** The ten modeled order states as a shared enum — the single wire-value bound - * reused by `transitionBody` (target state), `ordersListQuery` (CSV state - * filter), and the opaque-cursor filter re-validation. The DOMAIN rejects any - * illegal transition; this only bounds the wire value to a known state. */ -export const orderStateEnum = z.enum([ - "pending", - "paid", - "failed", - "expired", - "processing", - "shipped", - "delivered", - "completed", - "cancelled", - "refunded", -]); - -export const transitionBody = z.object({ - toState: orderStateEnum, -}); - -// Admin Orders console: append an order note (admin-UX Increment 0). Author + -// body are bounded free text; the domain use-case trims and rejects empties (a -// blank note is meaningless), so the min here is a cheap 1-char floor and the -// substantive validation stays in the domain. -export const appendNoteBody = z.object({ - author: z.string().min(1).max(200), - body: z.string().min(1).max(4000), -}); - -/** The three reconciliation dispositions (admin-UX Increment 1) — the wire bound - * mirrors the domain `ReconciliationOutcome`. The domain owns the legality (an - * order must actually be flagged); this only bounds the wire value. */ -export const reconciliationOutcomeEnum = z.enum(["refunded", "fulfilled", "written_off"]); - -// Admin Orders console: resolve an order's reconciliation flag (admin-UX -// Increment 1). `expectedFlag` is the flag detail the admin REVIEWED (as -// displayed) — the domain requires the live flag to still EQUAL it (a -// compare-and-clear), so a mid-review re-flag is a 409 conflict, never a blind -// clear. `outcome` is the disposition; `reason`/`resolvedBy` are bounded free -// text — the domain use-case trims and rejects empties, so the 1-char floor -// here is cheap and the substantive validation stays in the domain. -export const resolveReconciliationBody = z.object({ - expectedFlag: z.string().min(1).max(4000), - outcome: reconciliationOutcomeEnum, - reason: z.string().min(1).max(4000), - resolvedBy: z.string().min(1).max(200), -}); - -// Admin Orders console: record shipping fulfillment (admin-UX Increment 1). -// Recording fulfillment ships the order (`processing → shipped`) and makes the -// shipped email carry tracking. `carrier`/`trackingNumber`/`recordedBy` are -// bounded free text — the domain use-case trims and rejects empties, so the -// 1-char floor here is cheap and the substantive validation stays in the domain. -// `trackingUrl` is optional (the buyer's tracking link) and `shippedAt` optional -// (absent ⇒ the store stamps its own clock at record time); both nullable so an -// explicit null clears them at the boundary the same way an absent field does. -// `trackingUrl` is SCHEME-BOUND to http(s) as defense-in-depth (PR #63 review): -// the value is rendered into the buyer's shipped email and the admin panel, so a -// `javascript:`/`data:` URI must never even be storable — the plugin validates -// the same bound client-side, and the email renderer escapes regardless. -export const recordFulfillmentBody = z.object({ - carrier: z.string().min(1).max(200), - trackingNumber: z.string().min(1).max(200), - trackingUrl: z - .string() - .max(2000) - .regex(/^https?:\/\/\S+$/i, "trackingUrl must be an http(s) URL") - .nullable() - .optional(), - shippedAt: z.string().datetime().nullable().optional(), - recordedBy: z.string().min(1).max(200), -}); - -/** The five structured cancellation reasons (admin-UX Increment 1, "cancel with - * reason") — the wire bound mirrors the domain `CancellationReason`. The - * domain owns the legality (an order must actually be cancellable); this only - * bounds the wire value. */ -export const cancellationReasonEnum = z.enum([ - "customer_request", - "fraud_suspected", - "out_of_stock", - "pricing_error", - "other", -]); - -// Admin Orders console: cancel an order with a structured reason (admin-UX -// Increment 1). `reason` is the closed enum; `detail` is optional bounded free -// text (the domain trims + normalizes a blank to null); `cancelledBy` is -// bounded free text — the domain use-case trims and rejects an empty value, so -// the 1-char floor here is cheap and the substantive validation stays in the -// domain. -export const cancelOrderBody = z.object({ - reason: cancellationReasonEnum, - detail: z.string().max(4000).nullable().optional(), - cancelledBy: z.string().min(1).max(200), -}); - -// Admin Orders console: issue / record a refund (ADR-0008). `amountCents` is -// money — a POSITIVE integer minor-unit value (a $0 refund is meaningless; the -// domain also rejects it) + an ISO-4217 `currency` the domain checks against the -// order's currency. `reason` is optional bounded free text (the domain trims a -// blank → null); `refundedBy` is bounded free text (the domain trims + rejects an -// empty value, so the 1-char floor here is cheap and the substantive validation -// stays in the domain). The ceiling / capability / gateway error taxonomy all live -// in the domain + adapter — this only bounds the wire values. The `Idempotency-Key` -// header is REQUIRED at the route (refunds are ADDITIVE, like a restock — two -// deliberate refunds must NOT collapse), so there is no key field on the body. -export const refundOrderBody = z.object({ - amountCents: z.number().int().positive().max(1_000_000_000_000), - currency: z.string().regex(/^[A-Z]{3}$/), - reason: z.string().max(4000).nullable().optional(), - refundedBy: z.string().min(1).max(200), -}); - -export type RefundOrderBody = z.infer; - -// Admin Orders console: view-only list query (§ admin-orders). The date window -// is HALF-OPEN [from, to) — from inclusive, to EXCLUSIVE — deliberately DIFFERENT -// from the reporting queries' inclusive/inclusive BETWEEN (MOD-7); the store -// documents the same divergence. `states` is a CSV of the enum above (parsed + -// per-token validated in the route). `limit` is coerced + clamped to 1..100 -// (default 25); `cursor` is the opaque base64url keyset token. -export const ordersListQuery = z.object({ - states: z.string().min(1).max(200).optional(), - from: z.string().datetime().optional(), - to: z.string().datetime().optional(), - search: z.string().min(1).max(200).optional(), - cursor: z.string().min(1).max(1000).optional(), - limit: z.coerce.number().int().min(1).max(100).optional().default(25), -}); - -/** Validates the FILTER object carried inside a decoded opaque cursor (MOD-1: - * re-validate the decoded filter through zod before trusting it). `states` here - * is already an array (the encoder stored the parsed array), each token bound to - * the shared enum; the window bounds keep the ISO-8601 datetime discipline. */ -export const orderListFilterSchema = z.object({ - states: z.array(orderStateEnum).optional(), - from: z.string().datetime().optional(), - to: z.string().datetime().optional(), - search: z.string().min(1).max(200).optional(), -}); - -export type OrdersListQuery = z.infer; -export type OrderListFilterParsed = z.infer; - -// Admin Products console: view-only list query (admin-UX Increment 2). Mirrors -// `ordersListQuery`'s shape: `limit` coerced + clamped to 1..100 (default 25), -// `cursor` the opaque base64url keyset token. No date window (products aren't -// filtered by creation date in this slice — see the port doc's ordering note); -// `active` is a single boolean (a two-value axis, unlike orders' multi-state -// `states` CSV); `search` matches EITHER an exact sku OR a substring of title -// (the store's shared predicate, port doc). -export const productKindEnum = z.enum(["physical", "digital"]); - -// `deleted` (product lifecycle surfacing, admin-UX Increment 2): the -// tombstone-axis toggle for the admin archive view — omitted/"false" ⇒ the -// ORIGINAL default (live rows only); "true" ⇒ ONLY soft-deleted rows. Same -// tri-state-via-optional-enum shape as `active`. -export const productsListQuery = z.object({ - active: z.enum(["true", "false"]).optional(), - deleted: z.enum(["true", "false"]).optional(), - productKind: productKindEnum.optional(), - search: z.string().min(1).max(200).optional(), - cursor: z.string().min(1).max(1000).optional(), - limit: z.coerce.number().int().min(1).max(100).optional().default(25), - // The low-stock predicate's query-string twin (the admin list's own filter, - // port doc): a raw query param arrives as a string, so it is converted here - // rather than kept as one, unlike the tri-state `active`/`deleted` enums - // above — this one is a number, not a two-value axis. Same domain as - // `lowStockQuery`/`settingsBody` below and as the port's own guard: a - // non-negative integer no greater than `MAX_LOW_STOCK_THRESHOLD`, so nothing - // outside it reaches the port (which would otherwise throw - // `InvalidLowStockThresholdError` and 500 rather than 400 a bad query). - // - // THE DIGIT GATE IS NOT DECORATION, and it is why this does not use - // `z.coerce` the way `limit` above does. Coercion is `Number(value)`, and - // `Number("")` is 0 — so a bare `?lowStockThreshold=` would arrive as a - // perfectly valid threshold of ZERO and silently narrow the list to - // out-of-stock rows, which is the one answer an operator who typed nothing - // cannot have meant. `Number` is equally content with `0x10` (16), `1e2` - // (100) and `" 7 "`, none of which a query string should be allowed to mean - // here. So the SHAPE is checked before the conversion: plain digits, or a - // 400. - // - // (`limit` above keeps its coercion, and the difference is not that an empty - // value is harmless there — `?limit=` coerces to 0, fails `min(1)` and 400s - // the whole query; `.default(25)` only fires when the key is ABSENT. It is - // that `limit`'s bounds catch every value coercion invents, whereas a - // threshold has no upper bound tight enough to do the same job: `0` is a - // perfectly valid threshold, so an empty parameter would sail through.) - lowStockThreshold: z - .string() - .regex(/^\d+$/) - .transform(Number) - .pipe(z.number().int().nonnegative().max(MAX_LOW_STOCK_THRESHOLD)) - .optional(), -}); - -/** Validates the FILTER object carried inside a decoded opaque product-list - * cursor (MOD-1: re-validate the decoded filter through zod before trusting - * it) — mirrors `orderListFilterSchema`. `lowStockThreshold` mirrors - * `productsListQuery`'s own field one layer in: the cursor already carries a - * real number (not a query string), so it is validated rather than coerced. */ -export const productListFilterSchema = z.object({ - active: z.boolean().optional(), - deleted: z.boolean().optional(), - productKind: productKindEnum.optional(), - search: z.string().min(1).max(200).optional(), - lowStockThreshold: z.number().int().nonnegative().max(MAX_LOW_STOCK_THRESHOLD).optional(), -}); - -export type ProductsListQuery = z.infer; -export type ProductListFilterParsed = z.infer; - -export const productPathParams = z.object({ productId: idParam }); - -const addressFields = { - kind: z.enum(["billing", "shipping"]), - name: z.string().min(1).max(200), - line1: z.string().min(1).max(300), - line2: z.string().max(300).nullable().optional(), - city: z.string().min(1).max(200), - region: z.string().max(200).nullable().optional(), - postalCode: z.string().min(1).max(40), - country: z.string().min(2).max(2), - isDefault: z.boolean().optional(), -}; - -export const createAddressBody = z.object(addressFields); -export const updateAddressBody = z.object(addressFields).partial(); - -export const addressPathParams = z.object({ addressId: idParam }); - -export type LoginRequestBody = z.infer; -export type LoginVerifyBody = z.infer; -export type TransitionBody = z.infer; -export type CreateAddressBody = z.infer; - -// Phase 7 (§6): reporting query params + settings body. Money on the wire stays -// integer minor units + ISO-4217 currency (report responses); the domain -// use-case enforces the 400-day range cap (mapped to a 400 by the route). -export const reportRevenueQuery = z.object({ - from: z.string().datetime(), - to: z.string().datetime(), - interval: z.enum(["day", "week", "month"]).optional().default("day"), -}); - -export const ordersByStatusQuery = z.object({ - from: z.string().datetime(), - to: z.string().datetime(), -}); - -export const topProductsQuery = z.object({ - from: z.string().datetime(), - to: z.string().datetime(), - metric: z.enum(["revenue", "quantity"]).optional().default("revenue"), - limit: z.coerce.number().int().positive().max(1000).optional().default(10), -}); - -export const lowStockQuery = z.object({ - threshold: z.coerce.number().int().nonnegative().max(MAX_LOW_STOCK_THRESHOLD).optional(), -}); - -// Settings body — both fields optional (partial update). Bounds mirror the -// domain use-case (holdTtlMinutes positive, ≤ 1 week) and, for the threshold, -// the port's own guard: `MAX_LOW_STOCK_THRESHOLD` is `int4`'s maximum, because -// `inventory.on_hand` is a Postgres `integer` the threshold is compared -// against. The SAVED value is what every later list read binds, so an -// unbounded write here is how an out-of-range threshold would reach the query -// without ever appearing in a URL. Invalid values are a 400, never silently -// clamped (§5.3). -export const settingsBody = z.object({ - holdTtlMinutes: z.number().int().positive().max(10_080).optional(), - lowStockThreshold: z.number().int().nonnegative().max(MAX_LOW_STOCK_THRESHOLD).optional(), -}); - -export type SettingsBody = z.infer; - -export type CommerceBatchBody = z.infer; -export type UpsertProductCommerceBody = z.infer; -export type CreateCartBody = z.infer; -export type AddLineBody = z.infer; -export type PatchLineBody = z.infer; diff --git a/packages/service/src/stripe-wiring.ts b/packages/service/src/stripe-wiring.ts deleted file mode 100644 index 7b15a27a..00000000 --- a/packages/service/src/stripe-wiring.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { Clock } from "@otta-sh/domain"; -import { StripePaymentGateway } from "@otta-sh/payments-stripe"; - -/** The Stripe slice of the service env (mirrors `X402Env`). */ -export interface StripeEnv { - STRIPE_WEBHOOK_SECRET?: string | undefined; - STRIPE_SECRET_KEY?: string | undefined; -} - -/** - * Wire the Stripe gateway from env — and WARN, never throw. - * - * `STRIPE_WEBHOOK_SECRET` is what enables Stripe at all (unset ⇒ no gateway, - * `POST /webhooks/stripe` answers 503). `STRIPE_SECRET_KEY` is now - * **behaviour-changing, not decorative**: with it, `createIntent` performs a real - * `paymentIntents.create` (and refunds are possible — `refundable:true`); without - * it, `createIntent` mints the OFFLINE deterministic handle whose client secret - * is a fake string no Stripe.js/Elements can ever pay. - * - * A deployment with the webhook secret but NO secret key therefore hands buyers - * unpayable client secrets — the exact inverse hazard of the live path, and worth - * a loud boot warning. It is **only** a warning: staging and every e2e - * environment run without a secret key, and must keep booting. (Contrast - * `wireX402Gateway`, which DOES fail closed — there the hazard is a forgeable - * settlement, not a checkout that simply cannot be completed.) - * - * @returns the gateway, or `undefined` when Stripe is simply not configured. - */ -export function wireStripeGateway( - env: StripeEnv, - options: { clock?: Clock } = {}, -): StripePaymentGateway | undefined { - const webhookSecret = env.STRIPE_WEBHOOK_SECRET; - if (webhookSecret === undefined || webhookSecret.length === 0) { - return undefined; // Stripe not configured — nothing to wire. - } - const secretKey = - env.STRIPE_SECRET_KEY !== undefined && env.STRIPE_SECRET_KEY.length > 0 - ? env.STRIPE_SECRET_KEY - : undefined; - if (secretKey === undefined) { - console.warn( - "[service] ⚠ STRIPE_WEBHOOK_SECRET is set but STRIPE_SECRET_KEY is NOT: createIntent " + - "mints OFFLINE, UNPAYABLE client secrets (and refunds are unavailable). Dev/test/e2e " + - "only — set STRIPE_SECRET_KEY to create real PaymentIntents.", - ); - } - return new StripePaymentGateway({ - webhookSecret, - ...(secretKey !== undefined ? { secretKey } : {}), - ...(options.clock !== undefined ? { clock: options.clock } : {}), - }); -} diff --git a/packages/service/src/worker.ts b/packages/service/src/worker.ts deleted file mode 100644 index 740b0388..00000000 --- a/packages/service/src/worker.ts +++ /dev/null @@ -1,444 +0,0 @@ -import { - type CartDeps, - type Clock, - dispatchOrderEmails, - type EmailSender, - type ExpireOrdersDeps, - expireHolds, - expireOrders, - type PaymentGateway, - type PaymentMethod, -} from "@otta-sh/domain"; -import { - KyselyAddressStore, - KyselyCartStore, - KyselyCouponStore, - KyselyCredentialVerifier, - KyselyCustomerStore, - KyselyEntitlementStore, - KyselyInventoryStore, - KyselyOrderNotesStore, - KyselyOrderStore, - KyselyPaymentEventStore, - KyselyProductCommerceStore, - KyselyReportingStore, - KyselySessionStore, - KyselySettingsStore, - KyselyShippingRulesStore, - KyselyTaxRulesStore, - makePostgresDb, - makePostgresPool, - migrateToLatest, - uuidIdGen, -} from "@otta-sh/store-postgres/pg"; -import type { Hono } from "hono"; -import { createApp } from "./app.js"; -import { openWriteGateWarning, resolveServiceConfig, type ServiceConfig } from "./config.js"; -import { ConsoleEmailSender, HttpEmailSender } from "./email/senders.js"; -import { wireStripeGateway } from "./stripe-wiring.js"; -import { wireX402Gateway } from "./x402-wiring.js"; - -/** - * Cloudflare Worker entry (plan D1–D5, D7). Imports ONLY the sqlite-free - * `@otta-sh/store-postgres/pg` subpath so wrangler/esbuild never see the - * better-sqlite3 native addon. - * - * Structural env/runtime types instead of `@cloudflare/workers-types`: the - * ambient globals it injects collide with `@types/node` in this strict - * tsconfig, and three small interfaces cover everything this file touches - * (recorded as a reversible choice in the plan, D2). - */ -export interface WorkerEnv { - /** Injected by the platform from the wrangler `hyperdrive` binding — the - * origin credentials live platform-side, never in this repo. */ - HYPERDRIVE?: { connectionString?: string }; - CART_HOLD_TTL_MS?: string; - INTERNAL_API_TOKEN?: string; - SERVICE_API_TOKEN?: string; - // Phase 4 gateway secrets — same names the Node bin reads from process.env; - // on Workers each is a `wrangler secret put` entry. A gateway is wired only - // when its secret is present (checkout with an unwired method throws at the - // domain → the app's 500 envelope; the webhook route answers 503). - STRIPE_WEBHOOK_SECRET?: string; - STRIPE_SECRET_KEY?: string; - X402_PAYTO?: string; - X402_FACILITATOR_SECRET?: string; - X402_ACCEPTS?: string; - X402_ALLOW_TEST_FACILITATOR?: string; - // Phase 5 email transport + magic-link base URL — same names as the Node - // bin. EMAIL_API_URL unset ⇒ ConsoleEmailSender (workers `console.log`, - // visible in `wrangler tail`); set ⇒ HttpEmailSender over fetch. - EMAIL_API_URL?: string; - EMAIL_API_KEY?: string; - EMAIL_FROM?: string; - STOREFRONT_BASE_URL?: string; -} - -export interface WorkerExecutionContext { - waitUntil(promise: Promise): void; - /** Present on workerd's real ctx; optional so test stubs stay minimal. */ - passThroughOnException?(): void; -} - -export interface WorkerScheduledController { - scheduledTime: number; - cron: string; -} - -/** Test-only seams (D2): a bare `createWorker()` is what wrangler deploys. */ -export interface CreateWorkerOverrides { - makePool?: typeof makePostgresPool; - migrate?: (db: ReturnType) => Promise; - clock?: Clock; -} - -export interface OttaWorker { - fetch(request: Request, env: WorkerEnv, ctx: WorkerExecutionContext): Promise; - scheduled( - controller: WorkerScheduledController, - env: WorkerEnv, - ctx: WorkerExecutionContext, - ): Promise; -} - -type Db = ReturnType; -type PgPool = ReturnType; - -function requireConnectionString(env: WorkerEnv): string { - const connectionString = env.HYPERDRIVE?.connectionString; - if (connectionString === undefined || connectionString.length === 0) { - throw new Error( - 'Missing Hyperdrive connection string: wrangler.jsonc needs a `hyperdrive` binding named "HYPERDRIVE" ' + - "(with a provisioned Hyperdrive config id), the `nodejs_compat` compatibility flag, and " + - "`compatibility_date` >= 2024-09-23 for pg over Hyperdrive to work.", - ); - } - return connectionString; -} - -/** `db.destroy()` is a no-op when the driver never initialized (a request - * that ran no query), so end the pool explicitly as well — guarded by - * `pool.ending` because an initialized driver's destroy already ends it. */ -async function destroyEventDb(db: Db, pool: PgPool): Promise { - try { - await db.destroy(); - } finally { - // Runs even when db.destroy() rejects: the sockets must close regardless - // (the rejection still propagates to teardown's catch for logging). - if (!pool.ending) await pool.end(); - } -} - -/** Defer teardown past the response via waitUntil; never let it reject. */ -function teardown(ctx: WorkerExecutionContext, db: Db | undefined, pool: PgPool | undefined): void { - if (db === undefined || pool === undefined) return; - ctx.waitUntil( - destroyEventDb(db, pool).catch((err: unknown) => { - console.error("[service] pool teardown failed:", err); - }), - ); -} - -/** - * Worker factory. All cross-request memos (parsed config, the "migrations - * done" promise) live in THIS closure — two instances share nothing (tests - * are isolated by construction) while the deployed `export default - * createWorker()` still gets per-isolate memoization (D2/D3). - * - * Per-event resources — pg Pool, Kysely db, stores, the Hono app — are - * created fresh on every fetch/scheduled event and destroyed via - * `ctx.waitUntil` in a `finally`: on workerd a TCP socket is bound to the - * request that opened it, so a cached cross-request pool hangs or errors - * with "Cannot perform I/O on behalf of a different request" (D1). `max: 5` - * is plenty (Hyperdrive owns the real origin pool) and `idleTimeoutMillis: 0` - * disables pg's idle-reaper timer, which would otherwise fire during a later - * request and perform cross-request I/O. - */ -export function createWorker(overrides: CreateWorkerOverrides = {}): OttaWorker { - const makePool = overrides.makePool ?? makePostgresPool; - const migrate = overrides.migrate ?? ((db: Db) => migrateToLatest(db)); - const clock: Clock = overrides.clock ?? { now: () => new Date() }; - - // Config memo — env bindings are stable for a deployment, so the first - // event's parse outcome (value OR error) holds for the isolate's lifetime. - let configMemo: { ok: true; value: ServiceConfig } | { ok: false; error: unknown } | undefined; - let warnedOpenGate = false; - - function getConfig(env: WorkerEnv): ServiceConfig { - if (configMemo === undefined) { - try { - configMemo = { ok: true, value: resolveServiceConfig(env) }; - } catch (error) { - configMemo = { ok: false, error }; - } - } - if (!configMemo.ok) throw configMemo.error; - // Unset OR empty both leave the gate open (the middleware treats an - // empty token as disabled) — warn once per isolate for either. The - // gate-open condition + message live in the shared builder (config.ts). - if (!warnedOpenGate) { - const warning = openWriteGateWarning( - configMemo.value.serviceToken, - "Run `wrangler secret put SERVICE_API_TOKEN` once the CMS-side plugin threads the same token.", - ); - if (warning !== undefined) { - warnedOpenGate = true; - console.warn(warning); - } - } - return configMemo.value; - } - - // Gateway memo — mirrors the Node bin's wiring (index.ts) over the env - // binding instead of process.env. Gateways hold secrets + node:crypto only - // (no sockets), so unlike the pool they are safe to reuse across requests. - // Wiring can THROW (x402's fail-closed test-facilitator opt-in, review G4); - // the outcome — value or error — is memoized exactly like the config. - let gatewaysMemo: - | { ok: true; value: Partial> } - | { ok: false; error: unknown } - | undefined; - - function getGateways(env: WorkerEnv): Partial> { - if (gatewaysMemo === undefined) { - try { - const gateways: Partial> = {}; - // Stripe (src/stripe-wiring.ts, shared with the Node bin): the webhook - // secret enables the gateway, STRIPE_SECRET_KEY flips createIntent to - // REAL PaymentIntents. Missing secret key ⇒ a `wrangler tail`-visible - // warning about unpayable offline client secrets, never a throw. - const stripeGateway = wireStripeGateway(env, { clock }); - if (stripeGateway !== undefined) { - gateways.stripe = stripeGateway; - } - const x402Gateway = wireX402Gateway(env); - if (x402Gateway !== undefined) { - gateways.x402 = x402Gateway; - } - gatewaysMemo = { ok: true, value: gateways }; - } catch (error) { - gatewaysMemo = { ok: false, error }; - } - } - if (!gatewaysMemo.ok) throw gatewaysMemo.error; - return gatewaysMemo.value; - } - - // Email sender memo (Phase 5) — stateless like the gateways; HttpEmailSender - // performs its fetch inside the current event, so cross-request reuse is - // safe. Same env names as the Node bin; unset EMAIL_API_URL falls back to - // ConsoleEmailSender (visible via `wrangler tail`). - let emailSenderMemo: EmailSender | undefined; - - function getEmailSender(env: WorkerEnv): EmailSender { - emailSenderMemo ??= - env.EMAIL_API_URL !== undefined && env.EMAIL_API_URL.length > 0 - ? new HttpEmailSender({ - apiUrl: env.EMAIL_API_URL, - apiKey: env.EMAIL_API_KEY, - from: env.EMAIL_FROM ?? "no-reply@otta.local", - }) - : new ConsoleEmailSender(); - return emailSenderMemo; - } - - // Migrations: lazy, once per isolate, inside the first event (workers have - // no boot phase and forbid top-level I/O). A rejection clears the memo so - // the next event retries; cross-isolate races are serialized by kysely's - // `kysely_migration_lock` and migrations are forward-only/idempotent (D3). - let migrated: Promise | undefined; - - function ensureMigrated(db: Db): Promise { - migrated ??= migrate(db).catch((err: unknown) => { - migrated = undefined; - throw err; - }); - return migrated; - } - - function makeEventDb(env: WorkerEnv): { pool: PgPool; db: Db } { - const pool = makePool({ - connectionString: requireConnectionString(env), - max: 5, - idleTimeoutMillis: 0, - }); - return { pool, db: makePostgresDb(pool) }; - } - - function buildApp( - db: Db, - env: WorkerEnv, - config: ServiceConfig, - gateways: Partial>, - ): Hono { - const store = new KyselyInventoryStore({ db, idGen: uuidIdGen, clock }); - const productCommerce = new KyselyProductCommerceStore({ db, clock }); - const cartStore = new KyselyCartStore({ db, idGen: uuidIdGen, clock }); - const orderStore = new KyselyOrderStore({ db, idGen: uuidIdGen, clock }); - const orderNotesStore = new KyselyOrderNotesStore({ db, idGen: uuidIdGen, clock }); - const entitlementStore = new KyselyEntitlementStore({ db, idGen: uuidIdGen, clock }); - const paymentEventStore = new KyselyPaymentEventStore({ db, idGen: uuidIdGen }); - // Phase 6 rules + Phase 7 reporting/settings (reporting is SQL-dialect- - // aware; this entry is pg-only by construction). - const shippingRules = new KyselyShippingRulesStore({ db }); - const taxRules = new KyselyTaxRulesStore({ db }); - const couponStore = new KyselyCouponStore({ db, idGen: uuidIdGen, clock }); - const reportingStore = new KyselyReportingStore({ db, dialect: "postgres" }); - const settingsStore = new KyselySettingsStore({ db, clock }); - // Phase 5 customer identity + email surface — mirrors the Node bin. - const customerStore = new KyselyCustomerStore({ db, idGen: uuidIdGen, clock }); - const addressStore = new KyselyAddressStore({ db, idGen: uuidIdGen, clock }); - const sessionStore = new KyselySessionStore({ db, idGen: uuidIdGen, clock }); - const credentialVerifier = new KyselyCredentialVerifier({ - db, - customerStore, - idGen: uuidIdGen, - clock, - }); - const storefrontBaseUrl = env.STOREFRONT_BASE_URL; - return createApp({ - store, - productCommerce, - cartStore, - orderStore, - orderNotesStore, - entitlementStore, - paymentEventStore, - shippingRules, - taxRules, - couponStore, - reportingStore, - settingsStore, - customerStore, - addressStore, - sessionStore, - credentialVerifier, - emailSender: getEmailSender(env), - idGen: uuidIdGen, - gateways, - clock, - ttlMs: config.ttlMs, - // Same knob as the Node bin: CART_HOLD_TTL_MS drives both TTLs. - checkoutTtlMs: config.ttlMs, - internalToken: config.internalToken, - serviceToken: config.serviceToken, - ...(storefrontBaseUrl !== undefined ? { storefrontBaseUrl } : {}), - }); - } - - return { - async fetch(request, env, ctx): Promise { - let pool: PgPool | undefined; - let db: Db | undefined; - try { - // Config resolves INSIDE the try — before any pool exists — so a bad - // CART_HOLD_TTL_MS binding is the standard 500 envelope with zero - // cleanup surface, never an uncaught workerd exception. - const config = getConfig(env); - const gateways = getGateways(env); - ({ pool, db } = makeEventDb(env)); - await ensureMigrated(db); - const app = buildApp(db, env, config, gateways); - // Every route returns a buffered `c.json(...)` body, so `finally` - // (which only DEFERS destroy via waitUntil) can never truncate it. - // env/ctx are threaded through for any future route that reads - // `c.env`/`c.executionCtx` (no current route does — no behavior - // change). workerd's real ctx satisfies Hono's ExecutionContext; - // test stubs only carry waitUntil, which is all Hono itself calls. - return await app.fetch(request, env, ctx as Parameters[2]); - } catch (err) { - console.error("[service] worker event failed:", err); - return Response.json({ ok: false, error: "internal_error" }, { status: 500 }); - } finally { - teardown(ctx, db, pool); - } - }, - - // The cron sweeps call the domain use-cases directly — no HTTP self-call, - // so they need no secret and cannot silently degrade to a 503 no-op (D5). - // Failures are logged, never thrown: hold correctness is carried by - // lazy-on-read expiry, and order expiry's guarded flips are idempotent — - // the next 15-min tick retries. Order expiry (Phase 4) is clock-driven - // (NOT lazy-on-read), so this cron is its production driver on Workers — - // the same janitor pattern the Node bin exposes via - // `POST /internal/expire-orders`. - async scheduled(_controller, env, ctx): Promise { - let pool: PgPool | undefined; - let db: Db | undefined; - try { - const config = getConfig(env); - ({ pool, db } = makeEventDb(env)); - await ensureMigrated(db); - const store = new KyselyInventoryStore({ db, idGen: uuidIdGen, clock }); - const cartStore = new KyselyCartStore({ db, idGen: uuidIdGen, clock }); - const orderStore = new KyselyOrderStore({ db, idGen: uuidIdGen, clock }); - const couponStore = new KyselyCouponStore({ db, idGen: uuidIdGen, clock }); - const cartDeps: CartDeps = { - cartStore, - inventoryStore: store, - clock, - ttlMs: config.ttlMs, - }; - // couponStore: Phase 6 (review I2) — expiry releases the order's coupon. - const expireDeps: ExpireOrdersDeps = { - orderStore, - inventoryStore: store, - couponStore, - clock, - }; - // Each janitor gets its OWN catch: a persistently failing hold sweep - // must not starve order expiry (or vice versa), and each failure - // carries its own label for diagnostics. - try { - const reclaimed = await expireHolds(cartDeps); - console.log(`[service] cron sweep reclaimed ${reclaimed}`); - } catch (err) { - console.error("[service] hold sweep failed:", err); - } - try { - const expired = await expireOrders(expireDeps); - console.log(`[service] cron sweep expired ${expired} orders`); - } catch (err) { - console.error("[service] order sweep failed:", err); - } - // Phase 5 maintenance legs — the same pair the Node bin's - // self-interval runs (and POST /internal/dispatch-emails triggers): - // drain the order-email outbox (claims are atomic, at-least-once, - // send failures retried next tick) and prune consumed/expired login - // challenges. Same labels as index.ts. - const customerStore = new KyselyCustomerStore({ db, idGen: uuidIdGen, clock }); - const credentialVerifier = new KyselyCredentialVerifier({ - db, - customerStore, - idGen: uuidIdGen, - clock, - }); - try { - const sent = await dispatchOrderEmails({ - orderStore, - emailSender: getEmailSender(env), - customerStore, - clock, - }); - console.log(`[service] cron sweep sent ${sent} emails`); - } catch (err) { - console.error("[service] email dispatch failed:", err); - } - try { - const pruned = await credentialVerifier.pruneChallenges(clock.now().toISOString()); - console.log(`[service] cron sweep pruned ${pruned} login challenges`); - } catch (err) { - console.error("[service] login-challenge prune failed:", err); - } - } catch (err) { - // Setup failures only (config/binding/pool/migration) — the sweeps - // catch their own. - console.error("[service] cron event failed:", err); - } finally { - teardown(ctx, db, pool); - } - }, - }; -} - -export default createWorker(); diff --git a/packages/service/src/x402-wiring.ts b/packages/service/src/x402-wiring.ts deleted file mode 100644 index 0c9a11d9..00000000 --- a/packages/service/src/x402-wiring.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { createTestFacilitator, X402PaymentGateway } from "@otta-sh/payments-x402"; - -/** The x402 slice of the service env (review G4). */ -export interface X402Env { - X402_PAYTO?: string | undefined; - X402_FACILITATOR_SECRET?: string | undefined; - X402_ACCEPTS?: string | undefined; - X402_ALLOW_TEST_FACILITATOR?: string | undefined; -} - -/** - * Wire the x402 gateway from env — FAIL CLOSED (review G4). - * - * The only facilitator this bin can currently wire is `createTestFacilitator`: - * an OFFLINE shared-secret HMAC check, NOT a real x402 facilitator - * verification — anyone holding (or guessing a deployment leaked) - * `X402_FACILITATOR_SECRET` can mint a "verified" proof and settle any - * same-priced order. So configuring `X402_PAYTO` + `X402_FACILITATOR_SECRET` - * WITHOUT the explicit `X402_ALLOW_TEST_FACILITATOR=true` opt-in refuses to - * start (a thrown Error, never a silently-armed gateway), and the opt-in path - * warns loudly at startup that it is not production-safe. A production - * deployment replaces this with a real `HTTPFacilitatorClient`-backed - * `X402Facilitator` (the seam is `X402PaymentGateway`'s injected - * `facilitator`), at which point the opt-in gate stops applying to it. - * - * @returns the gateway, or `undefined` when x402 is simply not configured. - * @throws when x402 IS configured but the test-facilitator opt-in is absent. - */ -export function wireX402Gateway(env: X402Env): X402PaymentGateway | undefined { - const payTo = env.X402_PAYTO; - const secret = env.X402_FACILITATOR_SECRET; - if (payTo === undefined || payTo.length === 0 || secret === undefined || secret.length === 0) { - return undefined; // x402 not configured — nothing to wire. - } - if (env.X402_ALLOW_TEST_FACILITATOR !== "true") { - throw new Error( - "x402 is configured (X402_PAYTO + X402_FACILITATOR_SECRET) but the only available " + - "facilitator is the OFFLINE TEST facilitator (shared-secret HMAC, no real x402 " + - "verification) — refusing to start. Set X402_ALLOW_TEST_FACILITATOR=true ONLY for " + - "non-production environments, or wire a real HTTPFacilitatorClient-backed facilitator.", - ); - } - console.warn( - "[service] ⚠ x402 is using createTestFacilitator (X402_ALLOW_TEST_FACILITATOR=true): " + - "offline shared-secret HMAC verification — NOT production-safe. Any holder of " + - "X402_FACILITATOR_SECRET can forge a settling proof.", - ); - return new X402PaymentGateway({ - facilitator: createTestFacilitator(secret), - payTo, - accepts: (env.X402_ACCEPTS ?? "eip155:8453").split(","), - }); -} diff --git a/packages/service/test/admin-cancel-http.test.ts b/packages/service/test/admin-cancel-http.test.ts deleted file mode 100644 index c6b14119..00000000 --- a/packages/service/test/admin-cancel-http.test.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test } from "vitest"; -import { startTestServer, type TestServer } from "./helpers/start-test-server.js"; - -// Admin cancel-with-reason HTTP contract (admin-UX Increment 1): wire ⇄ port -// fidelity for POST /admin/orders/:id/cancel against a LIVE server backed by -// Postgres. Cancelling drives {pending,paid,processing} → cancelled and records -// the structured reason envelope, NEVER touching line items. Guards: -// internal-token, the X-Service-Token write gate (a non-GET), validation -// (bad reason / blank cancelledBy → 400), a non-cancellable order (→ 409 -// NOT_CANCELLABLE), an unknown order (→ 404), and idempotent replay via the -// guarded flip. - -const PG = process.env.PG_CONNECTION_STRING; - -async function json(res: Response): Promise> { - return (await res.json()) as Record; -} - -describe.skipIf(PG === undefined)("admin cancel-order HTTP contract", () => { - let server: TestServer; - let token: string; - - beforeEach(async () => { - server = await startTestServer(); - token = server.internalToken as string; - // A processing order — cancellable (pre-shipment). - await server.seedOrder({ - id: "ord-proc", - state: "processing", - currency: "USD", - buyerRef: "alice@example.com", - createdAt: "2026-07-10T00:00:00.000Z", - totalCents: 1000, - }); - // A shipped order — terminal-adjacent, not cancellable via this slice. - await server.seedOrder({ - id: "ord-shipped", - state: "shipped", - currency: "USD", - buyerRef: "bob@example.com", - createdAt: "2026-07-10T00:00:00.000Z", - totalCents: 500, - }); - }); - afterEach(async () => { - await server.stop(); - }); - - function post( - orderId: string, - body: Record, - opts: { token?: string | null; idempotencyKey?: string; serviceToken?: string } = {}, - ): Promise { - const headers: Record = { "content-type": "application/json" }; - const tk = opts.token === undefined ? token : opts.token; - if (tk !== null) headers["X-Internal-Token"] = tk; - if (opts.idempotencyKey !== undefined) headers["Idempotency-Key"] = opts.idempotencyKey; - if (opts.serviceToken !== undefined) headers["X-Service-Token"] = opts.serviceToken; - return fetch(`${server.baseUrl}/admin/orders/${orderId}/cancel`, { - method: "POST", - headers, - body: JSON.stringify(body), - }); - } - - function getOrder(orderId: string): Promise { - return fetch(`${server.baseUrl}/admin/orders/${orderId}`, { - headers: { "X-Internal-Token": token }, - }); - } - - test("cancels a processing order with a reason (200, cancelled:true): records it + moves to cancelled", async () => { - const res = await post("ord-proc", { - reason: "out_of_stock", - detail: "last unit sold on another channel", - cancelledBy: "ops@shop.test", - }); - expect(res.status).toBe(200); - const body = await json(res); - expect(body.cancelled).toBe(true); - const order = body.order as Record; - expect(order.state).toBe("cancelled"); - expect(order.cancellation).toMatchObject({ - reason: "out_of_stock", - detail: "last unit sold on another channel", - cancelledBy: "ops@shop.test", - }); - - // A fresh GET reflects the cancelled state + reason; cancelled is terminal - // (no allowedTransitions). - const reloaded = await json(await getOrder("ord-proc")); - const ro = reloaded.order as Record; - expect(ro.state).toBe("cancelled"); - expect((ro.cancellation as Record).reason).toBe("out_of_stock"); - expect(reloaded.allowedTransitions).toEqual([]); - }); - - test("an absent detail normalizes to null", async () => { - const body = await json( - await post("ord-proc", { reason: "customer_request", cancelledBy: "alice" }), - ); - const c = (body.order as Record).cancellation as Record; - expect(c.detail).toBeNull(); - }); - - test("a non-cancellable (shipped) order → 409 NOT_CANCELLABLE; state untouched", async () => { - const res = await post("ord-shipped", { reason: "customer_request", cancelledBy: "ops" }); - expect(res.status).toBe(409); - expect((await json(res)).reason).toBe("NOT_CANCELLABLE"); - const reloaded = (await json(await getOrder("ord-shipped"))).order as Record; - expect(reloaded.state).toBe("shipped"); - expect(reloaded.cancellation).toBeNull(); - }); - - test("replay is once-only: a second cancel is cancelled:false, reason unchanged", async () => { - const first = await json( - await post("ord-proc", { reason: "out_of_stock", cancelledBy: "alice" }), - ); - expect(first.cancelled).toBe(true); - const replay = await json( - await post("ord-proc", { reason: "pricing_error", cancelledBy: "bob" }), - ); - expect(replay.cancelled).toBe(false); - // The first reason stands — the loser never overwrote it. - const c = (replay.order as Record).cancellation as Record; - expect(c.reason).toBe("out_of_stock"); - expect(c.cancelledBy).toBe("alice"); - }); - - test("validation: an unknown reason value → 400", async () => { - expect( - (await post("ord-proc", { reason: "buyer_changed_mind", cancelledBy: "y" })).status, - ).toBe(400); - }); - - test("validation: a blank cancelledBy → 400", async () => { - expect( - (await post("ord-proc", { reason: "customer_request", cancelledBy: " " })).status, - ).toBe(400); - }); - - test("unknown order → 404", async () => { - const res = await post("does-not-exist", { reason: "customer_request", cancelledBy: "y" }); - expect(res.status).toBe(404); - expect((await json(res)).reason).toBe("ORDER_NOT_FOUND"); - }); - - test("guard: no internal token ⇒ 401", async () => { - const res = await post( - "ord-proc", - { reason: "customer_request", cancelledBy: "y" }, - { token: null }, - ); - expect(res.status).toBe(401); - }); - - test("write gate: with a service token set, POST needs X-Service-Token (401 without, 200 with)", async () => { - const gated = await startTestServer({ serviceToken: "svc-secret" }); - try { - const gatedToken = gated.internalToken as string; - await gated.seedOrder({ - id: "ord-g", - state: "processing", - currency: "USD", - buyerRef: "g@example.com", - createdAt: "2026-07-10T00:00:00.000Z", - totalCents: 500, - }); - const common = { "content-type": "application/json", "X-Internal-Token": gatedToken }; - const path = `${gated.baseUrl}/admin/orders/ord-g/cancel`; - const payload = JSON.stringify({ reason: "customer_request", cancelledBy: "ops" }); - const blocked = await fetch(path, { method: "POST", headers: common, body: payload }); - expect(blocked.status).toBe(401); - const ok = await fetch(path, { - method: "POST", - headers: { ...common, "X-Service-Token": "svc-secret" }, - body: payload, - }); - expect(ok.status).toBe(200); - expect((await json(ok)).cancelled).toBe(true); - } finally { - await gated.stop(); - } - }); -}); diff --git a/packages/service/test/admin-coupons-http.test.ts b/packages/service/test/admin-coupons-http.test.ts deleted file mode 100644 index 45b4b559..00000000 --- a/packages/service/test/admin-coupons-http.test.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test } from "vitest"; -import { startTestServer, type TestServer } from "./helpers/start-test-server.js"; - -// Admin Coupons console (view-only, admin-UX Increment 3): wire ⇄ port -// fidelity for GET /admin/coupons (list, keyset cursor round-trip preserving -// the filter), against a LIVE server backed by Postgres. Guards: no token ⇒ -// 401, no configured token ⇒ 503. Cursor fail-closed (MOD-1): a garbage/ -// tampered cursor ⇒ 400; a decoded out-of-range limit is clamped, not -// honored. Mirrors admin-products-http.test.ts's shape. - -const PG = process.env.PG_CONNECTION_STRING; - -async function json(res: Response): Promise> { - return (await res.json()) as Record; -} - -/** Encode an opaque cursor the way the route does (base64url of the JSON) so a - * test can craft a tampered/out-of-range token. */ -function b64url(payload: unknown): string { - return Buffer.from(JSON.stringify(payload), "utf8").toString("base64url"); -} - -describe.skipIf(PG === undefined)("admin Coupons console HTTP contract", () => { - let server: TestServer; - let token: string; - - beforeEach(async () => { - server = await startTestServer(); - token = server.internalToken as string; - }); - afterEach(async () => { - await server.stop(); - }); - - function get(path: string, opts: { token?: string } = { token }): Promise { - const headers: Record = {}; - if (opts.token !== undefined) headers["X-Internal-Token"] = opts.token; - return fetch(`${server.baseUrl}/admin${path}`, { headers }); - } - - async function seed(): Promise { - // Three coupons across three creation times, plus a redeemed one so the - // usesCount indicator is exercised. - await server.seedCouponRow({ - id: "cpn-1", - code: "SAVE5", - type: "fixed_amount", - amountCents: 500, - currency: "USD", - maxUses: 10, - usesCount: 0, - createdAt: "2026-07-10T01:00:00.000Z", - }); - await server.seedCouponRow({ - id: "cpn-2", - code: "TEN-OFF", - type: "percentage", - rateBps: 1000, - capCents: 2000, - // Validity window: the list wire MUST carry it (PR #74 review) — the - // console renders the expiry column straight off the summary row. - startsAt: "2026-07-01T00:00:00.000Z", - expiresAt: "2026-08-01T00:00:00.000Z", - maxUses: 5, - usesCount: 2, - createdAt: "2026-07-11T01:00:00.000Z", - }); - await server.seedCouponRow({ - id: "cpn-3", - code: "WELCOME", - type: "fixed_amount", - amountCents: 1000, - currency: "USD", - createdAt: "2026-07-12T01:00:00.000Z", - }); - } - - test("GET /admin/coupons lists newest-first with the summary projection (integer cents, usesCount as the redeemed indicator)", async () => { - await seed(); - const body = await json(await get("/coupons")); - expect(body.ok).toBe(true); - const coupons = body.coupons as Array>; - expect(coupons.map((c) => c.id)).toEqual(["cpn-3", "cpn-2", "cpn-1"]); - const redeemed = coupons.find((c) => c.id === "cpn-2")!; - expect(redeemed).toMatchObject({ - id: "cpn-2", - code: "TEN-OFF", - type: "percentage", - rateBps: 1000, - capCents: 2000, - // The validity window is ON the list wire (PR #74 review) — dropping - // it would force the console into a per-row detail fetch. - startsAt: "2026-07-01T00:00:00.000Z", - expiresAt: "2026-08-01T00:00:00.000Z", - maxUses: 5, - usesCount: 2, - createdAt: "2026-07-11T01:00:00.000Z", - }); - const unredeemed = coupons.find((c) => c.id === "cpn-1")!; - expect(unredeemed.usesCount).toBe(0); - // A coupon with no window carries EXPLICIT nulls — present unconditionally - // on the wire, never "sometimes absent". - expect(unredeemed.startsAt).toBeNull(); - expect(unredeemed.expiresAt).toBeNull(); - expect(body.nextCursor).toBeNull(); - }); - - test("search matches an EXACT code, case-insensitively (never a substring)", async () => { - await seed(); - const exact = await json(await get("/coupons?search=save5")); - expect((exact.coupons as Array>).map((c) => c.id)).toEqual(["cpn-1"]); - - const partial = await json(await get("/coupons?search=save")); - expect(partial.coupons as Array>).toEqual([]); - }); - - test("keyset cursor round-trips and preserves the filter across pages (no overlap/gap)", async () => { - await seed(); - const page1 = await json(await get("/coupons?limit=2")); - const p1 = page1.coupons as Array>; - expect(p1.map((c) => c.id)).toEqual(["cpn-3", "cpn-2"]); - expect(typeof page1.nextCursor).toBe("string"); - - const page2 = await json( - await get(`/coupons?cursor=${encodeURIComponent(page1.nextCursor as string)}`), - ); - const p2 = page2.coupons as Array>; - expect(p2.map((c) => c.id)).toEqual(["cpn-1"]); - expect(page2.nextCursor).toBeNull(); - expect([...p1, ...p2].map((c) => c.id)).toEqual(["cpn-3", "cpn-2", "cpn-1"]); - }); - - // -- total: the exact size of the filtered set (INC-23) -------------------- - - test("GET /admin/coupons carries `total` — the whole FILTERED set, identical on every page, and 0 (present) when nothing matches", async () => { - await seed(); - const page1 = await json(await get("/coupons?limit=2")); - // 3 coupons behind a 2-row page. - expect(page1.total).toBe(3); - expect((page1.coupons as unknown[]).length).toBe(2); - const page2 = await json( - await get(`/coupons?cursor=${encodeURIComponent(page1.nextCursor as string)}`), - ); - expect(page2.total).toBe(3); - // The count is taken under the LIST's own predicate — the same EXACT-match - // search, never a substring. - expect((await json(await get("/coupons?search=save5"))).total).toBe(1); - const none = await json(await get("/coupons?search=save")); - expect(none.coupons).toEqual([]); - // Zero is REPORTED, not omitted (the key's presence is the capability). - expect(none.total).toBe(0); - expect(Object.hasOwn(none, "total")).toBe(true); - }); - - test("guard: no token ⇒ 401", async () => { - expect((await get("/coupons", {})).status).toBe(401); - }); - - test("guard: a server with no configured internal token ⇒ 503 (disabled, not open)", async () => { - const disabled = await startTestServer({ internalToken: null }); - try { - const res = await fetch(`${disabled.baseUrl}/admin/coupons`); - expect(res.status).toBe(503); - } finally { - await disabled.stop(); - } - }); - - test("MOD-1: a garbage/tampered cursor fails closed with 400 (never 500)", async () => { - expect((await get("/coupons?cursor=%21%21%21not-base64%21%21%21")).status).toBe(400); - const notJson = Buffer.from("this is not json", "utf8").toString("base64url"); - expect((await get(`/coupons?cursor=${notJson}`)).status).toBe(400); - // A cursor whose pos.createdAt is not a valid ISO datetime ⇒ 400. - const badCreatedAt = b64url({ - pos: { createdAt: "not-a-timestamp", couponId: "cpn-3" }, - filter: {}, - limit: 25, - }); - expect((await get(`/coupons?cursor=${badCreatedAt}`)).status).toBe(400); - }); - - test("MOD-1: a decoded out-of-range limit is clamped, not honored (no 400/500)", async () => { - await seed(); - const cursor = b64url({ - pos: { createdAt: "2999-01-01T00:00:00.000Z", couponId: "zzzz" }, - filter: {}, - limit: 999_999, - }); - const res = await get(`/coupons?cursor=${cursor}`); - expect(res.status).toBe(200); // clamped to the max, request still succeeds - const coupons = (await json(res)).coupons as Array>; - expect(coupons.map((c) => c.id)).toEqual(["cpn-3", "cpn-2", "cpn-1"]); - }); -}); diff --git a/packages/service/test/admin-customer-context-http.test.ts b/packages/service/test/admin-customer-context-http.test.ts deleted file mode 100644 index 8e98509a..00000000 --- a/packages/service/test/admin-customer-context-http.test.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test } from "vitest"; -import { startTestServer, type TestServer } from "./helpers/start-test-server.js"; - -// Admin customer-context HTTP contract (admin-UX Increment 1): wire ⇄ use-case -// fidelity for GET /admin/orders/:id/customer-context against a LIVE server -// backed by Postgres, with the account minted through the REAL magic-link flow -// (request → verify), so linking semantics (`linkGuestOrders`) are the genuine -// article, not a seeded approximation. Guards: internal-token (401 without), -// unknown order (404). The headline case is the lazy-linking regression: a -// linked order and a later, not-yet-relinked order of the SAME person must -// answer with the SAME identity and the SAME counts. - -const PG = process.env.PG_CONNECTION_STRING; - -async function json(res: Response): Promise> { - return (await res.json()) as Record; -} - -describe.skipIf(PG === undefined)("admin customer-context HTTP contract", () => { - let server: TestServer; - let token: string; - - beforeEach(async () => { - server = await startTestServer(); - token = server.internalToken as string; - }); - afterEach(async () => { - await server.stop(); - }); - - function lastLoginToken(): { challengeId: string; token: string } { - const sends = server.emailSender.sends.filter((s) => s.template === "customer-login-link"); - const last = sends[sends.length - 1]!; - return { challengeId: last.data["challengeId"] as string, token: last.data["token"] as string }; - } - - /** Full magic-link login over the wire → the bearer session token. Also - * links any guest orders with a matching buyer_ref (the real mechanism). */ - async function login(email: string): Promise { - const reqRes = await fetch(`${server.baseUrl}/auth/login/request`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email }), - }); - expect(reqRes.status).toBe(200); - const { challengeId, token: magicToken } = lastLoginToken(); - const verifyRes = await fetch(`${server.baseUrl}/auth/login/verify`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ challengeId, token: magicToken }), - }); - expect(verifyRes.status).toBe(200); - return (await json(verifyRes))["sessionToken"] as string; - } - - function getContext(orderId: string, opts: { token?: string } = { token }): Promise { - const headers: Record = {}; - if (opts.token !== undefined) headers["X-Internal-Token"] = opts.token; - return fetch(`${server.baseUrl}/admin/orders/${orderId}/customer-context`, { headers }); - } - - test("lazy-linking regression: a claimed and a later unclaimed order answer with the SAME account and counts", async () => { - // Guest checkout (mixed case), then bob logs in → the order gets linked. - await server.seedOrder({ - id: "ord-a", - state: "paid", - currency: "USD", - buyerRef: "Bob@Example.com", - createdAt: "2026-07-10T00:00:01.000Z", - totalCents: 1500, - }); - const sessionToken = await login("bob@example.com"); - // A saved address on the profile (through the real /me surface). - const addrRes = await fetch(`${server.baseUrl}/me/addresses`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${sessionToken}`, - }, - body: JSON.stringify({ - kind: "shipping", - name: "Bob", - line1: "1 Main St", - city: "Springfield", - postalCode: "12345", - country: "US", - isDefault: true, - }), - }); - expect(addrRes.status).toBe(201); - // A NEW order after that login — born unlinked (the common path). - await server.seedOrder({ - id: "ord-b", - state: "paid", - currency: "USD", - buyerRef: "bob@example.com", - createdAt: "2026-07-10T00:00:02.000Z", - totalCents: 2500, - }); - - const fromA = await json(await getContext("ord-a")); - const fromB = await json(await getContext("ord-b")); - expect(fromA.ok).toBe(true); - expect(fromB.ok).toBe(true); - const ctxA = fromA.context as Record; - const ctxB = fromB.context as Record; - const idA = ctxA.identity as Record; - const idB = ctxB.identity as Record; - - // Same resolved account either way; linkage tells the true story. - expect(idA.email).toBe("bob@example.com"); - expect(idB.email).toBe("bob@example.com"); - expect(idA.customerId).toBe(idB.customerId); - expect(idA.linkage).toBe("claimed"); - expect(idB.linkage).toBe("unclaimed"); - expect(idA.emailVerifiedAt).not.toBeNull(); // the login proved the inbox - - // Union counts agree; each order's "recent" is the OTHER order. - expect(ctxA.orderCount).toBe(2); - expect(ctxB.orderCount).toBe(2); - expect((ctxA.recentOrders as Array<{ id: string }>).map((o) => o.id)).toEqual(["ord-b"]); - expect((ctxB.recentOrders as Array<{ id: string }>).map((o) => o.id)).toEqual(["ord-a"]); - - // The profile address book + token-free session history surface on BOTH. - for (const ctx of [ctxA, ctxB]) { - const addresses = ctx.addresses as Array>; - expect(addresses.map((a) => a.line1)).toEqual(["1 Main St"]); - const sessions = ctx.sessions as Array>; - expect(sessions.length).toBeGreaterThanOrEqual(1); - for (const s of sessions) { - expect(Object.keys(s).toSorted()).toEqual(["createdAt", "expiresAt", "id", "revokedAt"]); - } - } - }); - - test("a guest order with no account answers linkage:guest with empty addresses/sessions", async () => { - await server.seedOrder({ - id: "ord-guest", - state: "paid", - currency: "USD", - buyerRef: "carol@example.com", - createdAt: "2026-07-10T00:00:01.000Z", - totalCents: 900, - }); - const body = await json(await getContext("ord-guest")); - expect(body.ok).toBe(true); - const ctx = body.context as Record; - expect(ctx.identity).toEqual({ - customerId: null, - buyerRef: "carol@example.com", - email: null, - displayName: null, - emailVerifiedAt: null, - linkage: "guest", - }); - expect(ctx.addresses).toEqual([]); - expect(ctx.sessions).toEqual([]); - expect(ctx.orderCount).toBe(1); - expect(ctx.recentOrders).toEqual([]); - }); - - test("unknown order → 404 ORDER_NOT_FOUND", async () => { - const res = await getContext("does-not-exist"); - expect(res.status).toBe(404); - expect((await json(res)).reason).toBe("ORDER_NOT_FOUND"); - }); - - test("guard: no internal token ⇒ 401 (the read is admin-only — it carries PII)", async () => { - await server.seedOrder({ - id: "ord-guarded", - state: "paid", - currency: "USD", - buyerRef: "bob@example.com", - createdAt: "2026-07-10T00:00:01.000Z", - totalCents: 100, - }); - expect((await getContext("ord-guarded", {})).status).toBe(401); - }); -}); diff --git a/packages/service/test/admin-fulfillment-http.test.ts b/packages/service/test/admin-fulfillment-http.test.ts deleted file mode 100644 index b5700851..00000000 --- a/packages/service/test/admin-fulfillment-http.test.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test } from "vitest"; -import { startTestServer, type TestServer } from "./helpers/start-test-server.js"; - -// Admin record-fulfillment HTTP contract (admin-UX Increment 1): wire ⇄ port -// fidelity for POST /admin/orders/:id/fulfillment against a LIVE server backed by -// Postgres. Recording fulfillment ships a `processing` order (`→ shipped`) and -// records the tracking envelope, NEVER touching line items. Guards: internal-token, -// the X-Service-Token write gate (a non-GET), validation (blank fields → 400), a -// non-processing order (→ 409 NOT_FULFILLABLE), an unknown order (→ 404), and -// idempotent replay via the guarded flip. - -const PG = process.env.PG_CONNECTION_STRING; - -async function json(res: Response): Promise> { - return (await res.json()) as Record; -} - -describe.skipIf(PG === undefined)("admin record-fulfillment HTTP contract", () => { - let server: TestServer; - let token: string; - - beforeEach(async () => { - server = await startTestServer(); - token = server.internalToken as string; - // A processing order ready to ship. - await server.seedOrder({ - id: "ord-proc", - state: "processing", - currency: "USD", - buyerRef: "alice@example.com", - createdAt: "2026-07-10T00:00:00.000Z", - totalCents: 1000, - }); - // A paid order — not yet fulfillable (must reach processing first). - await server.seedOrder({ - id: "ord-paid", - state: "paid", - currency: "USD", - buyerRef: "bob@example.com", - createdAt: "2026-07-10T00:00:00.000Z", - totalCents: 500, - }); - }); - afterEach(async () => { - await server.stop(); - }); - - function post( - orderId: string, - body: Record, - opts: { token?: string | null; idempotencyKey?: string; serviceToken?: string } = {}, - ): Promise { - const headers: Record = { "content-type": "application/json" }; - const tk = opts.token === undefined ? token : opts.token; - if (tk !== null) headers["X-Internal-Token"] = tk; - if (opts.idempotencyKey !== undefined) headers["Idempotency-Key"] = opts.idempotencyKey; - if (opts.serviceToken !== undefined) headers["X-Service-Token"] = opts.serviceToken; - return fetch(`${server.baseUrl}/admin/orders/${orderId}/fulfillment`, { - method: "POST", - headers, - body: JSON.stringify(body), - }); - } - - function getOrder(orderId: string): Promise { - return fetch(`${server.baseUrl}/admin/orders/${orderId}`, { - headers: { "X-Internal-Token": token }, - }); - } - - test("records fulfillment on a processing order (200, recorded:true): ships it + stores tracking", async () => { - const res = await post("ord-proc", { - carrier: "UPS", - trackingNumber: "1Z-999", - trackingUrl: "https://track/1Z-999", - shippedAt: "2026-07-11T09:00:00.000Z", - recordedBy: "ops@shop.test", - }); - expect(res.status).toBe(200); - const body = await json(res); - expect(body.recorded).toBe(true); - const order = body.order as Record; - expect(order.state).toBe("shipped"); - expect(order.fulfillment).toMatchObject({ - carrier: "UPS", - trackingNumber: "1Z-999", - trackingUrl: "https://track/1Z-999", - shippedAt: "2026-07-11T09:00:00.000Z", - recordedBy: "ops@shop.test", - }); - - // A fresh GET reflects the shipped state + fulfillment; allowedTransitions - // come from the domain state machine (shipped → delivered|refunded). - const reloaded = await json(await getOrder("ord-proc")); - const ro = reloaded.order as Record; - expect(ro.state).toBe("shipped"); - expect((ro.fulfillment as Record).trackingNumber).toBe("1Z-999"); - // AND ASSERT IT, because the admin console's DA-2a watermark rests on this exact - // row: the route returns `[...legalNextStates(state)]` with NO narrowing, so a - // shipped order really is offered the TERMINAL `refunded` flip. Until this line - // existed the claim was grep-only, and the comment above asserted nothing. - expect(reloaded.allowedTransitions).toEqual(["delivered", "refunded"]); - }); - - test("trims the free-text fields + normalizes an absent tracking URL / ship time", async () => { - const body = await json( - await post("ord-proc", { - carrier: " DHL ", - trackingNumber: " DH-42 ", - recordedBy: " alice ", - }), - ); - const f = (body.order as Record).fulfillment as Record; - expect(f.carrier).toBe("DHL"); - expect(f.trackingNumber).toBe("DH-42"); - expect(f.recordedBy).toBe("alice"); - expect(f.trackingUrl).toBeNull(); - // A blank ship time defaults to the store's record timestamp. - expect(f.shippedAt).toBe(f.recordedAt); - }); - - test("a non-processing (paid) order → 409 NOT_FULFILLABLE; state untouched", async () => { - const res = await post("ord-paid", { - carrier: "UPS", - trackingNumber: "1Z-1", - recordedBy: "ops", - }); - expect(res.status).toBe(409); - expect((await json(res)).reason).toBe("NOT_FULFILLABLE"); - const reloaded = (await json(await getOrder("ord-paid"))).order as Record; - expect(reloaded.state).toBe("paid"); - expect(reloaded.fulfillment).toBeNull(); - }); - - test("replay is once-only: a second record is recorded:false, fulfillment unchanged", async () => { - const first = await json( - await post("ord-proc", { carrier: "UPS", trackingNumber: "1Z-A", recordedBy: "alice" }), - ); - expect(first.recorded).toBe(true); - const replay = await json( - await post("ord-proc", { carrier: "DHL", trackingNumber: "1Z-B", recordedBy: "bob" }), - ); - expect(replay.recorded).toBe(false); - // The first fulfillment stands — the loser never overwrote it. - const f = (replay.order as Record).fulfillment as Record; - expect(f.carrier).toBe("UPS"); - expect(f.trackingNumber).toBe("1Z-A"); - }); - - test("validation: blank carrier / tracking number / recorder → 400", async () => { - expect( - (await post("ord-proc", { carrier: "", trackingNumber: "x", recordedBy: "y" })).status, - ).toBe(400); - expect( - (await post("ord-proc", { carrier: "x", trackingNumber: " ", recordedBy: "y" })).status, - ).toBe(400); - expect( - (await post("ord-proc", { carrier: "x", trackingNumber: "y", recordedBy: " " })).status, - ).toBe(400); - }); - - test("validation: a non-http(s) trackingUrl (javascript:/data:/relative) → 400, never stored", async () => { - for (const url of ["javascript:alert(1)", "data:text/html,x", "ftp://x", "not-a-url"]) { - const res = await post("ord-proc", { - carrier: "UPS", - trackingNumber: "1Z-1", - trackingUrl: url, - recordedBy: "ops", - }); - expect(res.status).toBe(400); - } - // None of the rejected attempts shipped the order or stored a URL. - const reloaded = (await json(await getOrder("ord-proc"))).order as Record; - expect(reloaded.state).toBe("processing"); - expect(reloaded.fulfillment).toBeNull(); - }); - - test("unknown order → 404", async () => { - const res = await post("does-not-exist", { - carrier: "UPS", - trackingNumber: "x", - recordedBy: "y", - }); - expect(res.status).toBe(404); - expect((await json(res)).reason).toBe("ORDER_NOT_FOUND"); - }); - - test("guard: no internal token ⇒ 401", async () => { - const res = await post( - "ord-proc", - { carrier: "UPS", trackingNumber: "x", recordedBy: "y" }, - { token: null }, - ); - expect(res.status).toBe(401); - }); - - test("write gate: with a service token set, POST needs X-Service-Token (401 without, 200 with)", async () => { - const gated = await startTestServer({ serviceToken: "svc-secret" }); - try { - const gatedToken = gated.internalToken as string; - await gated.seedOrder({ - id: "ord-g", - state: "processing", - currency: "USD", - buyerRef: "g@example.com", - createdAt: "2026-07-10T00:00:00.000Z", - totalCents: 500, - }); - const common = { "content-type": "application/json", "X-Internal-Token": gatedToken }; - const path = `${gated.baseUrl}/admin/orders/ord-g/fulfillment`; - const payload = JSON.stringify({ carrier: "UPS", trackingNumber: "1Z", recordedBy: "ops" }); - const blocked = await fetch(path, { method: "POST", headers: common, body: payload }); - expect(blocked.status).toBe(401); - const ok = await fetch(path, { - method: "POST", - headers: { ...common, "X-Service-Token": "svc-secret" }, - body: payload, - }); - expect(ok.status).toBe(200); - expect((await json(ok)).recorded).toBe(true); - } finally { - await gated.stop(); - } - }); -}); diff --git a/packages/service/test/admin-order-notes-http.test.ts b/packages/service/test/admin-order-notes-http.test.ts deleted file mode 100644 index 9e487efc..00000000 --- a/packages/service/test/admin-order-notes-http.test.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test } from "vitest"; -import { startTestServer, type TestServer } from "./helpers/start-test-server.js"; - -// Admin order-notes HTTP contract (admin-UX Increment 0): wire ⇄ port fidelity -// for POST/GET /admin/orders/:id/notes against a LIVE server backed by Postgres. -// Append-only; server clock is advanced between appends so created_at ordering is -// exercised (not just the id tie-break). Guards: internal-token (both verbs), -// the X-Service-Token write gate (POST only), validation (empty body → 400), -// unknown order (→ 404), and idempotent replay via Idempotency-Key. - -const PG = process.env.PG_CONNECTION_STRING; - -async function json(res: Response): Promise> { - return (await res.json()) as Record; -} - -describe.skipIf(PG === undefined)("admin order notes HTTP contract", () => { - let server: TestServer; - let token: string; - - beforeEach(async () => { - server = await startTestServer(); - token = server.internalToken as string; - await server.seedOrder({ - id: "ord-1", - state: "paid", - currency: "USD", - buyerRef: "alice@example.com", - createdAt: "2026-07-10T00:00:00.000Z", - totalCents: 1000, - }); - }); - afterEach(async () => { - await server.stop(); - }); - - function getNotes(orderId: string, opts: { token?: string } = { token }): Promise { - const headers: Record = {}; - if (opts.token !== undefined) headers["X-Internal-Token"] = opts.token; - return fetch(`${server.baseUrl}/admin/orders/${orderId}/notes`, { headers }); - } - - function postNote( - orderId: string, - body: { author?: string; body?: string }, - opts: { token?: string | null; idempotencyKey?: string; serviceToken?: string } = {}, - ): Promise { - const headers: Record = { "content-type": "application/json" }; - const tk = opts.token === undefined ? token : opts.token; - if (tk !== null) headers["X-Internal-Token"] = tk; - if (opts.idempotencyKey !== undefined) headers["Idempotency-Key"] = opts.idempotencyKey; - if (opts.serviceToken !== undefined) headers["X-Service-Token"] = opts.serviceToken; - return fetch(`${server.baseUrl}/admin/orders/${orderId}/notes`, { - method: "POST", - headers, - body: JSON.stringify(body), - }); - } - - test("POST appends a note (201, appended:true) and GET lists it", async () => { - const res = await postNote("ord-1", { author: "alice", body: "gift wrap please" }); - expect(res.status).toBe(201); - const posted = await json(res); - expect(posted.appended).toBe(true); - const note = posted.note as Record; - expect(note).toMatchObject({ orderId: "ord-1", author: "alice", body: "gift wrap please" }); - expect(typeof note.id).toBe("string"); - expect(note.createdAt).toBe("2026-07-10T00:00:00.000Z"); - - const listed = await json(await getNotes("ord-1")); - expect(listed.ok).toBe(true); - const notes = listed.notes as Array>; - expect(notes.map((n) => n.body)).toEqual(["gift wrap please"]); - }); - - test("GET lists notes in append order (chronological, server clock advanced between appends)", async () => { - await postNote("ord-1", { author: "a", body: "first" }); - server.advance(1000); - await postNote("ord-1", { author: "b", body: "second" }); - server.advance(1000); - await postNote("ord-1", { author: "c", body: "third" }); - const notes = (await json(await getNotes("ord-1"))).notes as Array>; - expect(notes.map((n) => n.body)).toEqual(["first", "second", "third"]); - }); - - test("trims author + body server-side (domain validation)", async () => { - const posted = await json( - await postNote("ord-1", { author: " bob ", body: " call back " }), - ); - expect((posted.note as Record).author).toBe("bob"); - expect((posted.note as Record).body).toBe("call back"); - }); - - test("replay with the same Idempotency-Key appends once (appended:false, list stays length 1)", async () => { - const first = await json( - await postNote("ord-1", { author: "alice", body: "once" }, { idempotencyKey: "note-key-1" }), - ); - expect(first.appended).toBe(true); - const replay = await json( - await postNote( - "ord-1", - { author: "alice", body: "a different body ignored" }, - { idempotencyKey: "note-key-1" }, - ), - ); - expect(replay.appended).toBe(false); - expect((replay.note as Record).id).toBe( - (first.note as Record).id, - ); - const notes = (await json(await getNotes("ord-1"))).notes as unknown[]; - expect(notes).toHaveLength(1); - }); - - test("empty body → 400 (domain rejects a blank note)", async () => { - const res = await postNote("ord-1", { author: "alice", body: " " }); - expect(res.status).toBe(400); - }); - - test("note on an unknown order → 404", async () => { - const res = await postNote("does-not-exist", { author: "alice", body: "hi" }); - expect(res.status).toBe(404); - expect((await json(res)).reason).toBe("ORDER_NOT_FOUND"); - }); - - test("guard: no internal token ⇒ 401 on both GET and POST", async () => { - expect((await getNotes("ord-1", {})).status).toBe(401); - expect((await postNote("ord-1", { author: "a", body: "b" }, { token: null })).status).toBe(401); - }); - - test("write gate: with a service token set, POST needs X-Service-Token (401 without, 201 with)", async () => { - const gated = await startTestServer({ serviceToken: "svc-secret" }); - try { - const gatedToken = gated.internalToken as string; - await gated.seedOrder({ - id: "ord-g", - state: "paid", - currency: "USD", - buyerRef: "g@example.com", - createdAt: "2026-07-10T00:00:00.000Z", - totalCents: 500, - }); - const common = { "content-type": "application/json", "X-Internal-Token": gatedToken }; - // Missing X-Service-Token ⇒ blocked by the write gate. - const blocked = await fetch(`${gated.baseUrl}/admin/orders/ord-g/notes`, { - method: "POST", - headers: common, - body: JSON.stringify({ author: "a", body: "b" }), - }); - expect(blocked.status).toBe(401); - // With the service token ⇒ appends. - const ok = await fetch(`${gated.baseUrl}/admin/orders/ord-g/notes`, { - method: "POST", - headers: { ...common, "X-Service-Token": "svc-secret" }, - body: JSON.stringify({ author: "a", body: "b" }), - }); - expect(ok.status).toBe(201); - // GET is a read — gate-exempt, so it works with only the internal token. - const listed = await fetch(`${gated.baseUrl}/admin/orders/ord-g/notes`, { - headers: { "X-Internal-Token": gatedToken }, - }); - expect(listed.status).toBe(200); - } finally { - await gated.stop(); - } - }); -}); diff --git a/packages/service/test/admin-orders-http.test.ts b/packages/service/test/admin-orders-http.test.ts deleted file mode 100644 index dc3f6084..00000000 --- a/packages/service/test/admin-orders-http.test.ts +++ /dev/null @@ -1,601 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test } from "vitest"; -import { startTestServer, type TestServer } from "./helpers/start-test-server.js"; - -// Admin Orders console (view-only): wire ⇄ port fidelity for GET /admin/orders -// (list, keyset cursor round-trip preserving the filter) and GET -// /admin/orders/:id (detail + allowedTransitions + 404), against a LIVE server -// backed by Postgres. Guards: no token ⇒ 401, no configured token ⇒ 503. -// Cursor fail-closed (MOD-1): a garbage/tampered cursor ⇒ 400; a decoded -// out-of-range limit is clamped, not honored. - -const PG = process.env.PG_CONNECTION_STRING; - -async function json(res: Response): Promise> { - return (await res.json()) as Record; -} - -/** Encode an opaque cursor the way the route does (base64url of the JSON) so a - * test can craft a tampered/out-of-range token. */ -function b64url(payload: unknown): string { - return Buffer.from(JSON.stringify(payload), "utf8").toString("base64url"); -} - -describe.skipIf(PG === undefined)("admin Orders console HTTP contract", () => { - let server: TestServer; - let token: string; - - beforeEach(async () => { - server = await startTestServer(); - token = server.internalToken as string; - }); - afterEach(async () => { - await server.stop(); - }); - - function get(path: string, opts: { token?: string } = { token }): Promise { - const headers: Record = {}; - if (opts.token !== undefined) headers["X-Internal-Token"] = opts.token; - return fetch(`${server.baseUrl}/admin${path}`, { headers }); - } - - async function seed(): Promise { - // Three paid USD orders across three days + a cancelled distractor inside the - // same window (to prove filters survive paging). - await server.seedOrder({ - id: "ord-1", - state: "paid", - currency: "USD", - buyerRef: "Alice@Example.com", - paymentMethod: "stripe", - customerId: "cust-a", - createdAt: "2026-07-10T01:00:00.000Z", - totalCents: 1000, - }); - await server.seedOrder({ - id: "ord-2", - state: "paid", - currency: "USD", - buyerRef: "bob@example.com", - createdAt: "2026-07-11T01:00:00.000Z", - totalCents: 2000, - }); - await server.seedOrder({ - id: "ord-3", - state: "paid", - currency: "USD", - buyerRef: "carol@example.com", - createdAt: "2026-07-12T01:00:00.000Z", - totalCents: 3000, - }); - await server.seedOrder({ - id: "ord-cancel", - state: "cancelled", - currency: "USD", - buyerRef: "dave@example.com", - createdAt: "2026-07-11T12:00:00.000Z", - totalCents: 9999, - reconciliationFlag: "manual review", - }); - } - - test("GET /admin/orders lists newest-first with the summary projection (integer cents)", async () => { - await seed(); - const body = await json(await get("/orders")); - expect(body.ok).toBe(true); - const orders = body.orders as Array>; - // Newest-first across all four. - expect(orders.map((o) => o.id)).toEqual(["ord-3", "ord-cancel", "ord-2", "ord-1"]); - const first = orders[0]!; - expect(first).toMatchObject({ - id: "ord-3", - state: "paid", - currency: "USD", - buyerRef: "carol@example.com", - totalCents: 3000, - reconciliationFlag: false, - createdAt: "2026-07-12T01:00:00.000Z", - }); - // The reconciliation badge is a boolean on the distractor. - const cancel = orders.find((o) => o.id === "ord-cancel")!; - expect(cancel.reconciliationFlag).toBe(true); - expect(cancel.state).toBe("cancelled"); - expect(body.nextCursor).toBeNull(); - }); - - test("state + date-window + search filters compose ([from,to) half-open)", async () => { - await seed(); - // Half-open: to = 2026-07-12T01:00:00Z EXCLUDES ord-3 (created exactly at to). - const body = await json( - await get("/orders?states=paid&from=2026-07-10T00:00:00.000Z&to=2026-07-12T01:00:00.000Z"), - ); - const orders = body.orders as Array>; - expect(orders.map((o) => o.id)).toEqual(["ord-2", "ord-1"]); // ord-3 excluded, cancel excluded - - // Search by whole order id (a whole id is its own prefix). - const byId = (await json(await get("/orders?search=ord-2"))).orders as Array< - Record - >; - expect(byId.map((o) => o.id)).toEqual(["ord-2"]); - - // Search by whole buyer_ref, case-insensitive. - const byRef = (await json(await get("/orders?search=ALICE@example.com"))).orders as Array< - Record - >; - expect(byRef.map((o) => o.id)).toEqual(["ord-1"]); - }); - - test("search passes the port's id-PREFIX / email-SUBSTRING semantics through the wire", async () => { - await seed(); - // An id PREFIX — what the console renders (the short id) and therefore what - // an operator types back. All four seeded ids share it, newest-first. - const prefix = await json(await get("/orders?search=ord-")); - expect((prefix.orders as Array<{ id: string }>).map((o) => o.id)).toEqual([ - "ord-3", - "ord-cancel", - "ord-2", - "ord-1", - ]); - // `total` is counted under the SAME predicate as the rows. - expect(prefix.total).toBe(4); - - // A MID-STRING fragment of the buyer email — unanchored, unlike the id half. - const infix = await json(await get("/orders?search=arol@")); - expect((infix.orders as Array<{ id: string }>).map((o) => o.id)).toEqual(["ord-3"]); - expect(infix.total).toBe(1); - - // A mid-string fragment of an ID is NOT a match: the id half is anchored. - const midId = await json(await get("/orders?search=cancel")); - expect(midId.orders).toEqual([]); - expect(midId.total).toBe(0); - - // A LIKE metacharacter is a character to search for, never a wildcard — - // unescaped, `%` would match every row here. - const wildcard = await json(await get("/orders?search=%25")); - expect(wildcard.orders).toEqual([]); - expect(wildcard.total).toBe(0); - }); - - /** Check an order out through the real cart → checkout path, so it carries - * REAL purchase-time line snapshots (`seedOrder` writes a bare order + totals - * row with no lines, and the sku half of `search` reads the lines). */ - async function checkoutOrder(input: { - key: string; - buyerRef: string; - items: ReadonlyArray<{ productId: string; sku: string }>; - }): Promise { - for (const item of input.items) { - await server.seedProduct({ - productId: item.productId, - sku: item.sku, - priceCents: 500, - title: "Item", - kind: "physical", - onHand: 5, - }); - } - const cart = await json( - await fetch(`${server.baseUrl}/carts`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ currency: "USD" }), - }), - ); - const cartId = cart["cartId"] as string; - for (const item of input.items) { - const addRes = await fetch(`${server.baseUrl}/carts/${cartId}/lines`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "Idempotency-Key": `add-${input.key}-${item.sku}`, - }, - body: JSON.stringify({ sku: item.sku, qty: 1, productId: item.productId }), - }); - expect(addRes.status).toBe(200); - } - const coRes = await fetch(`${server.baseUrl}/checkout/orders`, { - method: "POST", - headers: { "Content-Type": "application/json", "Idempotency-Key": `co-${input.key}` }, - body: JSON.stringify({ cartId, paymentMethod: "stripe", buyerRef: input.buyerRef }), - }); - expect(coRes.status).toBe(201); - const order = (await json(coRes))["order"] as Record; - return order["id"] as string; - } - - test("search passes the port's purchase-time SKU semantics through the wire", async () => { - await seed(); // lineless distractors, none of which any sku may drag in - const twoLine = await checkoutOrder({ - key: "sku-two", - buyerRef: "erin@example.com", - items: [ - { productId: "p-alpha", sku: "SKU-ALPHA" }, - { productId: "p-beta", sku: "SKU-BETA" }, - ], - }); - const otherLine = await checkoutOrder({ - key: "sku-one", - buyerRef: "frank@example.com", - items: [{ productId: "p-gamma", sku: "SKU-GAMMA" }], - }); - - // The sku frozen on a line finds the order that bought it, folded… - const alpha = await json(await get("/orders?search=SKU-ALPHA")); - expect((alpha.orders as Array<{ id: string }>).map((o) => o.id)).toEqual([twoLine]); - expect(alpha.total).toBe(1); - const folded = await json(await get("/orders?search=sku-alpha")); - expect((folded.orders as Array<{ id: string }>).map((o) => o.id)).toEqual([twoLine]); - - // …and a two-line order is ONE row, whichever of its lines matched. - const beta = await json(await get("/orders?search=SKU-BETA")); - expect((beta.orders as Array<{ id: string }>).map((o) => o.id)).toEqual([twoLine]); - expect(beta.total).toBe(1); - const gamma = await json(await get("/orders?search=SKU-GAMMA")); - expect((gamma.orders as Array<{ id: string }>).map((o) => o.id)).toEqual([otherLine]); - - // EXACT on the wire too: neither a prefix nor a fragment of a sku matches - // (both would hit here — `SKU-` leads all three). - const prefix = await json(await get("/orders?search=SKU-")); - expect(prefix.orders).toEqual([]); - expect(prefix.total).toBe(0); - const fragment = await json(await get("/orders?search=ALPHA")); - expect(fragment.orders).toEqual([]); - - // The LIVE CATALOGUE is not what is searched: a product nobody ordered - // matches no order, however real its sku is. - await server.seedProductRow({ - id: "p-unsold", - sku: "SKU-UNSOLD", - title: "Unsold", - priceCents: 900, - createdAt: "2026-07-10T00:00:00.000Z", - }); - const unsold = await json(await get("/orders?search=SKU-UNSOLD")); - expect(unsold.orders).toEqual([]); - expect(unsold.total).toBe(0); - }); - - test("the cursor gate compares the search STRING, not its semantics", async () => { - await seed(); - // A search that now matches four rows still mints a cursor whose filter is - // the raw string. Paging it with the SAME string agrees; the widened - // semantics change nothing about the canonical form on the wire. - const page1 = await json(await get("/orders?search=ord-&limit=2")); - expect((page1.orders as Array<{ id: string }>).map((o) => o.id)).toEqual([ - "ord-3", - "ord-cancel", - ]); - const cursor = encodeURIComponent(page1.nextCursor as string); - const aloneRes = await get(`/orders?cursor=${cursor}`); - const agreeRes = await get(`/orders?cursor=${cursor}&search=ord-`); - expect(aloneRes.status).toBe(200); - expect(agreeRes.status).toBe(200); - expect(await agreeRes.text()).toBe(await aloneRes.text()); - // A DIFFERENT string is a different filter, even though this one selects a - // superset of the same rows — the gate compares spellings, not result sets. - expect((await get(`/orders?cursor=${cursor}&search=ord`)).status).toBe(400); - // Case is NOT folded by the canonicalizer (the store's case-insensitivity is - // the store's business): a differently-cased spelling still disagrees. - expect((await get(`/orders?cursor=${cursor}&search=ORD-`)).status).toBe(400); - }); - - test("a SKU search pages like any other — the gate still compares the raw string", async () => { - // Two orders of the same item: the sku half has to compose with the keyset - // WHERE across a page boundary, and its spelling has to survive the cursor - // the same way the other two halves do. - const first = await checkoutOrder({ - key: "sku-page-1", - buyerRef: "gia@example.com", - items: [{ productId: "p-paged", sku: "SKU-PAGED" }], - }); - const second = await checkoutOrder({ - key: "sku-page-2", - buyerRef: "hal@example.com", - items: [{ productId: "p-paged", sku: "SKU-PAGED" }], - }); - const page1 = await json(await get("/orders?search=SKU-PAGED&limit=1")); - expect((page1.orders as unknown[]).length).toBe(1); - expect(page1.total).toBe(2); // the SET, counted under the same predicate - const cursor = encodeURIComponent(page1.nextCursor as string); - const page2 = await json(await get(`/orders?cursor=${cursor}&search=SKU-PAGED`)); - expect((page2.orders as unknown[]).length).toBe(1); - expect(page2.total).toBe(2); - expect(page2.nextCursor).toBeNull(); - // Union is both orders, once each — no overlap, no gap, no duplicate row. - const paged = [ - ...(page1.orders as Array<{ id: string }>), - ...(page2.orders as Array<{ id: string }>), - ].map((o) => o.id); - expect(paged.toSorted()).toEqual([first, second].toSorted()); - // A different spelling of the same search is still a different filter. - expect((await get(`/orders?cursor=${cursor}&search=SKU-PAGE`)).status).toBe(400); - }); - - test("keyset cursor round-trips and preserves the filter across pages (no overlap/gap)", async () => { - await seed(); - const page1 = await json(await get("/orders?states=paid&limit=2")); - const p1 = page1.orders as Array>; - expect(p1.map((o) => o.id)).toEqual(["ord-3", "ord-2"]); // newest paid first, cancel excluded - expect(typeof page1.nextCursor).toBe("string"); - - const page2 = await json( - await get(`/orders?cursor=${encodeURIComponent(page1.nextCursor as string)}`), - ); - const p2 = page2.orders as Array>; - // The filter (states=paid) SURVIVES the cursor: the cancelled distractor is - // never surfaced, and the remainder is exactly ord-1. - expect(p2.map((o) => o.id)).toEqual(["ord-1"]); - expect(page2.nextCursor).toBeNull(); - // Union is the full paid set newest-first, no dup. - expect([...p1, ...p2].map((o) => o.id)).toEqual(["ord-3", "ord-2", "ord-1"]); - }); - - // -- total: the exact size of the filtered set (INC-23) -------------------- - - test("GET /admin/orders carries `total` — the whole FILTERED set, identical on every page", async () => { - await seed(); - const page1 = await json(await get("/orders?states=paid&limit=2")); - // 3 paid orders behind a 2-row page: the count is of the SET, not the page, - // which is precisely what a keyset cursor cannot tell a console on its own. - expect(page1.total).toBe(3); - expect((page1.orders as unknown[]).length).toBe(2); - const page2 = await json( - await get(`/orders?cursor=${encodeURIComponent(page1.nextCursor as string)}`), - ); - // Page 2 carries the SAME total — the filter rode the cursor, and so did - // the predicate the count is taken under. - expect(page2.total).toBe(3); - }); - - test("GET /admin/orders `total` counts under the SAME filter as the rows, and is 0 (present) when nothing matches", async () => { - await seed(); - const unfiltered = await json(await get("/orders")); - expect(unfiltered.total).toBe(4); // every seeded order, cancelled included - const cancelled = await json(await get("/orders?states=cancelled")); - expect(cancelled.total).toBe(1); - const none = await json(await get("/orders?search=nobody@example.com")); - expect(none.orders).toEqual([]); - // Zero is REPORTED, not omitted: the key's presence is what tells a client - // "this service counts", and its absence is what means "it cannot". - expect(none.total).toBe(0); - expect(Object.hasOwn(none, "total")).toBe(true); - }); - - test("GET /admin/orders/:id returns the full order + createdAt/customerId + allowedTransitions", async () => { - await seed(); - const body = await json(await get("/orders/ord-1")); - expect(body.ok).toBe(true); - const order = body.order as Record; - expect(order.id).toBe("ord-1"); - expect(order.createdAt).toBe("2026-07-10T01:00:00.000Z"); - expect(order.customerId).toBe("cust-a"); - // allowedTransitions is the domain state machine for `paid`. - expect(body.allowedTransitions).toEqual(["processing", "completed", "cancelled", "refunded"]); - }); - - test("GET /admin/orders/:id 404s for an unknown order", async () => { - const res = await get("/orders/does-not-exist"); - expect(res.status).toBe(404); - expect((await json(res)).reason).toBe("ORDER_NOT_FOUND"); - }); - - test("guard: no token ⇒ 401 on both list and detail", async () => { - expect((await get("/orders", {})).status).toBe(401); - expect((await get("/orders/ord-1", {})).status).toBe(401); - }); - - test("guard: a server with no configured internal token ⇒ 503 (disabled, not open)", async () => { - const disabled = await startTestServer({ internalToken: null }); - try { - const res = await fetch(`${disabled.baseUrl}/admin/orders`); - expect(res.status).toBe(503); - } finally { - await disabled.stop(); - } - }); - - test("MOD-1: a garbage/tampered cursor fails closed with 400 (never 500)", async () => { - // Non-base64 garbage. - expect((await get("/orders?cursor=%21%21%21not-base64%21%21%21")).status).toBe(400); - // Well-formed base64url but not JSON. - const notJson = Buffer.from("this is not json", "utf8").toString("base64url"); - expect((await get(`/orders?cursor=${notJson}`)).status).toBe(400); - // Structurally valid but the embedded filter is invalid (unknown state) — - // re-validated through zod ⇒ 400. - const badFilter = b64url({ - pos: { createdAt: "2026-07-12T01:00:00.000Z", id: "ord-3" }, - filter: { states: ["bogus-state"] }, - limit: 25, - }); - expect((await get(`/orders?cursor=${badFilter}`)).status).toBe(400); - // A cursor whose pos.createdAt is not a valid ISO datetime ⇒ 400. - const badCreatedAt = b64url({ - pos: { createdAt: "not-a-timestamp", id: "ord-3" }, - filter: {}, - limit: 25, - }); - expect((await get(`/orders?cursor=${badCreatedAt}`)).status).toBe(400); - }); - - test("MOD-1: a decoded out-of-range limit is clamped, not honored (no 400/500)", async () => { - await seed(); - // A hand-crafted cursor positioned before everything, with an absurd limit. - const cursor = b64url({ - pos: { createdAt: "2999-01-01T00:00:00.000Z", id: "zzzz" }, - filter: {}, - limit: 999_999, - }); - const res = await get(`/orders?cursor=${cursor}`); - expect(res.status).toBe(200); // clamped to the max, request still succeeds - const orders = (await json(res)).orders as Array>; - expect(orders.map((o) => o.id)).toEqual(["ord-3", "ord-cancel", "ord-2", "ord-1"]); - }); - - // -- a cursor that disagrees with the query's filters fails CLOSED ---------- - // - // The token is authoritative for paging AND carries the filter it was minted - // under, so a request that ALSO spells that filter out in the query string can - // contradict it. Resolving the contradiction in the token's favour is silent - // divergence: the address claims one predicate while the rows answer another, - // and nothing in the response says so. PRESENT filter params must therefore - // canonicalize to exactly the token's filter; ABSENT ones claim nothing (the - // cursor-alone request every client sends today must keep working). - // - // The four quadrants are pinned below: cursor alone, cursor + agreeing params, - // cursor + disagreeing params, params alone. - - test("quadrant: cursor + AGREEING filter params pages byte-identically to the cursor ALONE", async () => { - await seed(); - const page1 = await json(await get("/orders?states=paid&limit=2")); - const cursor = encodeURIComponent(page1.nextCursor as string); - - const aloneRes = await get(`/orders?cursor=${cursor}`); - const alone = await aloneRes.text(); - const agreeRes = await get(`/orders?cursor=${cursor}&states=paid`); - expect(aloneRes.status).toBe(200); - expect(agreeRes.status).toBe(200); - // BYTE-identical — same rows, same total, same nextCursor. The agreeing - // params are redundant, not a second opinion. - expect(await agreeRes.text()).toBe(alone); - const parsed = JSON.parse(alone) as { orders: Array<{ id: string }>; total: number }; - expect(parsed.orders.map((o) => o.id)).toEqual(["ord-1"]); - expect(parsed.total).toBe(3); - }); - - test("quadrant: cursor + DISAGREEING filter params ⇒ 400, never a silently divergent page", async () => { - await seed(); - // An UNFILTERED first page mints an UNFILTERED token. Paging it with - // `states=paid` beside it used to answer 200 with the unfiltered set — - // four orders under an address that claims only the paid ones. - const unfiltered = await json(await get("/orders?limit=1")); - const unfilteredCursor = encodeURIComponent(unfiltered.nextCursor as string); - const res = await get(`/orders?cursor=${unfilteredCursor}&states=paid`); - expect(res.status).toBe(400); - expect(await json(res)).toEqual({ error: "cursor filter mismatch" }); - - // And the mirror: a FILTERED token under a different states value. - const paid = await json(await get("/orders?states=paid&limit=1")); - const paidCursor = encodeURIComponent(paid.nextCursor as string); - expect((await get(`/orders?cursor=${paidCursor}&states=cancelled`)).status).toBe(400); - // Every filter axis participates, not just `states` — BOTH window bounds - // included. - expect((await get(`/orders?cursor=${paidCursor}&states=paid&search=ord-2`)).status).toBe(400); - expect( - (await get(`/orders?cursor=${paidCursor}&states=paid&from=2026-07-01T00:00:00.000Z`)).status, - ).toBe(400); - expect( - (await get(`/orders?cursor=${paidCursor}&states=paid&to=2026-08-01T00:00:00.000Z`)).status, - ).toBe(400); - }); - - test("an unparseable `states` beside a cursor is the invalid-FILTER 400, not the mismatch one", async () => { - await seed(); - const page1 = await json(await get("/orders?states=paid&limit=2")); - const cursor = encodeURIComponent(page1.nextCursor as string); - // Newly REACHABLE: the cursor arm used to ignore the query's states - // outright, so an unknown token beside a cursor answered 200. It now gets - // the answer the no-cursor arm has always given — and it is the - // invalid-filter 400, not the mismatch one, because the request is - // unanswerable before there is anything to compare. - const res = await get(`/orders?cursor=${cursor}&states=bogus-state`); - expect(res.status).toBe(400); - expect(await json(res)).toEqual({ error: "invalid states filter" }); - // The same value with no cursor is the same 400 — one rule, both arms. - expect(await json(await get("/orders?states=bogus-state"))).toEqual({ - error: "invalid states filter", - }); - }); - - test("quadrant: filter params ALONE (no cursor) are untouched by the gate", async () => { - await seed(); - const body = await json(await get("/orders?states=paid")); - expect((body.orders as Array>).map((o) => o.id)).toEqual([ - "ord-3", - "ord-2", - "ord-1", - ]); - expect(body.total).toBe(3); - }); - - test("a filter axis the query OMITS is still a disagreement when the token carries it", async () => { - await seed(); - // The token carries states + a window; the address claims only the states. - // A subset is not agreement — the rows are narrower than the address says. - const page1 = await json( - await get("/orders?states=paid&from=2026-07-10T00:00:00.000Z&limit=2"), - ); - const cursor = encodeURIComponent(page1.nextCursor as string); - expect((await get(`/orders?cursor=${cursor}&states=paid`)).status).toBe(400); - // Spelling BOTH axes out agrees, and pages. - expect( - (await get(`/orders?cursor=${cursor}&states=paid&from=2026-07-10T00:00:00.000Z`)).status, - ).toBe(200); - }); - - test("canonicalization: state ORDER, duplicates and datetime SPELLING are not disagreements", async () => { - await seed(); - const page1 = await json(await get("/orders?states=paid,cancelled&limit=2")); - const cursor = encodeURIComponent(page1.nextCursor as string); - const alone = await (await get(`/orders?cursor=${cursor}`)).text(); - // Same SET of states, written in the other order — and with a duplicate. - expect(await (await get(`/orders?cursor=${cursor}&states=cancelled,paid`)).text()).toBe(alone); - expect(await (await get(`/orders?cursor=${cursor}&states=paid,paid,cancelled`)).text()).toBe( - alone, - ); - - // A window bound is an INSTANT, not a string: the same moment spelled with - // and without the fractional part is the same filter. - const windowed = await json( - await get("/orders?states=paid&from=2026-07-10T00:00:00.000Z&limit=2"), - ); - const windowedCursor = encodeURIComponent(windowed.nextCursor as string); - const windowedAlone = await (await get(`/orders?cursor=${windowedCursor}`)).text(); - expect( - await ( - await get(`/orders?cursor=${windowedCursor}&states=paid&from=2026-07-10T00:00:00Z`) - ).text(), - ).toBe(windowedAlone); - }); - - test("a `limit` that disagrees with the token's embedded limit ⇒ 400; an agreeing one pages", async () => { - await seed(); - const page1 = await json(await get("/orders?states=paid&limit=2")); - const cursor = encodeURIComponent(page1.nextCursor as string); - const alone = await (await get(`/orders?cursor=${cursor}`)).text(); - - // The shape live clients send today: cursor + the same page limit. - const agree = await get(`/orders?cursor=${cursor}&limit=2`); - expect(agree.status).toBe(200); - expect(await agree.text()).toBe(alone); - - const disagree = await get(`/orders?cursor=${cursor}&limit=5`); - expect(disagree.status).toBe(400); - expect(await json(disagree)).toEqual({ error: "cursor filter mismatch" }); - }); - - test("the limit gate compares the EFFECTIVE page size, which is the token's whenever it is usable", async () => { - await seed(); - // A FINITE but out-of-range token limit is clamped and HONORED (MOD-1) — - // the query's own value is never consulted — so a page of 100 beside a - // request asking for 50 is a real disagreement, not a spurious one. - const clamped = b64url({ - pos: { createdAt: "2999-01-01T00:00:00.000Z", id: "zzzz" }, - filter: {}, - limit: 999_999, - }); - expect((await get(`/orders?cursor=${clamped}&limit=50`)).status).toBe(400); - // The same token ALONE still pages, clamped, exactly as it did before. - expect((await get(`/orders?cursor=${clamped}`)).status).toBe(200); - - // A token limit that is not a finite number is UNUSABLE, and only then is - // the query's value the one honored — so it agrees with itself rather than - // 400ing, and the page it describes is the page it gets. - const unusable = b64url({ - pos: { createdAt: "2999-01-01T00:00:00.000Z", id: "zzzz" }, - filter: {}, - limit: "not-a-number", - }); - const res = await get(`/orders?cursor=${unusable}&limit=3`); - expect(res.status).toBe(200); - expect((await json(res)).orders as unknown[]).toHaveLength(3); - }); -}); diff --git a/packages/service/test/admin-product-edit-http.test.ts b/packages/service/test/admin-product-edit-http.test.ts deleted file mode 100644 index 5ae69e77..00000000 --- a/packages/service/test/admin-product-edit-http.test.ts +++ /dev/null @@ -1,425 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test } from "vitest"; -import { startTestServer, type TestServer } from "./helpers/start-test-server.js"; - -// Standalone product EDIT (admin-UX Increment 2 slice 2): wire ⇄ port fidelity -// for PATCH /admin/products/:id against a LIVE server backed by Postgres. Pins -// the guarded commerce edit end-to-end — the optimistic compare-and-set on -// updatedAt (stale ⇒ 409, never a silent clobber), currency integrity, the -// price > 0 boundary, SKU uniqueness, not_found, and the snapshot-safe scope -// (active is never touched). Mirrors admin-products-http.test.ts's shape. - -const PG = process.env.PG_CONNECTION_STRING; - -async function json(res: Response): Promise> { - return (await res.json()) as Record; -} - -describe.skipIf(PG === undefined)("admin product EDIT HTTP contract", () => { - let server: TestServer; - let token: string; - - beforeEach(async () => { - server = await startTestServer(); - token = server.internalToken as string; - }); - afterEach(async () => { - await server.stop(); - }); - - function get(id: string): Promise { - return fetch(`${server.baseUrl}/admin/products/${id}`, { - headers: { "X-Internal-Token": token }, - }); - } - - function patch( - id: string, - body: unknown, - opts: { token?: string | null; idempotencyKey?: string } = {}, - ): Promise { - const headers: Record = { "Content-Type": "application/json" }; - const tok = opts.token === undefined ? token : opts.token; - if (tok !== null) headers["X-Internal-Token"] = tok; - if (opts.idempotencyKey !== undefined) headers["Idempotency-Key"] = opts.idempotencyKey; - return fetch(`${server.baseUrl}/admin/products/${id}`, { - method: "PATCH", - headers, - body: JSON.stringify(body), - }); - } - - async function seedAndReadWatermark(id = "prod-1"): Promise { - await server.seedProductRow({ - id, - sku: `SKU-${id}`, - title: "Original", - priceCents: 1000, - currency: "USD", - productKind: "physical", - active: true, - createdAt: "2026-07-10T01:00:00.000Z", - }); - const detail = (await json(await get(id))).product as Record; - return detail.updatedAt as string; - } - - test("applies a price edit under a matching expectedUpdatedAt (200), never touching the publish gate", async () => { - const watermark = await seedAndReadWatermark(); - const res = await patch("prod-1", { - expectedUpdatedAt: watermark, - price: { amount: 2599, currency: "USD" }, - taxClass: "reduced", - }); - expect(res.status).toBe(200); - expect((await json(res)).ok).toBe(true); - - const after = (await json(await get("prod-1"))).product as Record; - expect(after.priceCents).toBe(2599); - expect(after.taxClass).toBe("reduced"); - expect(after.active).toBe(true); // the CMS publish gate is untouched. - // The CMS-owned title rode through untouched — this edit has no channel to it. - expect(after.title).toBe("Original"); - }); - - test("a concurrent edit is a 409 STALE_EDIT carrying the current watermark, never a clobber", async () => { - await seedAndReadWatermark(); - const res = await patch("prod-1", { - expectedUpdatedAt: "1999-01-01T00:00:00.000Z", - sku: "SKU-loser", - }); - expect(res.status).toBe(409); - const body = await json(res); - expect(body.reason).toBe("STALE_EDIT"); - expect(typeof body.currentUpdatedAt).toBe("string"); - // The losing write never landed — the lost-update guard keeps its teeth on a - // field the edit CAN write (title moved to the CMS sync, ADR-0013). - expect(((await json(await get("prod-1"))).product as Record).sku).toBe( - "SKU-prod-1", - ); - }); - - test("a same-Idempotency-Key replay dedupes to one applied write (200 both times)", async () => { - const watermark = await seedAndReadWatermark(); - const first = await patch( - "prod-1", - { expectedUpdatedAt: watermark, taxClass: "reduced" }, - { idempotencyKey: "edit-key-1" }, - ); - expect(first.status).toBe(200); - // A retry with the SAME key but the now-stale watermark still succeeds (replay - // precedence over the CAS), rather than a spurious 409. - const replay = await patch( - "prod-1", - { expectedUpdatedAt: watermark, taxClass: "reduced" }, - { idempotencyKey: "edit-key-1" }, - ); - expect(replay.status).toBe(200); - }); - - // -- ADR-0013: title is CMS-owned, and the PATCH says so out loud ----------- - - test("REJECTS a PATCH carrying `title` (400 naming the field) and the stored title is UNCHANGED", async () => { - // Rung 3 of the ADR-0013 enforcement ladder. `editProductCommerceBody` is - // `.strict()` precisely so a stale client's title edit cannot vanish behind a - // 200: zod's default object behaviour STRIPS an unknown key, which is the - // failure mode most likely to be misread as "it saved". - // - // THE STORED-VALUE ASSERTION IS THE POINT. A status-only test passes just as - // well against a stripping schema and therefore proves nothing; only reading - // the title back distinguishes "rejected" from "silently dropped". - const watermark = await seedAndReadWatermark(); - const res = await patch("prod-1", { - expectedUpdatedAt: watermark, - title: "Renamed from a stale client", - }); - expect(res.status).toBe(400); - const body = await json(res); - expect(body.error).toBe("invalid request body"); - // The rejection NAMES the offending field, so the client sees which key is - // unwelcome rather than an opaque "invalid body". - expect(JSON.stringify(body.issues)).toContain("title"); - - const after = (await json(await get("prod-1"))).product as Record; - expect(after.title).toBe("Original"); - }); - - test("a legal edit alongside an illegal `title` is rejected WHOLE — no partial application", async () => { - // The other half of `.strict()`: the price must not land while the title is - // quietly discarded, which is what a stripping schema would do. - const watermark = await seedAndReadWatermark(); - const res = await patch("prod-1", { - expectedUpdatedAt: watermark, - price: { amount: 2599, currency: "USD" }, - title: "Renamed from a stale client", - }); - expect(res.status).toBe(400); - - const after = (await json(await get("prod-1"))).product as Record; - expect(after.priceCents).toBe(1000); // untouched - expect(after.title).toBe("Original"); // untouched - }); - - test("the CMS sync's own channel (PUT /products/:id/commerce) still writes the title", async () => { - // The positive statement of ADR-0013: removing the admin writer must not - // remove the ONE writer that remains. Without this the suite would be happy - // with a title nothing can ever set. - await seedAndReadWatermark(); - const res = await fetch(`${server.baseUrl}/products/prod-1/commerce`, { - method: "PUT", - headers: { "Content-Type": "application/json", "Idempotency-Key": "sync-1" }, - body: JSON.stringify({ title: "Renamed by the CMS" }), - }); - expect(res.status).toBe(200); - - const after = (await json(await get("prod-1"))).product as Record; - expect(after.title).toBe("Renamed by the CMS"); - }); - - test("rejects a silent currency switch (409 CURRENCY_MISMATCH)", async () => { - const watermark = await seedAndReadWatermark(); - const res = await patch("prod-1", { - expectedUpdatedAt: watermark, - price: { amount: 1000, currency: "EUR" }, - }); - expect(res.status).toBe(409); - expect((await json(res)).reason).toBe("CURRENCY_MISMATCH"); - }); - - test("rejects a non-positive price at the boundary (400)", async () => { - const watermark = await seedAndReadWatermark(); - const res = await patch("prod-1", { - expectedUpdatedAt: watermark, - price: { amount: 0, currency: "USD" }, - }); - expect(res.status).toBe(400); - }); - - test("a live-SKU collision is a 409 SKU_TAKEN", async () => { - await server.seedProductRow({ - id: "prod-a", - sku: "SKU-SHARED", - title: "A", - priceCents: 500, - currency: "USD", - createdAt: "2026-07-10T00:00:00.000Z", - }); - const watermark = await seedAndReadWatermark("prod-b"); - const res = await patch("prod-b", { expectedUpdatedAt: watermark, sku: "SKU-SHARED" }); - expect(res.status).toBe(409); - expect((await json(res)).reason).toBe("SKU_TAKEN"); - }); - - // -- the two RENAME refusals, as structured 409s --------------------------- - // A rename carries the sku's on-hand forward, and the domain refuses the two - // states it cannot carry honestly. Both used to reach the console as an opaque - // `internal_error` 500 — a refusal an operator could neither read nor act on, - // on the one screen where the answer is "type a different SKU" or "wait a few - // minutes". Each is now a 409 carrying a machine code plus the operands the - // sentence needs, in the same envelope SKU_TAKEN already uses. - - test("renaming ONTO a sku that already has an inventory row is a 409 SKU_STOCK_CONFLICT naming both skus", async () => { - const watermark = await seedAndReadWatermark(); - await server.seed("SKU-prod-1", 12); - // The target's row belongs to no live product — a sku renamed away from, or - // one whose product was deleted. A LIVE holder would be SKU_TAKEN instead, - // which is a different refusal with different advice. - await server.seed("SKU-RETIRED", 3); - - const res = await patch("prod-1", { expectedUpdatedAt: watermark, sku: "SKU-RETIRED" }); - expect(res.status).toBe(409); - expect(await json(res)).toEqual({ - ok: false, - reason: "SKU_STOCK_CONFLICT", - fromSku: "SKU-prod-1", - toSku: "SKU-RETIRED", - }); - - // NOTHING MOVED. The refusal and the product write are one transaction, so - // the product keeps its sku and both counts stand exactly where they were — - // which is the fact the operator's next decision rests on. - expect(((await json(await get("prod-1"))).product as Record).sku).toBe( - "SKU-prod-1", - ); - expect(await server.onHand("SKU-prod-1")).toBe(12); - expect(await server.onHand("SKU-RETIRED")).toBe(3); - }); - - test("renaming a sku with LIVE HOLDS is a 409 SKU_HELD_STOCK naming the sku and the count", async () => { - const watermark = await seedAndReadWatermark(); - await server.seed("SKU-prod-1", 12); - const reserved = await fetch(`${server.baseUrl}/inventory/reserve`, { - method: "POST", - headers: { "Content-Type": "application/json", "Idempotency-Key": "hold-1" }, - body: JSON.stringify({ sku: "SKU-prod-1", qty: 2 }), - }); - expect(reserved.status).toBe(200); - - const res = await patch("prod-1", { expectedUpdatedAt: watermark, sku: "SKU-NEW" }); - expect(res.status).toBe(409); - expect(await json(res)).toEqual({ - ok: false, - reason: "SKU_HELD_STOCK", - sku: "SKU-prod-1", - liveHolds: 1, - }); - - // The hold's units are still out of on_hand and still name the old sku; - // nothing was renamed and no row was claimed at the target. - expect(((await json(await get("prod-1"))).product as Record).sku).toBe( - "SKU-prod-1", - ); - expect(await server.onHand("SKU-prod-1")).toBe(10); - }); - - test("neither rename refusal leaks anything internal — a code and the operands, nothing else", async () => { - // The refusals carry operator data (skus, a hold count) and MUST NOT carry - // the domain's own message, the class name, a stack, or any hint of the - // tables the check ran against. A 500 would have leaked the lot through the - // generic handler, which is what these two arms exist to prevent. - const watermark = await seedAndReadWatermark(); - await server.seed("SKU-prod-1", 4); - await server.seed("SKU-RETIRED", 0); - // Distinct keys: both refusals run against the SAME unmoved watermark, and - // the route's content-derived fallback key would otherwise be identical for - // the two — a replay, not a second refusal. - const conflict = await patch( - "prod-1", - { expectedUpdatedAt: watermark, sku: "SKU-RETIRED" }, - { idempotencyKey: "rename-conflict" }, - ); - // OCCUPIED IS OCCUPIED: a target row at 0 refuses exactly like a stocked one. - expect(conflict.status).toBe(409); - - await fetch(`${server.baseUrl}/inventory/reserve`, { - method: "POST", - headers: { "Content-Type": "application/json", "Idempotency-Key": "hold-2" }, - body: JSON.stringify({ sku: "SKU-prod-1", qty: 1 }), - }); - const held = await patch( - "prod-1", - { expectedUpdatedAt: watermark, sku: "SKU-NEW" }, - { idempotencyKey: "rename-held" }, - ); - expect(held.status).toBe(409); - - for (const res of [conflict, held]) { - const body = await json(res); - expect(body).not.toHaveProperty("stack"); - expect(body).not.toHaveProperty("message"); - expect(JSON.stringify(body)).not.toMatch( - /constraint|violates|duplicate key|inventory|reservation|SkuStockConflict|SkuHeldStock|\.ts:/i, - ); - } - }); - - test("404s for an unknown product (an edit is not a create)", async () => { - const res = await patch("does-not-exist", { - expectedUpdatedAt: "2026-07-10T01:00:00.000Z", - taxClass: "reduced", - }); - expect(res.status).toBe(404); - expect((await json(res)).reason).toBe("PRODUCT_NOT_FOUND"); - }); - - test("guard: no admin token ⇒ 401", async () => { - const res = await patch( - "prod-1", - { expectedUpdatedAt: "2026-07-10T01:00:00.000Z", taxClass: "reduced" }, - { token: null }, - ); - expect(res.status).toBe(401); - }); - - // -- product data-model adds (Increment 2 slice 5) ------------------------ - - test("round-trips compare-at, unit cost, and inventory policy through the admin detail", async () => { - const watermark = await seedAndReadWatermark(); - const res = await patch("prod-1", { - expectedUpdatedAt: watermark, - compareAtPrice: { amount: 3000, currency: "USD" }, - unitCost: { amount: 850, currency: "USD" }, - inventoryPolicy: "deny", - }); - expect(res.status).toBe(200); - - const after = (await json(await get("prod-1"))).product as Record; - expect(after.compareAtCents).toBe(3000); - expect(after.compareAtCurrency).toBe("USD"); - expect(after.unitCostCents).toBe(850); - expect(after.unitCostCurrency).toBe("USD"); - expect(after.inventoryPolicy).toBe("deny"); - }); - - test("rejects a compare-at in a different currency than the product's price (409 CURRENCY_MISMATCH)", async () => { - const watermark = await seedAndReadWatermark(); - const res = await patch("prod-1", { - expectedUpdatedAt: watermark, - compareAtPrice: { amount: 3000, currency: "EUR" }, - }); - expect(res.status).toBe(409); - expect((await json(res)).reason).toBe("CURRENCY_MISMATCH"); - }); - - test("rejects a mixed-currency edit (price USD + compare-at EUR) as a 400, nothing written", async () => { - const watermark = await seedAndReadWatermark(); - const res = await patch("prod-1", { - expectedUpdatedAt: watermark, - price: { amount: 2599, currency: "USD" }, - compareAtPrice: { amount: 3000, currency: "EUR" }, - }); - expect(res.status).toBe(400); - const after = (await json(await get("prod-1"))).product as Record; - expect(after.compareAtCents).toBeNull(); - expect(after.priceCents).toBe(1000); // untouched - }); - - test("unit cost NEVER leaks to a storefront-facing read path (admin-only)", async () => { - const watermark = await seedAndReadWatermark(); - await patch("prod-1", { - expectedUpdatedAt: watermark, - compareAtPrice: { amount: 3000, currency: "USD" }, - unitCost: { amount: 850, currency: "USD" }, - }); - - // (a) The public (un-authenticated) raw commerce GET: compare-at is present, - // unit cost is absent — it is admin-only margin data. - const publicRes = await fetch(`${server.baseUrl}/products/prod-1/commerce`); - const publicBody = await json(publicRes); - expect(publicBody).not.toHaveProperty("unitCost"); - expect(publicBody).not.toHaveProperty("unitCostCents"); - expect(publicBody.compareAt).toEqual({ amount: 3000, currency: "USD" }); - - // (b) The storefront catalog batch view: no cost of any kind. - const catalogRes = await fetch(`${server.baseUrl}/catalog/commerce/batch`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ productIds: ["prod-1"] }), - }); - const items = (await json(catalogRes)).items as Array>; - for (const item of items) { - expect(item).not.toHaveProperty("unitCost"); - expect(item).not.toHaveProperty("unitCostCents"); - } - }); - - test("the write gate blocks a PATCH with no X-Service-Token when the service secret is set", async () => { - const gated = await startTestServer({ serviceToken: "svc-secret" }); - try { - const res = await fetch(`${gated.baseUrl}/admin/products/prod-1`, { - method: "PATCH", - headers: { - "Content-Type": "application/json", - "X-Internal-Token": gated.internalToken as string, - }, - body: JSON.stringify({ - expectedUpdatedAt: "2026-07-10T01:00:00.000Z", - taxClass: "reduced", - }), - }); - // The app-level write gate rejects a non-GET without the service token. - expect([401, 403]).toContain(res.status); - } finally { - await gated.stop(); - } - }); -}); diff --git a/packages/service/test/admin-products-http.test.ts b/packages/service/test/admin-products-http.test.ts deleted file mode 100644 index 557726a6..00000000 --- a/packages/service/test/admin-products-http.test.ts +++ /dev/null @@ -1,669 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test } from "vitest"; -import { startTestServer, type TestServer } from "./helpers/start-test-server.js"; - -// Admin Products console (view-only, admin-UX Increment 2): wire ⇄ port -// fidelity for GET /admin/products (list, keyset cursor round-trip preserving -// the filter) and GET /admin/products/:id (detail + stock, 404), against a -// LIVE server backed by Postgres. Guards: no token ⇒ 401, no configured token -// ⇒ 503. Cursor fail-closed (MOD-1): a garbage/tampered cursor ⇒ 400; a -// decoded out-of-range limit is clamped, not honored. Mirrors -// admin-orders-http.test.ts's shape. - -const PG = process.env.PG_CONNECTION_STRING; - -async function json(res: Response): Promise> { - return (await res.json()) as Record; -} - -/** Encode an opaque cursor the way the route does (base64url of the JSON) so a - * test can craft a tampered/out-of-range token. */ -function b64url(payload: unknown): string { - return Buffer.from(JSON.stringify(payload), "utf8").toString("base64url"); -} - -describe.skipIf(PG === undefined)("admin Products console HTTP contract", () => { - let server: TestServer; - let token: string; - - beforeEach(async () => { - server = await startTestServer(); - token = server.internalToken as string; - }); - afterEach(async () => { - await server.stop(); - }); - - function get(path: string, opts: { token?: string } = { token }): Promise { - const headers: Record = {}; - if (opts.token !== undefined) headers["X-Internal-Token"] = opts.token; - return fetch(`${server.baseUrl}/admin${path}`, { headers }); - } - - async function seed(): Promise { - // Three live USD products across three creation times + an inactive - // digital distractor inside the same window (to prove filters survive - // paging), plus a soft-deleted row that must never surface. - await server.seedProductRow({ - id: "prod-1", - sku: "SKU-1", - title: "Blue Widget", - priceCents: 1000, - currency: "USD", - productKind: "physical", - active: true, - createdAt: "2026-07-10T01:00:00.000Z", - }); - await server.seedProductRow({ - id: "prod-2", - sku: "SKU-2", - title: "Red Gadget", - priceCents: 2000, - currency: "USD", - productKind: "physical", - active: true, - createdAt: "2026-07-11T01:00:00.000Z", - }); - await server.seedProductRow({ - id: "prod-3", - sku: "SKU-3", - title: "Green Sprocket", - priceCents: 3000, - currency: "USD", - productKind: "physical", - active: true, - createdAt: "2026-07-12T01:00:00.000Z", - }); - await server.seedProductRow({ - id: "prod-ebook", - sku: "SKU-EBOOK", - title: "Findable Ebook", - priceCents: 999, - currency: "USD", - productKind: "digital", - active: false, - createdAt: "2026-07-11T12:00:00.000Z", - }); - await server.seedProductRow({ - id: "prod-deleted", - sku: "SKU-DEL", - title: "Deleted Product", - priceCents: 100, - currency: "USD", - active: true, - createdAt: "2026-07-13T00:00:00.000Z", - deletedAt: "2026-07-13T01:00:00.000Z", - }); - } - - test("GET /admin/products lists newest-first with the summary projection (integer cents), excluding soft-deleted rows", async () => { - await seed(); - const body = await json(await get("/products")); - expect(body.ok).toBe(true); - const products = body.products as Array>; - // Newest-first across the four LIVE rows; the soft-deleted one is absent. - expect(products.map((p) => p.productId)).toEqual(["prod-3", "prod-ebook", "prod-2", "prod-1"]); - const first = products[0]!; - expect(first).toMatchObject({ - productId: "prod-3", - sku: "SKU-3", - title: "Green Sprocket", - priceCents: 3000, - currency: "USD", - productKind: "physical", - active: true, - createdAt: "2026-07-12T01:00:00.000Z", - }); - expect(products.some((p) => p.productId === "prod-deleted")).toBe(false); - expect(body.nextCursor).toBeNull(); - }); - - // -- onHand on the list wire ----------------------------------------------- - // The service sources it from ONE LEFT JOIN per page. `null` ("no inventory - // record" — unknown) and `0` ("out of stock") are DIFFERENT facts and must - // stay distinguishable all the way to the client. - - test("GET /admin/products carries onHand on every row: a count when stocked, 0 when empty, null when there is no inventory record", async () => { - await seed(); - await server.seed("SKU-3", 12); // stocked - await server.seed("SKU-2", 0); // known sku, genuinely out of stock - // SKU-1 and SKU-EBOOK are deliberately left with NO inventory row. - - const body = await json(await get("/products")); - const products = body.products as Array>; - const bySku = new Map(products.map((p) => [p.sku, p])); - - expect(bySku.get("SKU-3")?.onHand).toBe(12); - // Out of stock — a real zero, which must NOT arrive as null. - expect(bySku.get("SKU-2")?.onHand).toBe(0); - expect(bySku.get("SKU-2")?.onHand).not.toBeNull(); - // Unknown — no inventory row at all, which must NOT arrive as 0. - expect(bySku.get("SKU-1")?.onHand).toBeNull(); - expect(bySku.get("SKU-1")?.onHand).not.toBe(0); - expect(bySku.get("SKU-EBOOK")?.onHand).toBeNull(); - - // Present on EVERY row (never "sometimes on the wire"), and never money — - // no cents/currency companion field appears beside it. - for (const p of products) expect(Object.hasOwn(p, "onHand")).toBe(true); - expect(Object.keys(products[0]!)).not.toContain("onHandCents"); - }); - - test("GET /admin/products: the stock join never duplicates or drops a row, and paging is unaffected", async () => { - await seed(); - await server.seed("SKU-3", 4); - await server.seed("SKU-1", 0); - const page1 = await json(await get("/products?limit=2")); - const p1 = page1.products as Array>; - expect(p1.map((p) => p.productId)).toEqual(["prod-3", "prod-ebook"]); - expect(p1.map((p) => p.onHand)).toEqual([4, null]); - expect(page1.nextCursor).not.toBeNull(); - - const page2 = await json(await get(`/products?cursor=${String(page1.nextCursor)}`)); - const p2 = page2.products as Array>; - expect(p2.map((p) => p.productId)).toEqual(["prod-2", "prod-1"]); - expect(p2.map((p) => p.onHand)).toEqual([null, 0]); - }); - - test("GET /admin/products: a product with no sku reports onHand null (nothing to join against)", async () => { - await server.seedProductRow({ - id: "prod-skuless", - sku: null, - title: "No SKU Yet", - priceCents: null, - active: true, - createdAt: "2026-07-14T00:00:00.000Z", - }); - const body = await json(await get("/products")); - const products = body.products as Array>; - expect(products.find((p) => p.productId === "prod-skuless")?.onHand).toBeNull(); - }); - - test("a CMS product that was never priced (no sku, no price) IS listed — PR 1b makes this row reachable", async () => { - await seed(); - // Before "one home per field" this row could not exist: the CMS sync - // refused to mint a row without a sku, so a product created in the CMS and - // not yet priced was INVISIBLE in Pricing & inventory and there was no way - // to price it from the console. Now every CMS product has a row, and it - // must show up here — unpriced, waiting for a SKU and a price. - await server.seedProductRow({ - id: "prod-unpriced", - sku: null, - title: "Freshly Created", - priceCents: null, - active: true, - createdAt: "2026-07-14T00:00:00.000Z", - }); - - const body = await json(await get("/products")); - const products = body.products as Array>; - const row = products.find((p) => p.productId === "prod-unpriced"); - expect(row).toBeDefined(); - expect(row).toMatchObject({ - productId: "prod-unpriced", - sku: null, - title: "Freshly Created", - priceCents: null, - active: true, - }); - // It is listed in the ADMIN console but is not sellable: the catalog read - // (`listCommerceByIds`) filters commerce-incomplete rows, which is what - // the admin's "active (not priced)" status label reports. - const detail = await json(await get("/products/prod-unpriced")); - expect(detail.ok).toBe(true); - expect(detail.product).toMatchObject({ sku: null, priceCents: null, active: true }); - }); - - test("active + productKind + search filters compose", async () => { - await seed(); - const activeOnly = await json(await get("/products?active=true")); - const activeIds = (activeOnly.products as Array>).map( - (p) => p.productId, - ); - expect(activeIds.toSorted()).toEqual(["prod-1", "prod-2", "prod-3"]); - - const digitalOnly = await json(await get("/products?productKind=digital")); - expect( - (digitalOnly.products as Array>).map((p) => p.productId), - ).toEqual(["prod-ebook"]); - - // Search by exact sku, case-insensitive. - const bySku = (await json(await get("/products?search=sku-2"))).products as Array< - Record - >; - expect(bySku.map((p) => p.productId)).toEqual(["prod-2"]); - - // Search by a title substring, case-insensitive. - const byTitle = (await json(await get("/products?search=WIDGET"))).products as Array< - Record - >; - expect(byTitle.map((p) => p.productId)).toEqual(["prod-1"]); - - // Composed: active + digital + search. - const composed = await json( - await get("/products?active=false&productKind=digital&search=ebook"), - ); - expect((composed.products as Array>).map((p) => p.productId)).toEqual([ - "prod-ebook", - ]); - }); - - test("keyset cursor round-trips and preserves the filter across pages (no overlap/gap)", async () => { - await seed(); - const page1 = await json(await get("/products?productKind=physical&limit=2")); - const p1 = page1.products as Array>; - expect(p1.map((p) => p.productId)).toEqual(["prod-3", "prod-2"]); // newest physical first - expect(typeof page1.nextCursor).toBe("string"); - - const page2 = await json( - await get(`/products?cursor=${encodeURIComponent(page1.nextCursor as string)}`), - ); - const p2 = page2.products as Array>; - // The filter (productKind=physical) SURVIVES the cursor: the digital - // distractor is never surfaced, and the remainder is exactly prod-1. - expect(p2.map((p) => p.productId)).toEqual(["prod-1"]); - expect(page2.nextCursor).toBeNull(); - expect([...p1, ...p2].map((p) => p.productId)).toEqual(["prod-3", "prod-2", "prod-1"]); - }); - - // -- total: the exact size of the filtered set (INC-23) -------------------- - - test("GET /admin/products carries `total` — the whole FILTERED set, identical on every page, and counting the ARCHIVE view when that is what was asked for", async () => { - await seed(); - const page1 = await json(await get("/products?productKind=physical&limit=2")); - // 3 live physical products behind a 2-row page (the digital distractor and - // the tombstone are outside this filter, and outside its count). - expect(page1.total).toBe(3); - expect((page1.products as unknown[]).length).toBe(2); - const page2 = await json( - await get(`/products?cursor=${encodeURIComponent(page1.nextCursor as string)}`), - ); - expect(page2.total).toBe(3); - - // The tombstone default is shared with the list: 4 live rows, 1 archived. - expect((await json(await get("/products"))).total).toBe(4); - expect((await json(await get("/products?deleted=true"))).total).toBe(1); - - const none = await json(await get("/products?search=nothing-matches-this")); - expect(none.products).toEqual([]); - // Zero is REPORTED, not omitted (the key's presence is the capability). - expect(none.total).toBe(0); - expect(Object.hasOwn(none, "total")).toBe(true); - }); - - // -- lowStockThreshold: the server-side predicate wired from the query string - - - test("?lowStockThreshold filters to on_hand <= threshold, excludes rows with no inventory record, and `total` agrees", async () => { - await seed(); - await server.seed("SKU-3", 2); // low - await server.seed("SKU-2", 10); // known, not low - // SKU-1 and SKU-EBOOK are deliberately left with NO inventory row — absent - // is not zero, so neither may match a low-stock predicate. - const body = await json(await get("/products?lowStockThreshold=5")); - const products = body.products as Array>; - expect(products.map((p) => p.productId)).toEqual(["prod-3"]); - expect(body.total).toBe(1); - }); - - test("?lowStockThreshold=0 is its own boundary — INCLUSIVE, and matches only a genuinely out-of-stock row", async () => { - await seed(); - await server.seed("SKU-3", 0); - await server.seed("SKU-2", 1); - const body = await json(await get("/products?lowStockThreshold=0")); - expect((body.products as Array>).map((p) => p.productId)).toEqual([ - "prod-3", - ]); - }); - - test("an out-of-domain ?lowStockThreshold is a 400, never a 500", async () => { - await seed(); - expect((await get("/products?lowStockThreshold=-1")).status).toBe(400); - expect((await get("/products?lowStockThreshold=2.5")).status).toBe(400); - expect((await get("/products?lowStockThreshold=not-a-number")).status).toBe(400); - }); - - test("keyset cursor round-trips `lowStockThreshold` across pages — the filter survives paging", async () => { - await seed(); - await server.seed("SKU-3", 1); - await server.seed("SKU-2", 2); - await server.seed("SKU-1", 3); - const page1 = await json(await get("/products?lowStockThreshold=5&limit=2")); - const p1 = page1.products as Array>; - expect(p1).toHaveLength(2); - expect(page1.total).toBe(3); - expect(typeof page1.nextCursor).toBe("string"); - - const page2 = await json( - await get(`/products?cursor=${encodeURIComponent(page1.nextCursor as string)}`), - ); - const p2 = page2.products as Array>; - // The remainder is exactly the third low-stock row — the digital - // distractor (no inventory row) never leaks in behind the cursor. - expect(p2).toHaveLength(1); - expect(page2.total).toBe(3); - expect([...p1, ...p2].map((p) => p.productId).toSorted()).toEqual([ - "prod-1", - "prod-2", - "prod-3", - ]); - }); - - test("GET /admin/products/:id returns the full detail incl. stock", async () => { - await seed(); - await server.seed("SKU-1", 42); - const body = await json(await get("/products/prod-1")); - expect(body.ok).toBe(true); - const product = body.product as Record; - expect(product).toMatchObject({ - productId: "prod-1", - sku: "SKU-1", - title: "Blue Widget", - priceCents: 1000, - currency: "USD", - productKind: "physical", - active: true, - onHand: 42, - }); - }); - - // -- onHand on the DETAIL wire, with the LIST's semantics (INC-23) ---------- - // The detail used to collapse both "no inventory row" and "no sku" to `0`, - // so the SAME product read `—` in the list and `0` on its own detail page, - // one click apart. These four pin the three cases apart, on the wire. - - test("GET /admin/products/:id with no inventory row reports onHand:null (unknown), never 0", async () => { - await seed(); - const body = await json(await get("/products/prod-2")); - expect(body.ok).toBe(true); - const product = body.product as Record; - expect(product.onHand).toBeNull(); - expect(product.onHand).not.toBe(0); - // The key is always present — a consumer never has to guess whether the - // field exists before reading it. - expect(Object.hasOwn(product, "onHand")).toBe(true); - }); - - test("GET /admin/products/:id reports onHand:0 for a sku that HAS a row at zero — out of stock is a fact, not an unknown", async () => { - await seed(); - await server.seed("SKU-2", 0); - const product = (await json(await get("/products/prod-2"))).product as Record; - expect(product.onHand).toBe(0); - expect(product.onHand).not.toBeNull(); - }); - - test("GET /admin/products/:id agrees with GET /admin/products on the same product's onHand", async () => { - await seed(); - await server.seed("SKU-3", 12); - const list = (await json(await get("/products"))).products as Array>; - const bySku = new Map(list.map((p) => [p.sku, p])); - for (const [sku, id] of [ - ["SKU-3", "prod-3"], // stocked - ["SKU-2", "prod-2"], // no inventory row - ] as const) { - const detail = (await json(await get(`/products/${id}`))).product as Record; - expect(detail.onHand).toEqual(bySku.get(sku)?.onHand); - } - }); - - test("GET /admin/products/:id: a product with no sku reports onHand:null (nothing to look up)", async () => { - await server.seedProductRow({ - id: "prod-skuless-detail", - sku: null, - title: "Create then price", - priceCents: null, - createdAt: "2026-07-14T00:00:00.000Z", - }); - const product = (await json(await get("/products/prod-skuless-detail"))).product as Record< - string, - unknown - >; - expect(product.onHand).toBeNull(); - }); - - test("GET /admin/products/:id 404s for an unknown product", async () => { - const res = await get("/products/does-not-exist"); - expect(res.status).toBe(404); - expect((await json(res)).reason).toBe("PRODUCT_NOT_FOUND"); - }); - - test("GET /admin/products/:id returns the read-only tombstone (200 + deletedAt) for a soft-deleted product — never masquerades as 'never existed' (product lifecycle surfacing)", async () => { - await seed(); - const res = await get("/products/prod-deleted"); - expect(res.status).toBe(200); - const body = await json(res); - expect(body.ok).toBe(true); - const product = body.product as Record; - expect(product.productId).toBe("prod-deleted"); - expect(product.deletedAt).toBe("2026-07-13T01:00:00.000Z"); - }); - - test("GET /admin/products excludes the archive by default; filter.deleted=true is the archive-only view, projecting deletedAt", async () => { - await seed(); - const live = await json(await get("/products")); - expect( - (live.products as Array>).every((p) => p.deletedAt === null), - ).toBe(true); - expect( - (live.products as Array>).some((p) => p.productId === "prod-deleted"), - ).toBe(false); - - const archived = await json(await get("/products?deleted=true")); - const archivedProducts = archived.products as Array>; - expect(archivedProducts.map((p) => p.productId)).toEqual(["prod-deleted"]); - expect(archivedProducts[0]?.deletedAt).toBe("2026-07-13T01:00:00.000Z"); - }); - - test("a soft-deleted product remains blocked from the WRITE routes (edit / restock / remove-stock) — 404, never editable from the tombstone view", async () => { - await seed(); - const patchRes = await fetch(`${server.baseUrl}/admin/products/prod-deleted`, { - method: "PATCH", - headers: { "X-Internal-Token": token, "Content-Type": "application/json" }, - // `taxClass`, not `title` — the edit schema is `.strict()` and title is - // CMS-owned (ADR-0013), so a title here would be a 400 and this case - // would stop testing the tombstone guard it exists for. - body: JSON.stringify({ expectedUpdatedAt: "2026-07-13T00:00:00.000Z", taxClass: "reduced" }), - }); - expect(patchRes.status).toBe(404); - expect((await json(patchRes)).reason).toBe("PRODUCT_NOT_FOUND"); - - const restockRes = await fetch(`${server.baseUrl}/admin/products/prod-deleted/restock`, { - method: "POST", - headers: { - "X-Internal-Token": token, - "Content-Type": "application/json", - "Idempotency-Key": "restock-deleted-1", - }, - body: JSON.stringify({ qty: 5 }), - }); - expect(restockRes.status).toBe(404); - expect((await json(restockRes)).reason).toBe("PRODUCT_NOT_FOUND"); - }); - - test("guard: no token ⇒ 401 on both list and detail", async () => { - expect((await get("/products", {})).status).toBe(401); - expect((await get("/products/prod-1", {})).status).toBe(401); - }); - - test("guard: a server with no configured internal token ⇒ 503 (disabled, not open)", async () => { - const disabled = await startTestServer({ internalToken: null }); - try { - const res = await fetch(`${disabled.baseUrl}/admin/products`); - expect(res.status).toBe(503); - } finally { - await disabled.stop(); - } - }); - - test("MOD-1: a garbage/tampered cursor fails closed with 400 (never 500)", async () => { - expect((await get("/products?cursor=%21%21%21not-base64%21%21%21")).status).toBe(400); - const notJson = Buffer.from("this is not json", "utf8").toString("base64url"); - expect((await get(`/products?cursor=${notJson}`)).status).toBe(400); - // Structurally valid but the embedded filter is invalid (unknown kind) — - // re-validated through zod ⇒ 400. - const badFilter = b64url({ - pos: { createdAt: "2026-07-12T01:00:00.000Z", productId: "prod-3" }, - filter: { productKind: "bogus-kind" }, - limit: 25, - }); - expect((await get(`/products?cursor=${badFilter}`)).status).toBe(400); - // A cursor whose pos.createdAt is not a valid ISO datetime ⇒ 400. - const badCreatedAt = b64url({ - pos: { createdAt: "not-a-timestamp", productId: "prod-3" }, - filter: {}, - limit: 25, - }); - expect((await get(`/products?cursor=${badCreatedAt}`)).status).toBe(400); - }); - - test("MOD-1: a decoded out-of-range limit is clamped, not honored (no 400/500)", async () => { - await seed(); - const cursor = b64url({ - pos: { createdAt: "2999-01-01T00:00:00.000Z", productId: "zzzz" }, - filter: {}, - limit: 999_999, - }); - const res = await get(`/products?cursor=${cursor}`); - expect(res.status).toBe(200); // clamped to the max, request still succeeds - const products = (await json(res)).products as Array>; - expect(products.map((p) => p.productId)).toEqual(["prod-3", "prod-ebook", "prod-2", "prod-1"]); - }); - - // -- a cursor that disagrees with the query's filters fails CLOSED ---------- - // Mirrors the Orders list's gate 1:1 (see admin-orders-http.test.ts for the - // reasoning): PRESENT filter params must canonicalize to exactly the token's - // embedded filter, ABSENT ones claim nothing. The four quadrants — cursor - // alone, cursor + agreeing params, cursor + disagreeing params, params alone — - // are pinned below, `lowStockThreshold` included. - - test("quadrant: cursor + AGREEING filter params pages byte-identically to the cursor ALONE", async () => { - await seed(); - const page1 = await json(await get("/products?productKind=physical&limit=2")); - const cursor = encodeURIComponent(page1.nextCursor as string); - - const aloneRes = await get(`/products?cursor=${cursor}`); - const alone = await aloneRes.text(); - const agreeRes = await get(`/products?cursor=${cursor}&productKind=physical`); - expect(aloneRes.status).toBe(200); - expect(agreeRes.status).toBe(200); - expect(await agreeRes.text()).toBe(alone); - const parsed = JSON.parse(alone) as { products: Array<{ productId: string }>; total: number }; - expect(parsed.products.map((p) => p.productId)).toEqual(["prod-1"]); - expect(parsed.total).toBe(3); - }); - - test("quadrant: cursor + DISAGREEING filter params ⇒ 400, never a silently divergent page", async () => { - await seed(); - // An UNFILTERED token paged under a `productKind` the token never carried. - const unfiltered = await json(await get("/products?limit=1")); - const unfilteredCursor = encodeURIComponent(unfiltered.nextCursor as string); - const res = await get(`/products?cursor=${unfilteredCursor}&productKind=physical`); - expect(res.status).toBe(400); - expect(await json(res)).toEqual({ error: "cursor filter mismatch" }); - - // The mirror, plus the other axes: active, deleted, search. - const physical = await json(await get("/products?productKind=physical&limit=1")); - const physicalCursor = encodeURIComponent(physical.nextCursor as string); - expect((await get(`/products?cursor=${physicalCursor}&productKind=digital`)).status).toBe(400); - expect( - (await get(`/products?cursor=${physicalCursor}&productKind=physical&active=true`)).status, - ).toBe(400); - expect( - (await get(`/products?cursor=${physicalCursor}&productKind=physical&deleted=true`)).status, - ).toBe(400); - expect( - (await get(`/products?cursor=${physicalCursor}&productKind=physical&search=widget`)).status, - ).toBe(400); - }); - - test("quadrant: filter params ALONE (no cursor) are untouched by the gate", async () => { - await seed(); - const body = await json(await get("/products?productKind=physical")); - expect((body.products as Array>).map((p) => p.productId)).toEqual([ - "prod-3", - "prod-2", - "prod-1", - ]); - expect(body.total).toBe(3); - }); - - test("a filter axis the query OMITS is still a disagreement when the token carries it", async () => { - await seed(); - const page1 = await json(await get("/products?active=true&productKind=physical&limit=2")); - const cursor = encodeURIComponent(page1.nextCursor as string); - // A subset is not agreement: the rows are narrower than the address says. - expect((await get(`/products?cursor=${cursor}&productKind=physical`)).status).toBe(400); - expect((await get(`/products?cursor=${cursor}&active=true&productKind=physical`)).status).toBe( - 200, - ); - }); - - test("`deleted=false` and an OMITTED `deleted` are one predicate, so the two spellings agree", async () => { - await seed(); - // The tombstone axis is `deleted_at IS NULL` for every value except `true` - // (store + port doc), so these two requests issue identical SQL. Treating - // them as different filters would 400 two spellings of ONE predicate — the - // exact failure this gate exists to prevent, inverted. - const bare = await json(await get("/products?productKind=physical&limit=2")); - const bareCursor = encodeURIComponent(bare.nextCursor as string); - const bareAlone = await (await get(`/products?cursor=${bareCursor}`)).text(); - const withFalse = await get( - `/products?cursor=${bareCursor}&productKind=physical&deleted=false`, - ); - expect(withFalse.status).toBe(200); - expect(await withFalse.text()).toBe(bareAlone); - - // And the reverse: a token MINTED with `deleted=false`, paged by a request - // that leaves the axis out. - const explicit = await json(await get("/products?productKind=physical&deleted=false&limit=2")); - const explicitCursor = encodeURIComponent(explicit.nextCursor as string); - const explicitAlone = await (await get(`/products?cursor=${explicitCursor}`)).text(); - const omitted = await get(`/products?cursor=${explicitCursor}&productKind=physical`); - expect(omitted.status).toBe(200); - expect(await omitted.text()).toBe(explicitAlone); - - // `active=false` is NOT that kind of axis: the store emits a real - // `active = 0` for it (an integer column), so it and an omitted `active` are - // genuinely different predicates and must keep disagreeing. The asymmetry - // belongs to the store, and is deliberate rather than an inconsistency. - expect( - (await get(`/products?cursor=${bareCursor}&productKind=physical&active=false`)).status, - ).toBe(400); - // `deleted=true` is a different predicate from both, and still disagrees. - expect( - (await get(`/products?cursor=${bareCursor}&productKind=physical&deleted=true`)).status, - ).toBe(400); - }); - - test("the low-stock threshold participates in the comparison, like every other axis", async () => { - await seed(); - await server.seed("SKU-3", 1); - await server.seed("SKU-2", 2); - await server.seed("SKU-1", 3); - const page1 = await json(await get("/products?lowStockThreshold=5&limit=2")); - const cursor = encodeURIComponent(page1.nextCursor as string); - const alone = await (await get(`/products?cursor=${cursor}`)).text(); - - // The SAME threshold agrees and pages; a DIFFERENT one is a 400 rather than - // a page whose rows answer a threshold the address does not name. - const agree = await get(`/products?cursor=${cursor}&lowStockThreshold=5`); - expect(agree.status).toBe(200); - expect(await agree.text()).toBe(alone); - expect((await get(`/products?cursor=${cursor}&lowStockThreshold=9`)).status).toBe(400); - // Zero is a real threshold, not an absent one. - expect((await get(`/products?cursor=${cursor}&lowStockThreshold=0`)).status).toBe(400); - }); - - test("a `limit` that disagrees with the token's embedded limit ⇒ 400; an agreeing one pages", async () => { - await seed(); - const page1 = await json(await get("/products?productKind=physical&limit=2")); - const cursor = encodeURIComponent(page1.nextCursor as string); - const alone = await (await get(`/products?cursor=${cursor}`)).text(); - - // The shape live clients send today: cursor + the same page limit. - const agree = await get(`/products?cursor=${cursor}&limit=2`); - expect(agree.status).toBe(200); - expect(await agree.text()).toBe(alone); - - const disagree = await get(`/products?cursor=${cursor}&limit=5`); - expect(disagree.status).toBe(400); - expect(await json(disagree)).toEqual({ error: "cursor filter mismatch" }); - }); -}); diff --git a/packages/service/test/admin-read-gate.test.ts b/packages/service/test/admin-read-gate.test.ts deleted file mode 100644 index e69d52fa..00000000 --- a/packages/service/test/admin-read-gate.test.ts +++ /dev/null @@ -1,297 +0,0 @@ -import { - CountingIdGen, - FakeEmailSender, - FixedClock, - InMemoryAddressStore, - InMemoryCartStore, - InMemoryCouponStore, - InMemoryCredentialVerifier, - InMemoryCustomerStore, - InMemoryEntitlementStore, - InMemoryInventoryStore, - InMemoryOrderNotesStore, - InMemoryOrderStore, - InMemoryPaymentEventStore, - InMemoryProductCommerceStore, - InMemoryReportingStore, - InMemorySessionStore, - InMemorySettingsStore, - InMemoryShippingRulesStore, - InMemoryTaxRulesStore, -} from "@otta-sh/domain/testing"; -import { Hono } from "hono"; -import { describe, expect, test } from "vitest"; -import { createApp } from "../src/app.js"; - -// Regression pin for the unauthenticated admin/config READ hole (ADR-0010). -// The Phase-6 rules-admin GETs (shipping/tax/coupon config) and `GET /settings` -// must require `X-Internal-Token`, exactly like their write siblings and like -// every `/reports/*` read — merchant config, not public catalog data. Before the -// guard these GETs were reachable with NO token at all: the app-level -// SERVICE_API_TOKEN write gate exempts GET/HEAD (`auth.ts`), so a GET reached -// them ungated and `GET /admin/coupons/:code` leaked full coupon economics plus -// live `usesCount`. -// -// The thing under test is the PARENT-level guard registered in `createApp`, not -// the sub-app blanket guards. Hono merges a sub-app's middleware into the parent -// AT MOUNT TIME, so that middleware covers only what is registered AFTER it: a -// sibling sub-app mounted at the same prefix EARLIER runs ungated. `adminRoutes` -// and `rulesAdminRoutes` are both mounted at "/admin", `adminRoutes` first, so a -// blanket guard inside the latter never covers the former (pinned below by -// "WHY the guard cannot live in a sub-app"). Every test therefore drives the -// FULL app, never a bare sub-app. -// IO-free: in-memory stores + `app.request()` (no server, no PG). - -function makeApp( - options: { serviceToken?: string; internalToken?: string; probeAdminRoute?: boolean } = {}, -): Hono { - const clock = new FixedClock(new Date("2026-07-10T00:00:00.000Z")); - const inventory = new InMemoryInventoryStore({ idGen: new CountingIdGen("res"), clock }); - const cartStore = new InMemoryCartStore({ - idGen: new CountingIdGen("cart"), - reservationState: (id) => { - try { - return inventory.reservationState(id); - } catch { - return undefined; - } - }, - releaseHold: (id) => { - void inventory.release(id); - }, - }); - const productCommerce = new InMemoryProductCommerceStore({ - clock, - // NOTE: `InMemoryInventoryStore.onHand` returns 0 for an unseeded sku, so - // this wiring COLLAPSES null -> 0. Fine for the coarse `inStock` boolean - // these suites exercise; do NOT assert the products-list `onHand` - // projection through it (the list must distinguish "no inventory row" - // from "out of stock" — see the divergence note in - // `packages/domain/src/ports/inventory-store.ts`'s `getOnHand` doc). - inventoryOnHand: (s) => inventory.onHand(s), - }); - const idGen = new CountingIdGen("id"); - const customerStore = new InMemoryCustomerStore({ idGen, clock }); - const app = createApp({ - store: inventory, - productCommerce, - cartStore, - orderStore: new InMemoryOrderStore({ idGen, clock }), - orderNotesStore: new InMemoryOrderNotesStore({ idGen, clock }), - entitlementStore: new InMemoryEntitlementStore({ idGen, clock }), - paymentEventStore: new InMemoryPaymentEventStore(), - shippingRules: new InMemoryShippingRulesStore(), - taxRules: new InMemoryTaxRulesStore(), - couponStore: new InMemoryCouponStore({ idGen, clock }), - reportingStore: new InMemoryReportingStore(), - settingsStore: new InMemorySettingsStore(), - customerStore, - addressStore: new InMemoryAddressStore({ idGen, clock }), - sessionStore: new InMemorySessionStore({ idGen, clock }), - credentialVerifier: new InMemoryCredentialVerifier({ customerStore, idGen, clock }), - emailSender: new FakeEmailSender(), - idGen, - gateways: {}, - clock, - serviceToken: options.serviceToken, - internalToken: options.internalToken, - }); - if (options.probeAdminRoute === true) { - // A THIRD sub-app mounted at "/admin" with NO inline guard of its own — - // stands in for a future route added by someone who forgot the guard. The - // parent-level `app.use("/admin/*")` must cover it. - // - // HONEST SCOPE: this is a FORWARD-LOOKING default-deny pin, not a - // discriminator against the sub-app-only design. Anything the test mounts - // necessarily lands AFTER `rulesAdminRoutes`, so its merged "/admin/*" - // guard would have covered this probe too — the sub-app-only design does - // NOT fail here. What it does fail is `/settings`, which has no such - // merged guard: the five `/settings` cases above are the discriminating - // ones. This pin's value is the future: it goes red if BOTH the parent - // guard and the sub-app guard are ever removed. - const probe = new Hono(); - probe.get("/probe-unguarded", (c) => c.json({ ok: true, leaked: "secret-config" }, 200)); - app.route("/admin", probe); - } - return app; -} - -const TOKEN = "int-secret"; -const authed = { "X-Internal-Token": TOKEN }; - -/** The full admin/config READ surface this change closes, with the status each - * path answers ONCE authorized (so "reached the route" is asserted precisely, - * not merely "not 401"). */ -const READ_PATHS: ReadonlyArray = [ - ["/admin/shipping/zones", 200], - ["/admin/shipping/zones/z1/methods", 200], - ["/admin/shipping/methods/m1/rates?currency=USD", 404], - ["/admin/tax/classes", 200], - ["/admin/tax/rates?zoneId=z1", 200], - ["/admin/coupons/SAVE5", 404], - ["/settings", 200], -]; - -describe("admin/config reads require the internal token", () => { - test.each(READ_PATHS)("GET %s without X-Internal-Token is 401 (token set)", async (path) => { - const res = await makeApp({ internalToken: TOKEN }).request(path); - expect(res.status).toBe(401); - expect(await res.json()).toEqual({ ok: false, error: "unauthorized" }); - }); - - test.each(READ_PATHS)("GET %s with a wrong X-Internal-Token is 401", async (path) => { - const res = await makeApp({ internalToken: TOKEN }).request(path, { - headers: { "X-Internal-Token": "wrong" }, - }); - expect(res.status).toBe(401); - expect(await res.json()).toEqual({ ok: false, error: "unauthorized" }); - }); - - test.each(READ_PATHS)( - "GET %s is 503 when the internal token is unset (disabled, never silently open)", - async (path) => { - const res = await makeApp({}).request(path); - expect(res.status).toBe(503); - expect(await res.json()).toEqual({ ok: false, error: "internal endpoints disabled" }); - }, - ); - - test.each(READ_PATHS)( - "GET %s with the correct token reaches the route", - async (path, authorizedStatus) => { - const res = await makeApp({ internalToken: TOKEN }).request(path, { headers: authed }); - expect(res.status).toBe(authorizedStatus); - }, - ); - - test("the exact path GET /settings (no trailing slash, no wildcard) is guarded", async () => { - // Pins the deliberate `app.use("/settings")` + `app.use("/settings/*")` - // double registration: the exact-path form must not depend on a Hono minor - // keeping its "the wildcard also matches the bare prefix" behavior. - const res = await makeApp({ internalToken: TOKEN }).request("/settings"); - expect(res.status).toBe(401); - }); - - test("PUT /settings is unchanged: 503 unset, 401 wrong token, 200 authorized", async () => { - const body = JSON.stringify({ holdTtlMinutes: 30 }); - const headers = { "content-type": "application/json", "Idempotency-Key": "k-1" }; - const unset = await makeApp({}).request("/settings", { method: "PUT", headers, body }); - expect(unset.status).toBe(503); - const wrong = await makeApp({ internalToken: TOKEN }).request("/settings", { - method: "PUT", - headers: { ...headers, "X-Internal-Token": "wrong" }, - body, - }); - expect(wrong.status).toBe(401); - const ok = await makeApp({ internalToken: TOKEN }).request("/settings", { - method: "PUT", - headers: { ...headers, ...authed }, - body, - }); - expect(ok.status).toBe(200); - }); -}); - -describe("the guard is PARENT-level, not per-sub-app", () => { - // `adminRoutes` is a SIBLING sub-app of `rulesAdminRoutes` at the same "/admin" - // mount. A blanket guard inside `rulesAdminRoutes` never runs for it, so these - // pin that the coverage comes from `createApp`, not from a sub-app. - test.each(["/admin/orders", "/admin/products"])( - "GET %s is 503 when the internal token is unset", - async (path) => { - const res = await makeApp({}).request(path); - expect(res.status).toBe(503); - expect(await res.json()).toEqual({ ok: false, error: "internal endpoints disabled" }); - }, - ); - - test("GET /admin/orders with the correct token still reaches the route", async () => { - const res = await makeApp({ internalToken: TOKEN }).request("/admin/orders", { - headers: authed, - }); - expect(res.status).toBe(200); - }); - - test.each([ - ["token set, no header", TOKEN, 401], - ["token unset", undefined, 503], - ] as const)( - "an /admin route with NO inline guard of its own is still closed (%s)", - async (_label, internalToken, expected) => { - const app = makeApp({ - probeAdminRoute: true, - ...(internalToken !== undefined ? { internalToken } : {}), - }); - const res = await app.request("/admin/probe-unguarded"); - expect(res.status).toBe(expected); - expect(await res.text()).not.toContain("secret-config"); - }, - ); - - test("WHY the guard cannot live in a sub-app: Hono merges sub-app middleware at mount time", async () => { - // The hazard this design exists to remove, pinned against the installed - // Hono rather than assumed. A blanket `app.use("/*")` inside one sub-app is - // merged into the parent WHERE THAT SUB-APP IS MOUNTED, so it only covers - // what is registered after it: a sibling mounted at the same prefix EARLIER - // runs ungated. `adminRoutes` is exactly that sibling, mounted at "/admin" - // before `rulesAdminRoutes`. If this ever starts failing, Hono changed its - // middleware semantics and the parent guard's rationale should be re-read. - const ranFor: string[] = []; - const parent = new Hono(); - const earlier = new Hono(); - earlier.get("/earlier", (c) => c.json({ ok: true })); - const guarded = new Hono(); - guarded.use("/*", async (c, next) => { - ranFor.push(c.req.path); - await next(); - }); - guarded.get("/own", (c) => c.json({ ok: true })); - parent.route("/admin", earlier); - parent.route("/admin", guarded); - - expect((await parent.request("/admin/own")).status).toBe(200); - expect((await parent.request("/admin/earlier")).status).toBe(200); - // The sub-app's own route was covered; the sibling mounted earlier was NOT. - expect(ranFor).toEqual(["/admin/own"]); - }); -}); - -describe("HEAD is guarded too (the write gate exempts it, this guard must not)", () => { - test("HEAD /settings with no token is 401", async () => { - const res = await makeApp({ internalToken: TOKEN }).request("/settings", { method: "HEAD" }); - expect(res.status).toBe(401); - }); - - test("HEAD /admin/tax/classes with no token is 401", async () => { - const res = await makeApp({ internalToken: TOKEN }).request("/admin/tax/classes", { - method: "HEAD", - }); - expect(res.status).toBe(401); - }); - - test("HEAD /health stays open", async () => { - const res = await makeApp({ internalToken: TOKEN }).request("/health", { method: "HEAD" }); - expect(res.status).toBe(200); - }); -}); - -describe("the public read surface stays open (ADR-0010 enumeration)", () => { - // Pin the OTHER half of the decision: gating the admin surface must not creep - // into the storefront reads. `GET /orders/:id` and `GET /me/*` are covered by - // their own suites (capability URL / session Bearer). - test.each([ - ["no service token", undefined], - ["service token set", "svc-secret"], - ] as const)("with %s, the storefront reads need no token", async (_label, serviceToken) => { - const app = makeApp({ - internalToken: TOKEN, - ...(serviceToken !== undefined ? { serviceToken } : {}), - }); - expect((await app.request("/health")).status).toBe(200); - // An unknown cart/product is a 404 FROM THE ROUTE — never 401/503. - for (const path of ["/carts/unknown-cart", "/products/unknown-product/commerce"]) { - const res = await app.request(path); - expect([200, 404]).toContain(res.status); - } - }); -}); diff --git a/packages/service/test/admin-refund-http.test.ts b/packages/service/test/admin-refund-http.test.ts deleted file mode 100644 index 6fd96e63..00000000 --- a/packages/service/test/admin-refund-http.test.ts +++ /dev/null @@ -1,394 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test } from "vitest"; -import type { - StripeCreatePaymentIntentResult, - StripeCreateRefundResult, - StripePreflightResult, - StripeTransport, -} from "@otta-sh/payments-stripe"; -import { startTestServer, type TestServer } from "./helpers/start-test-server.js"; - -// Admin refund HTTP contract (ADR-0008): wire ⇄ port fidelity for the refund -// endpoints against a LIVE server backed by Postgres. Covers the GET -// ceiling/remaining/capability read, the POST gateway (Stripe) + manual (x402) -// paths, the ceiling rejection, the required Idempotency-Key, the fail-closed -// PROVIDER_ALREADY_REFUNDED, the internal-token + write gate, and idempotent replay. - -const PG = process.env.PG_CONNECTION_STRING; - -async function json(res: Response): Promise> { - return (await res.json()) as Record; -} - -/** A scripted offline Stripe transport making the gateway refundable:true. */ -class StubTransport implements StripeTransport { - preflight: StripePreflightResult = { - ok: true, - view: { amountRefunded: 0, amountCaptured: 1000, currency: "usd" }, - }; - async readRefundedAmount(): Promise { - return this.preflight; - } - async createRefund(input: { amountCents: number }): Promise { - return { ok: true, refundId: "re_test", amountCents: input.amountCents, currency: "usd" }; - } - /** These suites never checkout through this server — the seam's (now - * required) third method is stubbed to keep the transport type-complete. */ - async createPaymentIntent(input: { orderId: string }): Promise { - return { - ok: true, - intentId: `pi_${input.orderId}`, - clientSecret: `pi_${input.orderId}_secret_stub`, - }; - } -} - -describe.skipIf(PG === undefined)("admin refund HTTP contract (Stripe, refundable)", () => { - let server: TestServer; - let token: string; - - beforeEach(async () => { - server = await startTestServer({ - stripeSecretKey: "sk_test", - stripeTransport: new StubTransport(), - }); - token = server.internalToken as string; - await server.seedOrder({ - id: "ord-paid", - state: "paid", - currency: "USD", - buyerRef: "alice@example.com", - paymentMethod: "stripe", - createdAt: "2026-07-10T00:00:00.000Z", - totalCents: 1000, - }); - await server.seedPayment({ - orderId: "ord-paid", - gateway: "stripe", - providerRef: "pi_paid", - amountCents: 1000, - currency: "USD", - }); - }); - afterEach(async () => { - await server.stop(); - }); - - function postRefund( - orderId: string, - body: Record, - opts: { token?: string | null; idempotencyKey?: string; serviceToken?: string } = {}, - ): Promise { - const headers: Record = { "content-type": "application/json" }; - const tk = opts.token === undefined ? token : opts.token; - if (tk !== null) headers["X-Internal-Token"] = tk; - if (opts.idempotencyKey !== undefined) headers["Idempotency-Key"] = opts.idempotencyKey; - if (opts.serviceToken !== undefined) headers["X-Service-Token"] = opts.serviceToken; - return fetch(`${server.baseUrl}/admin/orders/${orderId}/refund`, { - method: "POST", - headers, - body: JSON.stringify(body), - }); - } - - function getRefunds(orderId: string): Promise { - return fetch(`${server.baseUrl}/admin/orders/${orderId}/refunds`, { - headers: { "X-Internal-Token": token }, - }); - } - - test("GET refunds shows the ceiling, remaining, and honest capability (refundable:true)", async () => { - const body = await json(await getRefunds("ord-paid")); - expect(body.ok).toBe(true); - expect(body.capturedTotalCents).toBe(1000); - expect(body.ceilingCents).toBe(1000); - expect(body.refundedTotalCents).toBe(0); - expect(body.remainingCents).toBe(1000); - expect(body.refundable).toBe(true); - expect(body.paymentMethod).toBe("stripe"); - expect(body.refunds).toEqual([]); - }); - - test("a full gateway refund records + flips to refunded; the ledger + remaining update", async () => { - const res = await postRefund( - "ord-paid", - { amountCents: 1000, currency: "USD", refundedBy: "ops@shop.test" }, - { idempotencyKey: "rf-1" }, - ); - expect(res.status).toBe(200); - const body = await json(res); - expect(body.recorded).toBe(true); - expect(body.fullyRefunded).toBe(true); - const refund = body.refund as Record; - expect(refund.kind).toBe("gateway"); - expect(refund.refundRef).toBe("re_test"); - expect(refund.amountCents).toBe(1000); - expect((body.order as Record).state).toBe("refunded"); - - const after = await json(await getRefunds("ord-paid")); - expect(after.refundedTotalCents).toBe(1000); - expect(after.remainingCents).toBe(0); - expect((after.refunds as unknown[]).length).toBe(1); - }); - - test("a partial refund does not transition; remaining decreases", async () => { - await postRefund( - "ord-paid", - { amountCents: 400, currency: "USD", refundedBy: "ops" }, - { idempotencyKey: "rf-partial" }, - ); - const after = await json(await getRefunds("ord-paid")); - expect(after.refundedTotalCents).toBe(400); - expect(after.remainingCents).toBe(600); - }); - - test("over-refund past the ceiling → 409 REFUND_EXCEEDS_TOTAL", async () => { - const res = await postRefund( - "ord-paid", - { amountCents: 1001, currency: "USD", refundedBy: "ops" }, - { idempotencyKey: "rf-over" }, - ); - expect(res.status).toBe(409); - expect((await json(res)).reason).toBe("REFUND_EXCEEDS_TOTAL"); - }); - - test("a missing Idempotency-Key header → 400 (refunds are additive, must not collapse)", async () => { - const res = await postRefund("ord-paid", { - amountCents: 100, - currency: "USD", - refundedBy: "ops", - }); - expect(res.status).toBe(400); - expect((await json(res)).reason).toBe("MISSING_IDEMPOTENCY_KEY"); - }); - - test("idempotent replay: same key → recorded once, duplicate on the second", async () => { - const first = await json( - await postRefund( - "ord-paid", - { amountCents: 300, currency: "USD", refundedBy: "ops" }, - { idempotencyKey: "rf-idem" }, - ), - ); - expect(first.recorded).toBe(true); - const replay = await json( - await postRefund( - "ord-paid", - { amountCents: 300, currency: "USD", refundedBy: "ops" }, - { idempotencyKey: "rf-idem" }, - ), - ); - expect(replay.recorded).toBe(false); - expect(replay.duplicate).toBe(true); - const after = await json(await getRefunds("ord-paid")); - expect(after.refundedTotalCents).toBe(300); - }); - - test("unknown order → 404; no internal token → 401", async () => { - expect( - ( - await postRefund( - "nope", - { amountCents: 100, currency: "USD", refundedBy: "ops" }, - { idempotencyKey: "x" }, - ) - ).status, - ).toBe(404); - expect( - ( - await postRefund( - "ord-paid", - { amountCents: 100, currency: "USD", refundedBy: "ops" }, - { idempotencyKey: "x", token: null }, - ) - ).status, - ).toBe(401); - }); - - test("fail closed: a provider that already refunded past our view → 409 PROVIDER_ALREADY_REFUNDED, nothing recorded", async () => { - const gated = await startTestServer({ - stripeSecretKey: "sk_test", - stripeTransport: (() => { - const t = new StubTransport(); - t.preflight = { - ok: true, - view: { amountRefunded: 500, amountCaptured: 1000, currency: "usd" }, - }; - return t; - })(), - }); - try { - const tk = gated.internalToken as string; - await gated.seedOrder({ - id: "ord-pre", - state: "paid", - currency: "USD", - buyerRef: "b@example.com", - paymentMethod: "stripe", - createdAt: "2026-07-10T00:00:00.000Z", - totalCents: 1000, - }); - await gated.seedPayment({ - orderId: "ord-pre", - gateway: "stripe", - providerRef: "pi_pre", - amountCents: 1000, - currency: "USD", - }); - const res = await fetch(`${gated.baseUrl}/admin/orders/ord-pre/refund`, { - method: "POST", - headers: { - "content-type": "application/json", - "X-Internal-Token": tk, - "Idempotency-Key": "rf-pre", - }, - body: JSON.stringify({ amountCents: 300, currency: "USD", refundedBy: "ops" }), - }); - expect(res.status).toBe(409); - expect((await json(res)).reason).toBe("PROVIDER_ALREADY_REFUNDED"); - const after = await json( - await fetch(`${gated.baseUrl}/admin/orders/ord-pre/refunds`, { - headers: { "X-Internal-Token": tk }, - }), - ); - expect(after.refundedTotalCents).toBe(0); - } finally { - await gated.stop(); - } - }); - - test("ambiguous gateway timeout → 409 GATEWAY_UNVERIFIED end-to-end; reservation HELD (capacity kept), order NOT flipped, replay re-surfaces it", async () => { - // The reserve-before-issue seam through the REAL Stripe adapter: the create - // errors with an unknown fate (5xx/timeout → `ambiguous` → UNVERIFIED). The - // reservation is marked `unverified` and KEEPS holding its ceiling capacity - // (the safe direction) — the money's fate must be re-checked at the provider, - // never blind-retried. Previously proven only adapter-side; now driven over HTTP. - const gated = await startTestServer({ - stripeSecretKey: "sk_test", - stripeTransport: (() => { - const t = new StubTransport(); - // Pre-flight is clean; the CREATE is the ambiguous one. - t.createRefund = async (): Promise => ({ - ok: false, - class: "ambiguous", - }); - return t; - })(), - }); - try { - const tk = gated.internalToken as string; - await gated.seedOrder({ - id: "ord-amb", - state: "paid", - currency: "USD", - buyerRef: "b@example.com", - paymentMethod: "stripe", - createdAt: "2026-07-10T00:00:00.000Z", - totalCents: 1000, - }); - await gated.seedPayment({ - orderId: "ord-amb", - gateway: "stripe", - providerRef: "pi_amb", - amountCents: 1000, - currency: "USD", - }); - const res = await fetch(`${gated.baseUrl}/admin/orders/ord-amb/refund`, { - method: "POST", - headers: { - "content-type": "application/json", - "X-Internal-Token": tk, - "Idempotency-Key": "rf-amb", - }, - body: JSON.stringify({ amountCents: 1000, currency: "USD", refundedBy: "ops" }), - }); - expect(res.status).toBe(409); - expect((await json(res)).reason).toBe("GATEWAY_UNVERIFIED"); - - // The held reservation consumes the ceiling (remaining 0) but the order was - // NOT flipped to refunded — the money is unverified, not confirmed. - const after = await json( - await fetch(`${gated.baseUrl}/admin/orders/ord-amb/refunds`, { - headers: { "X-Internal-Token": tk }, - }), - ); - expect(after.refundedTotalCents, "unverified reservation HOLDS capacity").toBe(1000); - expect(after.remainingCents).toBe(0); - const refunds = after.refunds as Array>; - expect(refunds).toHaveLength(1); - expect(refunds[0]?.status).toBe("unverified"); - - // A same-key replay re-surfaces GATEWAY_UNVERIFIED — never a blind re-issue. - const replay = await fetch(`${gated.baseUrl}/admin/orders/ord-amb/refund`, { - method: "POST", - headers: { - "content-type": "application/json", - "X-Internal-Token": tk, - "Idempotency-Key": "rf-amb", - }, - body: JSON.stringify({ amountCents: 1000, currency: "USD", refundedBy: "ops" }), - }); - expect(replay.status).toBe(409); - expect((await json(replay)).reason).toBe("GATEWAY_UNVERIFIED"); - } finally { - await gated.stop(); - } - }); -}); - -describe.skipIf(PG === undefined)("admin refund HTTP contract (x402, manual record-only)", () => { - let server: TestServer; - let token: string; - - beforeEach(async () => { - server = await startTestServer(); - token = server.internalToken as string; - await server.seedOrder({ - id: "ord-x402", - state: "paid", - currency: "USD", - buyerRef: "c@example.com", - paymentMethod: "x402", - createdAt: "2026-07-10T00:00:00.000Z", - totalCents: 800, - }); - await server.seedPayment({ - orderId: "ord-x402", - gateway: "x402", - providerRef: "0xtx", - amountCents: 800, - currency: "USD", - }); - }); - afterEach(async () => { - await server.stop(); - }); - - test("GET refunds reports refundable:false for an x402 order (honest capability)", async () => { - const body = await json( - await fetch(`${server.baseUrl}/admin/orders/ord-x402/refunds`, { - headers: { "X-Internal-Token": token }, - }), - ); - expect(body.refundable).toBe(false); - expect(body.paymentMethod).toBe("x402"); - }); - - test("a full refund on an x402 order records a MANUAL entry (no refundRef) and flips to refunded", async () => { - const res = await fetch(`${server.baseUrl}/admin/orders/ord-x402/refund`, { - method: "POST", - headers: { - "content-type": "application/json", - "X-Internal-Token": token, - "Idempotency-Key": "rf-x402", - }, - body: JSON.stringify({ amountCents: 800, currency: "USD", refundedBy: "ops" }), - }); - expect(res.status).toBe(200); - const body = await json(res); - const refund = body.refund as Record; - expect(refund.kind).toBe("manual"); - expect(refund.refundRef).toBeNull(); - expect(body.fullyRefunded).toBe(true); - expect((body.order as Record).state).toBe("refunded"); - }); -}); diff --git a/packages/service/test/admin-resolve-reconciliation-http.test.ts b/packages/service/test/admin-resolve-reconciliation-http.test.ts deleted file mode 100644 index 68a8ceb5..00000000 --- a/packages/service/test/admin-resolve-reconciliation-http.test.ts +++ /dev/null @@ -1,236 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test } from "vitest"; -import { startTestServer, type TestServer } from "./helpers/start-test-server.js"; - -// Admin resolve-reconciliation HTTP contract (admin-UX Increment 1): wire ⇄ port -// fidelity for POST /admin/orders/:id/resolve-reconciliation against a LIVE server -// backed by Postgres. Clears a flagged order's reconciliation flag and records the -// admin disposition, NEVER touching state/line items. Guards: internal-token, the -// X-Service-Token write gate (a non-GET), validation (bad outcome / blank reason → -// 400), unknown order (→ 404), a never-flagged order (→ 409), and idempotent -// replay via the guarded flip. - -const PG = process.env.PG_CONNECTION_STRING; - -async function json(res: Response): Promise> { - return (await res.json()) as Record; -} - -describe.skipIf(PG === undefined)("admin resolve-reconciliation HTTP contract", () => { - let server: TestServer; - let token: string; - - beforeEach(async () => { - server = await startTestServer(); - token = server.internalToken as string; - // A flagged order (settle lost a hold) awaiting manual resolution. - await server.seedOrder({ - id: "ord-flagged", - state: "paid", - currency: "USD", - buyerRef: "alice@example.com", - createdAt: "2026-07-10T00:00:00.000Z", - totalCents: 1000, - reconciliationFlag: "commit lost for reservation res-1", - }); - // A clean order (never flagged). - await server.seedOrder({ - id: "ord-clean", - state: "paid", - currency: "USD", - buyerRef: "bob@example.com", - createdAt: "2026-07-10T00:00:00.000Z", - totalCents: 500, - }); - }); - afterEach(async () => { - await server.stop(); - }); - - // The flag detail seeded on ord-flagged — the "as displayed" value a real - // admin reviews; every resolve must echo it back (compare-and-clear). - const LIVE_FLAG = "commit lost for reservation res-1"; - - function post( - orderId: string, - body: Record, - opts: { token?: string | null; idempotencyKey?: string; serviceToken?: string } = {}, - ): Promise { - const headers: Record = { "content-type": "application/json" }; - const tk = opts.token === undefined ? token : opts.token; - if (tk !== null) headers["X-Internal-Token"] = tk; - if (opts.idempotencyKey !== undefined) headers["Idempotency-Key"] = opts.idempotencyKey; - if (opts.serviceToken !== undefined) headers["X-Service-Token"] = opts.serviceToken; - // expectedFlag defaults to the live flag; a test overrides it to exercise - // the stale-review conflict. - return fetch(`${server.baseUrl}/admin/orders/${orderId}/resolve-reconciliation`, { - method: "POST", - headers, - body: JSON.stringify({ expectedFlag: LIVE_FLAG, ...body }), - }); - } - - function getOrder(orderId: string): Promise { - return fetch(`${server.baseUrl}/admin/orders/${orderId}`, { - headers: { "X-Internal-Token": token }, - }); - } - - test("resolves a flagged order (200, resolved:true): clears the flag, records the disposition, state unchanged", async () => { - const res = await post("ord-flagged", { - outcome: "fulfilled", - reason: "re-sourced from warehouse B", - resolvedBy: "ops@shop.test", - }); - expect(res.status).toBe(200); - const body = await json(res); - expect(body.resolved).toBe(true); - const order = body.order as Record; - expect(order.reconciliationFlag).toBeNull(); - expect(order.state).toBe("paid"); // resolve never moves the state - expect(order.reconciliationResolution).toMatchObject({ - outcome: "fulfilled", - reason: "re-sourced from warehouse B", - resolvedBy: "ops@shop.test", - resolvedAt: "2026-07-10T00:00:00.000Z", - }); - - // A fresh GET reflects the same cleared flag + recorded disposition. - const reloaded = (await json(await getOrder("ord-flagged"))).order as Record; - expect(reloaded.reconciliationFlag).toBeNull(); - expect((reloaded.reconciliationResolution as Record).outcome).toBe( - "fulfilled", - ); - }); - - test("trims reason + resolvedBy server-side (domain validation)", async () => { - const body = await json( - await post("ord-flagged", { - outcome: "refunded", - reason: " refunded via stripe ", - resolvedBy: " alice ", - }), - ); - const resolution = (body.order as Record).reconciliationResolution as Record< - string, - unknown - >; - expect(resolution.reason).toBe("refunded via stripe"); - expect(resolution.resolvedBy).toBe("alice"); - }); - - test("a STALE expectedFlag → 409 RECONCILIATION_FLAG_CHANGED; the live flag survives", async () => { - const res = await post("ord-flagged", { - expectedFlag: "an older anomaly the admin reviewed", // ≠ the live flag - outcome: "written_off", - reason: "reviewed the old anomaly", - resolvedBy: "ops", - }); - expect(res.status).toBe(409); - expect((await json(res)).reason).toBe("RECONCILIATION_FLAG_CHANGED"); - // The live flag is untouched — never cleared blind. - const reloaded = (await json(await getOrder("ord-flagged"))).order as Record; - expect(reloaded.reconciliationFlag).toBe("commit lost for reservation res-1"); - expect(reloaded.reconciliationResolution).toBeNull(); - }); - - test("a never-flagged order → 409 NOT_IN_RECONCILIATION", async () => { - const res = await post("ord-clean", { - outcome: "written_off", - reason: "n/a", - resolvedBy: "ops", - }); - expect(res.status).toBe(409); - expect((await json(res)).reason).toBe("NOT_IN_RECONCILIATION"); - }); - - test("replay is once-only: a second resolve is resolved:false, disposition unchanged", async () => { - const first = await json( - await post("ord-flagged", { - outcome: "refunded", - reason: "refunded buyer", - resolvedBy: "alice", - }), - ); - expect(first.resolved).toBe(true); - const replay = await json( - await post("ord-flagged", { - outcome: "written_off", - reason: "second call", - resolvedBy: "bob", - }), - ); - expect(replay.resolved).toBe(false); - const resolution = (replay.order as Record).reconciliationResolution as Record< - string, - unknown - >; - // The first disposition stands — the loser never overwrote it. - expect(resolution.outcome).toBe("refunded"); - expect(resolution.resolvedBy).toBe("alice"); - }); - - test("validation: unknown outcome → 400; blank reason → 400", async () => { - expect( - (await post("ord-flagged", { outcome: "nope", reason: "x", resolvedBy: "y" })).status, - ).toBe(400); - expect( - (await post("ord-flagged", { outcome: "fulfilled", reason: " ", resolvedBy: "y" })).status, - ).toBe(400); - }); - - test("unknown order → 404", async () => { - const res = await post("does-not-exist", { - outcome: "fulfilled", - reason: "x", - resolvedBy: "y", - }); - expect(res.status).toBe(404); - expect((await json(res)).reason).toBe("ORDER_NOT_FOUND"); - }); - - test("guard: no internal token ⇒ 401", async () => { - const res = await post( - "ord-flagged", - { outcome: "fulfilled", reason: "x", resolvedBy: "y" }, - { token: null }, - ); - expect(res.status).toBe(401); - }); - - test("write gate: with a service token set, POST needs X-Service-Token (401 without, 200 with)", async () => { - const gated = await startTestServer({ serviceToken: "svc-secret" }); - try { - const gatedToken = gated.internalToken as string; - await gated.seedOrder({ - id: "ord-g", - state: "paid", - currency: "USD", - buyerRef: "g@example.com", - createdAt: "2026-07-10T00:00:00.000Z", - totalCents: 500, - reconciliationFlag: "paid flip lost", - }); - const common = { "content-type": "application/json", "X-Internal-Token": gatedToken }; - const path = `${gated.baseUrl}/admin/orders/ord-g/resolve-reconciliation`; - const payload = JSON.stringify({ - expectedFlag: "paid flip lost", - outcome: "written_off", - reason: "loss accepted", - resolvedBy: "ops", - }); - // Missing X-Service-Token ⇒ blocked by the write gate. - const blocked = await fetch(path, { method: "POST", headers: common, body: payload }); - expect(blocked.status).toBe(401); - // With the service token ⇒ resolves. - const ok = await fetch(path, { - method: "POST", - headers: { ...common, "X-Service-Token": "svc-secret" }, - body: payload, - }); - expect(ok.status).toBe(200); - expect((await json(ok)).resolved).toBe(true); - } finally { - await gated.stop(); - } - }); -}); diff --git a/packages/service/test/admin-restock-http.test.ts b/packages/service/test/admin-restock-http.test.ts deleted file mode 100644 index eba93244..00000000 --- a/packages/service/test/admin-restock-http.test.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test } from "vitest"; -import { startTestServer, type TestServer } from "./helpers/start-test-server.js"; - -// Merchant restock / stock removal (admin-UX Increment 2, slice 3): wire ⇄ port -// fidelity for POST /admin/products/:id/restock and /remove-stock against a LIVE -// server backed by Postgres. Pins the productId → authoritative-sku resolution, -// the additive-restock + guarded-removal semantics, the required Idempotency-Key -// (an additive op has no safe content-only fallback), and the double-gate auth. - -const PG = process.env.PG_CONNECTION_STRING; - -async function json(res: Response): Promise> { - return (await res.json()) as Record; -} - -describe.skipIf(PG === undefined)("admin restock / remove-stock HTTP contract", () => { - let server: TestServer; - let token: string; - - beforeEach(async () => { - server = await startTestServer(); - token = server.internalToken as string; - }); - afterEach(async () => { - await server.stop(); - }); - - async function seedProduct(id: string, onHand: number, sku = `SKU-${id}`): Promise { - await server.seedProductRow({ - id, - sku, - title: "Widget", - priceCents: 1000, - currency: "USD", - productKind: "physical", - active: true, - createdAt: "2026-07-10T01:00:00.000Z", - }); - await server.seed(sku, onHand); - return sku; - } - - function post( - id: string, - verb: "restock" | "remove-stock", - body: unknown, - opts: { token?: string | null; idempotencyKey?: string | null } = {}, - ): Promise { - const headers: Record = { "Content-Type": "application/json" }; - const tok = opts.token === undefined ? token : opts.token; - if (tok !== null) headers["X-Internal-Token"] = tok; - const key = opts.idempotencyKey === undefined ? "key-1" : opts.idempotencyKey; - if (key !== null) headers["Idempotency-Key"] = key; - return fetch(`${server.baseUrl}/admin/products/${id}/${verb}`, { - method: "POST", - headers, - body: JSON.stringify(body), - }); - } - - test("restock adds units and returns the new on_hand", async () => { - const sku = await seedProduct("prod-1", 5); - const res = await post("prod-1", "restock", { qty: 8 }); - expect(res.status).toBe(200); - expect(await json(res)).toEqual({ ok: true, onHand: 13 }); - expect(await server.onHand(sku)).toBe(13); - }); - - test("a same-Idempotency-Key restock replay adds the units exactly once", async () => { - const sku = await seedProduct("prod-1", 5); - const first = await post("prod-1", "restock", { qty: 8 }, { idempotencyKey: "rk-1" }); - const replay = await post("prod-1", "restock", { qty: 8 }, { idempotencyKey: "rk-1" }); - expect(first.status).toBe(200); - expect(replay.status).toBe(200); - expect(await json(replay)).toEqual({ ok: true, onHand: 13 }); - expect(await server.onHand(sku)).toBe(13); // added once, not twice - }); - - test("remove-stock removes units down to the guarded floor", async () => { - const sku = await seedProduct("prod-1", 5); - const res = await post("prod-1", "remove-stock", { qty: 2 }); - expect(res.status).toBe(200); - expect(await json(res)).toEqual({ ok: true, onHand: 3 }); - expect(await server.onHand(sku)).toBe(3); - }); - - test("remove-stock beyond available is a 409 INSUFFICIENT_STOCK carrying the current count, never negative", async () => { - const sku = await seedProduct("prod-1", 3); - const res = await post("prod-1", "remove-stock", { qty: 5 }); - expect(res.status).toBe(409); - expect(await json(res)).toEqual({ ok: false, reason: "INSUFFICIENT_STOCK", onHand: 3 }); - expect(await server.onHand(sku)).toBe(3); // untouched - }); - - test("a missing Idempotency-Key is a 400 (an additive op has no safe fallback)", async () => { - await seedProduct("prod-1", 5); - const res = await post("prod-1", "restock", { qty: 8 }, { idempotencyKey: null }); - expect(res.status).toBe(400); - expect((await json(res)).reason).toBe("MISSING_IDEMPOTENCY_KEY"); - }); - - test("a non-positive / non-integer qty is a 400", async () => { - await seedProduct("prod-1", 5); - expect((await post("prod-1", "restock", { qty: 0 })).status).toBe(400); - expect((await post("prod-1", "restock", { qty: -3 })).status).toBe(400); - expect((await post("prod-1", "restock", { qty: 1.5 })).status).toBe(400); - }); - - test("restock on an unknown product is a 404", async () => { - const res = await post("ghost", "restock", { qty: 1 }); - expect(res.status).toBe(404); - expect((await json(res)).reason).toBe("PRODUCT_NOT_FOUND"); - }); - - test("restock on a priced-but-unseeded product (no inventory row) is a 409 NO_INVENTORY_ROW", async () => { - // A product_commerce row with a sku but NO inventory row (stock never - // seeded). A stock movement cannot create the row — clean 409. - await server.seedProductRow({ - id: "prod-unseeded", - sku: "SKU-unseeded", - title: "Unseeded", - priceCents: 1000, - currency: "USD", - createdAt: "2026-07-10T01:00:00.000Z", - }); - const res = await post("prod-unseeded", "restock", { qty: 5 }); - expect(res.status).toBe(409); - expect((await json(res)).reason).toBe("NO_INVENTORY_ROW"); - }); - - test("setting the first SKU through the admin EDIT makes the product restockable (PR 1a, end to end)", async () => { - // The bug 1a fixes, over the wire: a bare CMS-created row is priced in the - // admin console, and the restock that used to 409 NO_INVENTORY_ROW forever - // now succeeds. Must be an HTTP test — the service wiring (the edit route - // passing its InventoryStore into the use-case) is half the fix. - await server.seedProductRow({ - id: "prod-1a", - sku: null, - title: "Unpriced", - createdAt: "2026-07-10T01:00:00.000Z", - }); - const detail = (await json( - await fetch(`${server.baseUrl}/admin/products/prod-1a`, { - headers: { "X-Internal-Token": token }, - }), - )) as Record; - const watermark = (detail.product as Record).updatedAt as string; - - const edit = await fetch(`${server.baseUrl}/admin/products/prod-1a`, { - method: "PATCH", - headers: { "Content-Type": "application/json", "X-Internal-Token": token }, - body: JSON.stringify({ - expectedUpdatedAt: watermark, - sku: "SKU-1a", - price: { amount: 1500, currency: "USD" }, - }), - }); - expect(edit.status).toBe(200); - - const res = await post("prod-1a", "restock", { qty: 4 }, { idempotencyKey: "rk-1a" }); - expect(res.status).toBe(200); - expect(await json(res)).toEqual({ ok: true, onHand: 4 }); - expect(await server.onHand("SKU-1a")).toBe(4); - }); - - test("a product with no sku (create-then-price) is a 409 NO_SKU", async () => { - await server.seedProductRow({ - id: "prod-noskued", - sku: null, - title: "Skuless", - createdAt: "2026-07-10T01:00:00.000Z", - }); - const res = await post("prod-noskued", "restock", { qty: 5 }); - expect(res.status).toBe(409); - expect((await json(res)).reason).toBe("NO_SKU"); - }); - - test("guard: no admin token ⇒ 401", async () => { - await seedProduct("prod-1", 5); - const res = await post("prod-1", "restock", { qty: 1 }, { token: null }); - expect(res.status).toBe(401); - }); - - test("the write gate blocks a restock with no X-Service-Token when the service secret is set", async () => { - const gated = await startTestServer({ serviceToken: "svc-secret" }); - try { - const res = await fetch(`${gated.baseUrl}/admin/products/prod-1/restock`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-Internal-Token": gated.internalToken as string, - "Idempotency-Key": "k", - }, - body: JSON.stringify({ qty: 1 }), - }); - expect([401, 403]).toContain(res.status); - } finally { - await gated.stop(); - } - }); -}); diff --git a/packages/service/test/admin-timeline-http.test.ts b/packages/service/test/admin-timeline-http.test.ts deleted file mode 100644 index f6d3453d..00000000 --- a/packages/service/test/admin-timeline-http.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test } from "vitest"; -import { startTestServer, type TestServer } from "./helpers/start-test-server.js"; - -// Admin order timeline HTTP contract (admin-UX Increment 1, timeline slice): -// wire ⇄ use-case fidelity for GET /admin/orders/:id/timeline against a LIVE -// server backed by Postgres. State changes are driven through the REAL admin -// transition + fulfillment endpoints (so the state-change audit is genuinely -// written inside each guarded flip), a note is appended, and the merged -// chronological timeline is asserted. Guards: internal-token (401 without), -// unknown order (404). A directly-seeded order (no events) proves the -// graceful-degradation path (`stateChangesAudited:false`, partial timeline). - -const PG = process.env.PG_CONNECTION_STRING; - -async function json(res: Response): Promise> { - return (await res.json()) as Record; -} - -describe.skipIf(PG === undefined)("admin order timeline HTTP contract", () => { - let server: TestServer; - let token: string; - - beforeEach(async () => { - server = await startTestServer(); - token = server.internalToken as string; - }); - afterEach(async () => { - await server.stop(); - }); - - function authed(extra: Record = {}): Record { - return { "X-Internal-Token": token, "Content-Type": "application/json", ...extra }; - } - - function getTimeline(orderId: string, opts: { token?: string } = { token }): Promise { - const headers: Record = {}; - if (opts.token !== undefined) headers["X-Internal-Token"] = opts.token; - return fetch(`${server.baseUrl}/admin/orders/${orderId}/timeline`, { headers }); - } - - test("merges driven state changes, fulfillment, and a note into one chronological timeline", async () => { - // A paid order, then advance the clock between each write so entries get - // distinct timestamps (chronological order, not just the id tie-break). - await server.seedOrder({ - id: "ord-tl", - state: "paid", - currency: "USD", - buyerRef: "buyer@example.com", - createdAt: "2026-07-10T00:00:00.000Z", - totalCents: 1500, - }); - server.advance(60_000); - const toProcessing = await fetch(`${server.baseUrl}/admin/orders/ord-tl/transition`, { - method: "POST", - headers: authed({ "Idempotency-Key": "k-proc" }), - body: JSON.stringify({ toState: "processing" }), - }); - expect(toProcessing.status).toBe(200); - server.advance(60_000); - const ship = await fetch(`${server.baseUrl}/admin/orders/ord-tl/fulfillment`, { - method: "POST", - headers: authed({ "Idempotency-Key": "k-ship" }), - body: JSON.stringify({ carrier: "UPS", trackingNumber: "1Z-9", recordedBy: "ops@shop" }), - }); - expect(ship.status).toBe(200); - server.advance(60_000); - const note = await fetch(`${server.baseUrl}/admin/orders/ord-tl/notes`, { - method: "POST", - headers: authed({ "Idempotency-Key": "k-note" }), - body: JSON.stringify({ author: "ops", body: "packed and shipped" }), - }); - expect(note.status).toBe(201); - - const body = await json(await getTimeline("ord-tl")); - expect(body.ok).toBe(true); - const timeline = body.timeline as { - stateChangesAudited: boolean; - entries: Array>; - }; - expect(timeline.stateChangesAudited).toBe(true); - expect(timeline.entries.map((e) => e.kind)).toEqual([ - "created", - "state_change", // paid → processing - "state_change", // processing → shipped - "fulfillment", // the tracking detail, same instant as the shipped flip - "note", - ]); - // The shipped flip carries the recorder as actor; the fulfillment detail - // carries the tracking. - expect(timeline.entries[2]).toMatchObject({ - kind: "state_change", - fromState: "processing", - toState: "shipped", - actor: "ops@shop", - }); - expect(timeline.entries[3]).toMatchObject({ - kind: "fulfillment", - carrier: "UPS", - trackingNumber: "1Z-9", - }); - expect(timeline.entries[4]).toMatchObject({ - kind: "note", - author: "ops", - body: "packed and shipped", - }); - }); - - test("a directly-seeded order (no events) degrades to a partial timeline", async () => { - await server.seedOrder({ - id: "ord-hist", - state: "paid", - currency: "USD", - buyerRef: "buyer@example.com", - createdAt: "2026-07-10T00:00:00.000Z", - totalCents: 500, - }); - const body = await json(await getTimeline("ord-hist")); - expect(body.ok).toBe(true); - const timeline = body.timeline as { stateChangesAudited: boolean; entries: unknown[] }; - // No state_change events were ever recorded for this order — but its creation - // still anchors the timeline (a useful partial history). - expect(timeline.stateChangesAudited).toBe(false); - expect((timeline.entries as Array<{ kind: string }>).map((e) => e.kind)).toEqual(["created"]); - }); - - test("unknown order → 404 ORDER_NOT_FOUND", async () => { - const res = await getTimeline("does-not-exist"); - expect(res.status).toBe(404); - expect((await json(res)).reason).toBe("ORDER_NOT_FOUND"); - }); - - test("guard: no internal token ⇒ 401 (the audit read is admin-only)", async () => { - await server.seedOrder({ - id: "ord-guarded", - state: "paid", - currency: "USD", - buyerRef: "buyer@example.com", - createdAt: "2026-07-10T00:00:01.000Z", - totalCents: 100, - }); - expect((await getTimeline("ord-guarded", {})).status).toBe(401); - }); -}); diff --git a/packages/service/test/auth.test.ts b/packages/service/test/auth.test.ts deleted file mode 100644 index 8d52050e..00000000 --- a/packages/service/test/auth.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { Hono } from "hono"; -import { describe, expect, test } from "vitest"; -import { requireServiceToken, tokenMatches } from "../src/auth.js"; - -// `tokenMatches` moved from routes/carts.ts to auth.ts (shared by the -// X-Internal-Token gate and the X-Service-Token write gate) — behavior preserved. -describe("tokenMatches", () => { - test("an exact match is accepted", () => { - expect(tokenMatches("secret", "secret")).toBe(true); - }); - - test("a mismatch of equal length is rejected", () => { - expect(tokenMatches("secreta", "secretb")).toBe(false); - }); - - test("a length-differing candidate is rejected (no length leak — hashed compare)", () => { - expect(tokenMatches("secret-longer", "secret")).toBe(false); - expect(tokenMatches("s", "secret")).toBe(false); - }); - - test("an absent candidate is rejected", () => { - expect(tokenMatches(undefined, "secret")).toBe(false); - }); -}); - -function appWith(token: string | undefined): Hono { - const app = new Hono(); - app.use("*", requireServiceToken(token)); - app.get("/health", (c) => c.json({ ok: true })); - app.get("/read", (c) => c.json({ ok: true, read: true })); - app.post("/write", (c) => c.json({ ok: true, wrote: true })); - return app; -} - -describe("requireServiceToken middleware", () => { - test("token unset: everything passes through untouched (today's behavior)", async () => { - const app = appWith(undefined); - expect((await app.request("/write", { method: "POST" })).status).toBe(200); - expect((await app.request("/read")).status).toBe(200); - }); - - test("token set: a non-GET without X-Service-Token is 401 and carries NO WWW-Authenticate challenge", async () => { - const app = appWith("tok"); - const res = await app.request("/write", { method: "POST" }); - expect(res.status).toBe(401); - // The machine token no longer uses the Bearer scheme — no challenge header, - // byte-identical to the X-Internal-Token gate's 401. - expect(res.headers.get("WWW-Authenticate")).toBeNull(); - expect(await res.json()).toEqual({ ok: false, error: "unauthorized" }); - }); - - test("token set: a wrong X-Service-Token is 401", async () => { - const app = appWith("tok"); - const res = await app.request("/write", { - method: "POST", - headers: { "X-Service-Token": "not-tok" }, - }); - expect(res.status).toBe(401); - }); - - test("token set: the correct X-Service-Token reaches the route", async () => { - const app = appWith("tok"); - const res = await app.request("/write", { - method: "POST", - headers: { "X-Service-Token": "tok" }, - }); - expect(res.status).toBe(200); - expect(await res.json()).toEqual({ ok: true, wrote: true }); - }); - - test("token set: the gate IGNORES Authorization — a matching Bearer token is still 401", async () => { - // Authorization: Bearer is owned SOLELY by customer session auth now; the - // write gate reads ONLY X-Service-Token (ADR-0007). A request whose only - // credential is `Authorization: Bearer ` must NOT pass. - const app = appWith("tok"); - const res = await app.request("/write", { - method: "POST", - headers: { Authorization: "Bearer tok" }, - }); - expect(res.status).toBe(401); - }); - - test("token set: GET and HEAD stay open (Hono serves HEAD via GET handlers)", async () => { - const app = appWith("tok"); - expect((await app.request("/read")).status).toBe(200); - expect((await app.request("/read", { method: "HEAD" })).status).toBe(200); - expect((await app.request("/health")).status).toBe(200); - }); -}); diff --git a/packages/service/test/carts.http.contract.test.ts b/packages/service/test/carts.http.contract.test.ts deleted file mode 100644 index 68edab5f..00000000 --- a/packages/service/test/carts.http.contract.test.ts +++ /dev/null @@ -1,583 +0,0 @@ -import { afterAll, beforeAll, describe, expect, test } from "vitest"; -import { startTestServer, type TestServer } from "./helpers/start-test-server.js"; - -const PG = process.env.PG_CONNECTION_STRING; - -interface JsonResponse { - status: number; - body: Record; -} - -// D1 — the cart behavioral cases against a LIVE Postgres-backed test server, so -// the wire ⇄ port fidelity cannot drift. `Idempotency-Key` header → domain key; -// OUT_OF_STOCK is a typed 200 body, never a status code. -describe.skipIf(PG === undefined)("HTTP cart contract [live server, Postgres]", () => { - let server: TestServer; - - beforeAll(async () => { - server = await startTestServer(); - }); - afterAll(async () => { - await server.stop(); - }); - - async function req( - method: string, - path: string, - body?: unknown, - headers: Record = {}, - ): Promise { - const res = await fetch(`${server.baseUrl}${path}`, { - method, - headers: { "content-type": "application/json", ...headers }, - body: body === undefined ? undefined : JSON.stringify(body), - }); - return { status: res.status, body: (await res.json()) as Record }; - } - - async function newCart(): Promise { - const res = await req("POST", "/carts", {}); - expect(res.status).toBe(201); - return res.body.cartId as string; - } - - function addLine(cartId: string, sku: string, qty: number, key: string): Promise { - return req("POST", `/carts/${cartId}/lines`, { sku, qty }, { "Idempotency-Key": key }); - } - - function addLineWithProduct( - cartId: string, - sku: string, - productId: string, - qty: number, - key: string, - ): Promise { - return req( - "POST", - `/carts/${cartId}/lines`, - { sku, productId, qty }, - { "Idempotency-Key": key }, - ); - } - - // -- Variant helpers: the two-writer split, over the wire ----------------- - // A size is DECLARED by the CMS sync (name + presence, nothing commercial) - // and PRICED by the admin (sku + price, under a compare-and-set). These - // helpers keep that split visible in every test below, because a helper that - // merged them would quietly make the tests pass through a door the product - // does not have. - - const CWM = "2026-08-08T00:00:00.000Z"; - - async function declareVariant( - productId: string, - variantKey: string, - title: string, - contentUpdatedAt: string = CWM, - ): Promise { - return req( - "PUT", - `/products/${productId}/variants/${variantKey}`, - { title, contentUpdatedAt }, - { "Idempotency-Key": `declare-${productId}-${variantKey}-${contentUpdatedAt}` }, - ); - } - - async function priceVariant( - productId: string, - variantKey: string, - skuValue: string, - amount: number, - expectedUpdatedAt: string, - ): Promise { - return req( - "PATCH", - `/products/${productId}/variants/${variantKey}`, - { sku: skuValue, price: { amount, currency: "USD" }, expectedUpdatedAt }, - { "Idempotency-Key": `price-${productId}-${variantKey}` }, - ); - } - - /** Declare a size, price it, and stock it — the full "live sellable unit" - * state a cart add is entitled to resolve against. */ - async function liveVariant( - productId: string, - variantKey: string, - skuValue: string, - amount: number, - onHand: number, - ): Promise { - const declared = await declareVariant(productId, variantKey, variantKey); - expect(declared.status).toBe(200); - const priced = await priceVariant( - productId, - variantKey, - skuValue, - amount, - declared.body.updatedAt as string, - ); - expect(priced.status).toBe(200); - // The edit seeds the sku's inventory row at zero; give it real units. - await server.seed(skuValue, onHand); - } - - // SECURITY (issue #80 review): the client supplies `sku` and `productId` - // independently; the service must reconcile them against the trusted catalog - // so a caller cannot pair product A's productId (from which checkout takes - // price/title/entitlement) with product B's sku (a different good). When a - // product_commerce row exists it is authoritative — its sku MUST equal the - // submitted sku, else the add is rejected (SKU_MISMATCH) and never persisted. - test("add with a productId/sku pair that DISAGREES with the catalog is rejected (SKU_MISMATCH), no line, un-orderable", async () => { - await server.seedProduct({ - productId: "prod-cheap", - sku: "SKU-CHEAP", - priceCents: 100, - title: "Cheap", - kind: "physical", - onHand: 10, - }); - await server.seedProduct({ - productId: "prod-pricey", - sku: "SKU-PRICEY", - priceCents: 100000, - title: "Pricey", - kind: "physical", - onHand: 10, - }); - const cartId = await newCart(); - - // Attack: product A's (cheap) productId paired with product B's (pricey) sku. - const add = await addLineWithProduct(cartId, "SKU-PRICEY", "prod-cheap", 1, "k-mismatch"); - expect(add.status).toBe(409); - expect(add.body).toEqual({ ok: false, reason: "SKU_MISMATCH" }); - - // The line was NOT persisted, so the cart cannot reach a priced checkout. - const get = await req("GET", `/carts/${cartId}`); - const cart = get.body.cart as { lines: unknown[] }; - expect(cart.lines).toHaveLength(0); - - const quote = await req("POST", "/checkout/quote", { cartId }); - expect(quote.status).toBe(409); - expect(quote.body.reason).toBe("CART_EMPTY"); - }); - - test("add with a MATCHING productId/sku pair is accepted and reflects productId on the line", async () => { - await server.seedProduct({ - productId: "prod-match", - sku: "SKU-MATCH", - priceCents: 1500, - title: "Match", - kind: "physical", - onHand: 10, - }); - const cartId = await newCart(); - const add = await addLineWithProduct(cartId, "SKU-MATCH", "prod-match", 2, "k-match"); - expect(add.status).toBe(200); - expect(add.body.ok).toBe(true); - expect((add.body.line as Record).productId).toBe("prod-match"); - }); - - // -- The add endpoint's SKU guard ---------------------------------------- - // The rule, stated once: an add that names a product must RESOLVE its sku to - // a live, priced sellable unit OF THAT PRODUCT — the product's own row, or - // one of its live variants. Everything below is a case of that one sentence, - // and each case is one an attacker or a stale client can actually send. - - test("a productId with NO commerce row no longer waves an arbitrary sku through", async () => { - // Previously "harmless" — the line was unorderable, so it was allowed. It - // is still unorderable, and it still reserves real stock against a sku the - // named product has never been shown to own, so it is now refused. - await server.seed("SKU-UNOWNED", 7); - const cartId = await newCart(); - - const add = await addLineWithProduct(cartId, "SKU-UNOWNED", "prod-never-synced", 3, "k-norow"); - expect(add.status).toBe(409); - expect(add.body).toEqual({ ok: false, reason: "SKU_MISMATCH" }); - // Nothing reserved: the guard runs before the domain's add, so a refusal - // costs no units at all. - expect(await server.onHand("SKU-UNOWNED")).toBe(7); - }); - - test("a soft-deleted product cannot lend its sku to a cart line", async () => { - await server.seedProduct({ - productId: "prod-gone", - sku: "SKU-GONE", - priceCents: 900, - title: "Gone", - kind: "physical", - onHand: 5, - }); - const del = await req("DELETE", "/products/prod-gone/commerce", undefined, { - "Idempotency-Key": "del-gone", - }); - expect(del.status).toBe(200); - - const cartId = await newCart(); - const add = await addLineWithProduct(cartId, "SKU-GONE", "prod-gone", 1, "k-gone"); - expect(add.status).toBe(409); - expect(add.body).toEqual({ ok: false, reason: "SKU_MISMATCH" }); - expect(await server.onHand("SKU-GONE")).toBe(5); - }); - - // THIS TEST IS WRITTEN TO FLIP. A live, priced size of exactly this product - // resolves — and is refused anyway, with the same token a spoof gets, because - // order pricing reads the snapshot price AND title from the `product_commerce` - // row named by `productId` and cannot reach a variant. Accepting the line here - // would sell a 2500 size for the parent's 2000 under the parent's name, frozen - // onto the order line forever. - // - // IT FLIPS WHEN ORDER PRICING RESOLVES THE SELLABLE UNIT RATHER THAN THE - // PRODUCT ROW — snapshotting the size's own price and its own title. On that - // day this expectation becomes the 200 the commented block below describes, - // and `resolveSellableUnit`'s variant branch returns `ok`. Until then the - // refusal is the contract, not an omission. - test("a LIVE variant's sku is REFUSED until order pricing resolves the sellable unit", async () => { - await server.seedProduct({ - productId: "prod-tee", - sku: "SKU-TEE", - priceCents: 2000, - title: "Tee", - kind: "physical", - onHand: 4, - }); - await liveVariant("prod-tee", "large", "SKU-TEE-L", 2500, 6); - const cartId = await newCart(); - - const add = await addLineWithProduct(cartId, "SKU-TEE-L", "prod-tee", 2, "k-variant"); - expect(add.status).toBe(409); - expect(add.body).toEqual({ ok: false, reason: "SKU_MISMATCH" }); - // Nothing held, on either sku: the refusal precedes the domain's add. - expect(await server.onHand("SKU-TEE-L")).toBe(6); - expect(await server.onHand("SKU-TEE")).toBe(4); - const get = await req("GET", `/carts/${cartId}`); - expect((get.body.cart as { lines: unknown[] }).lines).toHaveLength(0); - - // On the flip, this is the assertion: - // expect(add.status).toBe(200); - // expect((add.body.line as Record).sku).toBe("SKU-TEE-L"); - // expect(await server.onHand("SKU-TEE-L")).toBe(4); // the SIZE's units - // expect(await server.onHand("SKU-TEE")).toBe(4); // the parent's, untouched - }); - - // The second half of the same ruling, and the one a merchant hits first: a - // product whose sizes carry all the money has no price of its own, so its - // cart could never reach a quote even if the add succeeded. Refusing at the - // add is the same answer stated where it can still be acted on. - test("a product priced ONLY through its sizes cannot be added yet — the same refusal, one step earlier", async () => { - const bare = await req( - "PUT", - "/products/prod-sizes-only/commerce", - { sku: "SKU-SIZES-ONLY", title: "Sizes only", productKind: "physical" }, - { "Idempotency-Key": "seed-sizes-only" }, - ); - expect(bare.status).toBe(200); - expect(bare.body.price).toBeNull(); - await liveVariant("prod-sizes-only", "large", "SKU-SIZES-L", 3000, 5); - const cartId = await newCart(); - - const add = await addLineWithProduct( - cartId, - "SKU-SIZES-L", - "prod-sizes-only", - 1, - "k-sizes-only", - ); - expect(add.status).toBe(409); - expect(add.body).toEqual({ ok: false, reason: "SKU_MISMATCH" }); - expect(await server.onHand("SKU-SIZES-L")).toBe(5); - }); - - test("one product cannot borrow ANOTHER product's variant sku", async () => { - await server.seedProduct({ - productId: "prod-plain", - sku: "SKU-PLAIN", - priceCents: 500, - title: "Plain", - kind: "physical", - onHand: 3, - }); - await server.seedProduct({ - productId: "prod-fancy", - sku: "SKU-FANCY", - priceCents: 99000, - title: "Fancy", - kind: "physical", - onHand: 3, - }); - await liveVariant("prod-fancy", "xl", "SKU-FANCY-XL", 99000, 3); - const cartId = await newCart(); - - // The #80 attack, one level down: the cheap product's id paired with the - // expensive product's SIZE. - const add = await addLineWithProduct(cartId, "SKU-FANCY-XL", "prod-plain", 1, "k-crossvar"); - expect(add.status).toBe(409); - expect(add.body).toEqual({ ok: false, reason: "SKU_MISMATCH" }); - expect(await server.onHand("SKU-FANCY-XL")).toBe(3); - }); - - test("an ORPHANED variant's sku is dead to the cart, though the row keeps sku, price and stock", async () => { - await server.seedProduct({ - productId: "prod-orph", - sku: "SKU-ORPH", - priceCents: 1000, - title: "Orph", - kind: "physical", - onHand: 2, - }); - await liveVariant("prod-orph", "small", "SKU-ORPH-S", 1200, 9); - - // The CMS dropped the repeater row. Deactivation, never deletion. - const drop = await req( - "POST", - "/products/prod-orph/variants/small/deactivate", - { contentUpdatedAt: "2026-08-09T00:00:00.000Z" }, - { "Idempotency-Key": "drop-orph-small" }, - ); - expect(drop.status).toBe(200); - - const cartId = await newCart(); - const add = await addLineWithProduct(cartId, "SKU-ORPH-S", "prod-orph", 1, "k-orphaned"); - expect(add.status).toBe(409); - expect(add.body).toEqual({ ok: false, reason: "SKU_MISMATCH" }); - // The units are retained — that is what "deactivate, never delete" means — - // they are simply no longer sellable through this sku. - expect(await server.onHand("SKU-ORPH-S")).toBe(9); - - // And the discontinued size does not appear on the unauthenticated read: - // its title and its last price are not public data. - const list = await req("GET", "/products/prod-orph/variants"); - expect(list.body.variants).toEqual([]); - }); - - test("an UNPRICED variant fails legibly as unpriced — never a line priced at the row above it", async () => { - await server.seedProduct({ - productId: "prod-unpriced", - sku: "SKU-UP", - priceCents: 100, - title: "Unpriced parent", - kind: "physical", - onHand: 5, - }); - // Declared and given a sku, but never priced: the state a resurrect leaves - // behind when it clears a price whose currency no longer holds. - const declared = await declareVariant("prod-unpriced", "medium", "Medium"); - expect(declared.status).toBe(200); - const skued = await req( - "PATCH", - "/products/prod-unpriced/variants/medium", - { sku: "SKU-UP-M", expectedUpdatedAt: declared.body.updatedAt as string }, - { "Idempotency-Key": "sku-only-medium" }, - ); - expect(skued.status).toBe(200); - expect(skued.body.price).toBeNull(); - await server.seed("SKU-UP-M", 4); - - const cartId = await newCart(); - const add = await addLineWithProduct(cartId, "SKU-UP-M", "prod-unpriced", 1, "k-unpriced"); - expect(add.status).toBe(409); - // SKU_MISMATCH rather than PRODUCT_NOT_PRICED, because every variant sku is - // refused today whether priced or not (see the flip test above). When that - // branch opens, this case becomes the PRODUCT_NOT_PRICED it describes — an - // unpriced size must fail as unpriced and never at the row above it. - expect(add.body).toEqual({ ok: false, reason: "SKU_MISMATCH" }); - // Emphatically NOT charged the parent's 100: no line exists at all. - expect(await server.onHand("SKU-UP-M")).toBe(4); - const get = await req("GET", `/carts/${cartId}`); - expect((get.body.cart as { lines: unknown[] }).lines).toHaveLength(0); - }); - - test("a REPLAYED rejected add is rejected identically — never half-applied on the retry", async () => { - await server.seedProduct({ - productId: "prod-replay", - sku: "SKU-REPLAY", - priceCents: 700, - title: "Replay", - kind: "physical", - onHand: 6, - }); - await server.seed("SKU-ELSEWHERE", 6); - const cartId = await newCart(); - - const first = await addLineWithProduct(cartId, "SKU-ELSEWHERE", "prod-replay", 1, "k-replay"); - const replay = await addLineWithProduct(cartId, "SKU-ELSEWHERE", "prod-replay", 1, "k-replay"); - expect(first.status).toBe(409); - expect(replay.status).toBe(first.status); - expect(replay.body).toEqual(first.body); - // The guard refuses BEFORE the idempotency key ever reaches the domain, so - // there is no half-applied first attempt for the replay to complete. - expect(await server.onHand("SKU-ELSEWHERE")).toBe(6); - const get = await req("GET", `/carts/${cartId}`); - expect((get.body.cart as { lines: unknown[] }).lines).toHaveLength(0); - }); - - // THE BARE-ADD RULE, pinned so the decision is a test rather than a memory. - // An add that names NO product is left exactly as it was, and this is why: - // `ProductCommerceStore` has no by-sku lookup — every read on it is keyed by - // productId — so "which live sellable unit holds this sku" is a question the - // guard cannot ask, and refusing every bare add would break the raw - // reservation primitive without closing a spoof. It closes no spoof because - // the line is UNORDERABLE BY CONSTRUCTION: both checkout paths reject a null - // productId before they price anything, so it can confer neither a price nor - // an entitlement. Closing the remainder honestly needs a by-sku resolver on - // the port, and inventing one from the admin list's case-insensitive search - // would resolve "sku-a" onto "SKU-A" and would not see variants at all. - test("a BARE add still reserves, and is still unorderable — the line can confer no price", async () => { - await server.seed("SKU-BARE", 5); - const cartId = await newCart(); - - const add = await addLine(cartId, "SKU-BARE", 2, "k-bare"); - expect(add.status).toBe(200); - expect((add.body.line as Record).productId).toBeNull(); - expect(await server.onHand("SKU-BARE")).toBe(3); - - const quote = await req("POST", "/checkout/quote", { cartId }); - expect(quote.status).toBe(409); - expect(quote.body.reason).toBe("PRODUCT_NOT_PRICED"); - }); - - test("POST /carts mints a cart id", async () => { - const res = await req("POST", "/carts", { currency: "USD" }); - expect(res.status).toBe(201); - expect(typeof res.body.cartId).toBe("string"); - }); - - // Issue #136 (and #132's wire half): `serializeCart` is where these fields are - // PRODUCED, and nothing downstream validates the cart body at runtime — the - // plugin's `#cartResult` blind-casts once `isCartEnvelope` has seen an `ok` - // key. So a silently dropped field compiles clean, arrives `undefined`, and - // `isCartTerminal(undefined)` reads a terminal cart as live (#110, again, - // with the whole suite green). Pin PRESENCE, not just the value: a bare - // `toBeNull()` passes on an absent key too. - test("GET /carts/:id emits BOTH `state` and `orderId` — presence is the assertion (#136/#132)", async () => { - const cartId = await newCart(); - const get = await req("GET", `/carts/${cartId}`); - expect(get.status).toBe(200); - const cart = get.body.cart as Record; - expect(cart).toHaveProperty("state"); - expect(cart).toHaveProperty("orderId"); - expect(cart.state).toBe("active"); - // A cart that never checked out names no order. The non-null case lives in - // `checkout-intent.http.pg.test.ts`, where an order actually exists. - expect(cart.orderId).toBeNull(); - }); - - test("add reserves stock and returns the line; GET reflects it", async () => { - await server.seed("SKU-A", 5); - const cartId = await newCart(); - const add = await addLine(cartId, "SKU-A", 2, "k-a"); - expect(add.status).toBe(200); - expect(add.body.ok).toBe(true); - expect(await server.onHand("SKU-A")).toBe(3); - - const get = await req("GET", `/carts/${cartId}`); - expect(get.status).toBe(200); - const cart = get.body.cart as { lines: Array> }; - expect(cart.lines).toHaveLength(1); - expect(cart.lines[0]?.qty).toBe(2); - // A cart line snapshots no price (Phase 3). - expect(cart.lines[0]).not.toHaveProperty("price"); - expect(cart.lines[0]).not.toHaveProperty("unitPriceCents"); - }); - - test("add beyond stock is a 200 typed OUT_OF_STOCK body, no line", async () => { - await server.seed("SKU-B", 1); - const cartId = await newCart(); - const add = await addLine(cartId, "SKU-B", 5, "k-b"); - expect(add.status).toBe(200); - expect(add.body).toEqual({ ok: false, reason: "OUT_OF_STOCK" }); - expect(await server.onHand("SKU-B")).toBe(1); - const get = await req("GET", `/carts/${cartId}`); - expect((get.body.cart as { lines: unknown[] }).lines).toHaveLength(0); - }); - - test("add is idempotent under a replayed Idempotency-Key (one decrement)", async () => { - await server.seed("SKU-C", 5); - const cartId = await newCart(); - const first = await addLine(cartId, "SKU-C", 2, "k-c"); - const replay = await addLine(cartId, "SKU-C", 2, "k-c"); - expect(first.body).toEqual(replay.body); - expect(await server.onHand("SKU-C")).toBe(3); - }); - - test("PATCH increases via delta-reserve; decreases partial-release", async () => { - await server.seed("SKU-D", 5); - const cartId = await newCart(); - const add = await addLine(cartId, "SKU-D", 2, "k-d1"); - const lineId = (add.body.line as { lineId: string }).lineId; - - const up = await req( - "PATCH", - `/carts/${cartId}/lines/${lineId}`, - { qty: 4 }, - { "Idempotency-Key": "k-d2" }, - ); - expect(up.status).toBe(200); - expect(await server.onHand("SKU-D")).toBe(1); - - const down = await req( - "PATCH", - `/carts/${cartId}/lines/${lineId}`, - { qty: 1 }, - { "Idempotency-Key": "k-d3" }, - ); - expect(down.status).toBe(200); - expect(await server.onHand("SKU-D")).toBe(4); - }); - - test("PATCH increase beyond stock is a 200 typed OUT_OF_STOCK, line unchanged", async () => { - await server.seed("SKU-E", 3); - const cartId = await newCart(); - const add = await addLine(cartId, "SKU-E", 2, "k-e1"); - const lineId = (add.body.line as { lineId: string }).lineId; - const up = await req( - "PATCH", - `/carts/${cartId}/lines/${lineId}`, - { qty: 5 }, - { "Idempotency-Key": "k-e2" }, - ); - expect(up.status).toBe(200); - expect(up.body).toEqual({ ok: false, reason: "OUT_OF_STOCK" }); - expect(await server.onHand("SKU-E")).toBe(1); - }); - - test("DELETE releases the whole reservation; double-delete is a no-op", async () => { - await server.seed("SKU-F", 5); - const cartId = await newCart(); - const add = await addLine(cartId, "SKU-F", 2, "k-f1"); - const lineId = (add.body.line as { lineId: string }).lineId; - - const del = await req("DELETE", `/carts/${cartId}/lines/${lineId}`, undefined, { - "Idempotency-Key": "k-f2", - }); - expect(del.status).toBe(200); - expect(await server.onHand("SKU-F")).toBe(5); - - const again = await req("DELETE", `/carts/${cartId}/lines/${lineId}`, undefined, { - "Idempotency-Key": "k-f3", - }); - expect(again.status).toBe(200); - expect(await server.onHand("SKU-F")).toBe(5); - }); - - test("GET on an expired hold lazily releases it (stock returns)", async () => { - await server.seed("SKU-G", 5); - const cartId = await newCart(); - await addLine(cartId, "SKU-G", 2, "k-g"); - expect(await server.onHand("SKU-G")).toBe(3); - - server.advance(16 * 60 * 1000); - const get = await req("GET", `/carts/${cartId}`); - expect((get.body.cart as { lines: unknown[] }).lines).toHaveLength(0); - expect(await server.onHand("SKU-G")).toBe(5); - }); - - test("GET on an unknown cart is 404; add missing Idempotency-Key is 400", async () => { - const notFound = await req("GET", "/carts/does-not-exist"); - expect(notFound.status).toBe(404); - expect(notFound.body).toEqual({ ok: false, reason: "CART_NOT_FOUND" }); - - const cartId = await newCart(); - const noKey = await req("POST", `/carts/${cartId}/lines`, { sku: "SKU-A", qty: 1 }); - expect(noKey.status).toBe(400); - }); -}); diff --git a/packages/service/test/catalog-commerce-batch.http-contract.test.ts b/packages/service/test/catalog-commerce-batch.http-contract.test.ts deleted file mode 100644 index 6d4f1084..00000000 --- a/packages/service/test/catalog-commerce-batch.http-contract.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { afterAll, beforeAll, describe, expect, test } from "vitest"; -import { COMMERCE_BATCH_ID_CAP } from "../src/routes/catalog.js"; -import { startTestServer, type TestServer } from "./helpers/start-test-server.js"; - -const PG = process.env.PG_CONNECTION_STRING; - -interface JsonResponse { - status: number; - body: Record | null; -} - -/** - * Phase 2 §7 step 3: live-server contract for `POST /catalog/commerce/batch` - * — wire ⇄ port fidelity for `listCommerceByIds` (missing ids omitted, no - * per-id error entries, money as integer + ISO-4217 string, `inStock` from - * the service's own single intra-DB join) plus the id-cap 400 request-size - * guard (a guard, not pagination — ADR-0002 rule 2). - */ -describe.skipIf(PG === undefined)("HTTP catalog commerce batch [live server, Postgres]", () => { - let server: TestServer; - - beforeAll(async () => { - server = await startTestServer(); - }); - afterAll(async () => { - await server.stop(); - }); - - async function putCommerce(id: string, body: unknown, key: string): Promise { - const res = await fetch(`${server.baseUrl}/products/${id}/commerce`, { - method: "PUT", - headers: { "content-type": "application/json", "Idempotency-Key": key }, - body: JSON.stringify(body), - }); - return { status: res.status, body: (await res.json()) as Record | null }; - } - - async function batch(body: unknown): Promise { - const res = await fetch(`${server.baseUrl}/catalog/commerce/batch`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(body), - }); - return { status: res.status, body: (await res.json()) as Record | null }; - } - - test("POST /catalog/commerce/batch returns items for known ids and omits unknown ids — no per-id error entries", async () => { - await putCommerce( - "prod-cb-1", - { sku: "SKU-CB1", price: { amount: 1999, currency: "USD" }, initialOnHand: 5 }, - "kcb1", - ); - await putCommerce( - "prod-cb-2", - { sku: "SKU-CB2", price: { amount: 500, currency: "EUR" } }, - "kcb2", - ); - - const res = await batch({ productIds: ["prod-cb-1", "prod-cb-2", "prod-cb-nope"] }); - - expect(res.status).toBe(200); - const items = res.body?.["items"] as Array>; - expect(items).toHaveLength(2); - const byId = new Map(items.map((i) => [i["productId"], i])); - // Money on the wire: integer minor units + ISO-4217 string, never a float. - expect(byId.get("prod-cb-1")).toEqual({ - productId: "prod-cb-1", - sku: "SKU-CB1", - price: { amount: 1999, currency: "USD" }, - inStock: true, - // Unpublished until the deferred afterPublish→activate wiring lands - // — the wire carries the flag the plugin's join gates on. - active: false, - }); - // No inventory row seeded for SKU-CB2 ⇒ coarsely out of stock, still listed. - expect(byId.get("prod-cb-2")).toEqual({ - productId: "prod-cb-2", - sku: "SKU-CB2", - price: { amount: 500, currency: "EUR" }, - inStock: false, - active: false, - }); - // The unknown id is simply ABSENT — no error entry, no 404. - expect(byId.has("prod-cb-nope")).toBe(false); - expect(JSON.stringify(res.body)).not.toMatch(/error/i); - }); - - test("inStock arrives on the batch response itself (service-side join) — a drained sku flips to false", async () => { - await putCommerce( - "prod-cb-3", - { sku: "SKU-CB3", price: { amount: 100, currency: "USD" }, initialOnHand: 1 }, - "kcb3", - ); - const before = await batch({ productIds: ["prod-cb-3"] }); - const beforeItems = (before.body?.["items"] ?? []) as Array>; - expect(beforeItems[0]?.["inStock"]).toBe(true); - - await server.seed("SKU-CB3", 0); - const after = await batch({ productIds: ["prod-cb-3"] }); - const afterItems = (after.body?.["items"] ?? []) as Array>; - expect(afterItems[0]?.["inStock"]).toBe(false); - }); - - test("soft-deleted and not-yet-priced rows are omitted, not error entries", async () => { - await putCommerce( - "prod-cb-4", - { sku: "SKU-CB4", price: { amount: 100, currency: "USD" } }, - "kcb4", - ); - await fetch(`${server.baseUrl}/products/prod-cb-4/commerce`, { - method: "DELETE", - headers: { "Idempotency-Key": "kcb4-del" }, - }); - // "Create, then price" not finished: a bare row with no sku/price yet. - await putCommerce("prod-cb-5", {}, "kcb5"); - - const res = await batch({ productIds: ["prod-cb-4", "prod-cb-5"] }); - expect(res.status).toBe(200); - expect(res.body?.["items"]).toEqual([]); - }); - - test("a request over the id cap is rejected with 400 (request-size guard, not pagination)", async () => { - const ids = Array.from({ length: COMMERCE_BATCH_ID_CAP + 1 }, (_, i) => `prod-cap-${i}`); - const res = await batch({ productIds: ids }); - expect(res.status).toBe(400); - - // Exactly AT the cap is accepted. - const atCap = await batch({ productIds: ids.slice(0, COMMERCE_BATCH_ID_CAP) }); - expect(atCap.status).toBe(200); - expect(atCap.body?.["items"]).toEqual([]); - }); - - test("an empty id list is a valid request returning zero items", async () => { - const res = await batch({ productIds: [] }); - expect(res.status).toBe(200); - expect(res.body).toEqual({ items: [] }); - }); - - test("a schema-invalid body (missing/ill-typed productIds) is a 400", async () => { - for (const bad of [ - {}, - { productIds: "prod-1" }, - { productIds: [1, 2] }, - { productIds: [""] }, - ]) { - const res = await batch(bad); - expect(res.status, JSON.stringify(bad)).toBe(400); - } - }); -}); diff --git a/packages/service/test/checkout-intent.http.pg.test.ts b/packages/service/test/checkout-intent.http.pg.test.ts deleted file mode 100644 index 9445f10a..00000000 --- a/packages/service/test/checkout-intent.http.pg.test.ts +++ /dev/null @@ -1,231 +0,0 @@ -import type { - StripeCreatePaymentIntentInput, - StripeCreatePaymentIntentResult, - StripeCreateRefundResult, - StripePreflightResult, - StripeTransport, -} from "@otta-sh/payments-stripe"; -import { afterEach, describe, expect, test } from "vitest"; -import { startTestServer, type TestServer } from "./helpers/start-test-server.js"; - -// The live-intent path over HTTP (§8 step 4.8 style): a secretKey-configured -// server drives the transport seam, so `POST /checkout/orders` returns STRIPE's -// intent id + client secret — and a transport failure surfaces as a 502 -// PAYMENT_INTENT_FAILED with the order left pending (healed by expireOrders). - -const PG = process.env.PG_CONNECTION_STRING; - -/** Records the create-intent input and plays a scripted result. */ -class RecordingTransport implements StripeTransport { - result: StripeCreatePaymentIntentResult = { - ok: true, - intentId: "pi_live_http", - clientSecret: "pi_live_http_secret_abc", - }; - readonly intents: StripeCreatePaymentIntentInput[] = []; - - async readRefundedAmount(): Promise { - return { ok: true, view: { amountRefunded: 0, amountCaptured: 0, currency: "usd" } }; - } - async createRefund(): Promise { - return { ok: true, refundId: "re_x", amountCents: 0, currency: "usd" }; - } - async createPaymentIntent( - input: StripeCreatePaymentIntentInput, - ): Promise { - this.intents.push(input); - return this.result; - } -} - -async function json(res: Response): Promise> { - return (await res.json()) as Record; -} - -describe.skipIf(PG === undefined)("checkout → live Stripe createIntent (HTTP)", () => { - let server: TestServer; - afterEach(async () => { - await server.stop(); - }); - - /** Seeds a product, builds a one-line cart and checks it out. Returns the - * raw response AND the cart id, so a test can read the cart back afterwards. */ - async function checkout( - s: TestServer, - opts: { - priceCents?: number; - idempotencyKey?: string; - shippingAddress?: Record; - } = {}, - ): Promise<{ res: Response; cartId: string }> { - const suffix = Math.random().toString(36).slice(2, 8); - const sku = `SKU-${suffix}`; - await s.seedProduct({ - productId: `p-${suffix}`, - sku, - priceCents: opts.priceCents ?? 2500, - title: "Widget", - kind: "physical", - onHand: 5, - }); - const cart = await json( - await fetch(`${s.baseUrl}/carts`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ currency: "USD" }), - }), - ); - const cartId = cart["cartId"] as string; - const addRes = await fetch(`${s.baseUrl}/carts/${cartId}/lines`, { - method: "POST", - headers: { "Content-Type": "application/json", "Idempotency-Key": `add-${cartId}` }, - body: JSON.stringify({ sku, qty: 1, productId: `p-${suffix}` }), - }); - expect(addRes.status).toBe(200); - const res = await fetch(`${s.baseUrl}/checkout/orders`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "Idempotency-Key": opts.idempotencyKey ?? `co-${cartId}`, - }, - body: JSON.stringify({ - cartId, - paymentMethod: "stripe", - buyerRef: "buyer@example.com", - ...(opts.shippingAddress !== undefined ? { shippingAddress: opts.shippingAddress } : {}), - }), - }); - return { res, cartId }; - } - - test("a secretKey-configured server returns STRIPE's real intentId + clientSecret", async () => { - const transport = new RecordingTransport(); - server = await startTestServer({ stripeSecretKey: "sk_test_http", stripeTransport: transport }); - const { res } = await checkout(server); - expect(res.status).toBe(201); - const body = await json(res); - expect(body["intent"]).toEqual({ - gateway: "stripe", - intentId: "pi_live_http", - clientAction: { kind: "stripe_client_secret", clientSecret: "pi_live_http_secret_abc" }, - }); - }); - - test("the transport receives metadata order_id = the created order id, the total in minor units, and the request's Idempotency-Key", async () => { - const transport = new RecordingTransport(); - server = await startTestServer({ stripeSecretKey: "sk_test_http", stripeTransport: transport }); - const { res } = await checkout(server, { priceCents: 1234, idempotencyKey: "idem-live-1" }); - expect(res.status).toBe(201); - const order = (await json(res))["order"] as Record; - expect(transport.intents).toHaveLength(1); - expect(transport.intents[0]).toEqual({ - orderId: order["id"], - amountCents: 1234, - currency: "usd", - idempotencyKey: "idem-live-1", - secretKey: "sk_test_http", - // The India-export description, rendered from the ORDER's snapshotted - // title — a card payment against an India-based account is refused - // without it. No ship-to was submitted ⇒ no `shipping` key at all. - description: "1 × Widget", - }); - }); - - test("a submitted ship-to reaches Stripe as `shipping` (India requires it alongside the description for goods)", async () => { - const transport = new RecordingTransport(); - server = await startTestServer({ stripeSecretKey: "sk_test_http", stripeTransport: transport }); - const { res } = await checkout(server, { - idempotencyKey: "idem-live-ship", - shippingAddress: { - name: "Jenny Rosen", - line1: "510 Townsend St", - city: "San Francisco", - region: "CA", - postalCode: "94103", - country: "US", - email: "jenny@example.com", - phone: "+1-415-555-0100", - }, - }); - expect(res.status).toBe(201); - expect(transport.intents[0]?.shipping).toEqual({ - name: "Jenny Rosen", - line1: "510 Townsend St", - city: "San Francisco", - state: "CA", - postalCode: "94103", - country: "US", - }); - // PII minimization: the buyer's contact channels never cross the boundary. - const serialized = JSON.stringify(transport.intents[0]); - expect(serialized).not.toContain("jenny@example.com"); - expect(serialized).not.toContain("555-0100"); - }); - - test("a retryable intent failure ⇒ 502 PAYMENT_INTENT_FAILED, and the order is still pending", async () => { - const transport = new RecordingTransport(); - transport.result = { ok: false, class: "retryable", status: 503 }; - server = await startTestServer({ stripeSecretKey: "sk_test_http", stripeTransport: transport }); - const { res } = await checkout(server, { idempotencyKey: "idem-live-fail" }); - expect(res.status).toBe(502); - expect(await json(res)).toEqual({ ok: false, reason: "PAYMENT_INTENT_FAILED" }); - - // The pending order row survives — a same-key retry re-honors it. - transport.result = { ok: true, intentId: "pi_retry", clientSecret: "pi_retry_secret" }; - const retry = await fetch(`${server.baseUrl}/checkout/orders`, { - method: "POST", - headers: { "Content-Type": "application/json", "Idempotency-Key": "idem-live-fail" }, - body: JSON.stringify({ - cartId: "nonexistent-cart-id", - paymentMethod: "stripe", - buyerRef: "buyer@example.com", - }), - }); - expect(retry.status, "the I1 replay short-circuits on the key, not the cart").toBe(201); - // The I1 REPLAY re-issues the intent through the OTHER call site. Its body - // must be byte-identical to the first attempt's — Stripe rejects a same-key - // retry whose payload drifted — which holds because both sites render the - // order's purchase-time line SNAPSHOT. - expect(transport.intents).toHaveLength(2); - expect(transport.intents[1]?.description).toBe(transport.intents[0]?.description); - expect(transport.intents[1]?.description).toBe("1 × Widget"); - const order = (await json(retry))["order"] as Record; - const fetched = await json(await fetch(`${server.baseUrl}/orders/${String(order["id"])}`)); - expect((fetched["order"] as Record)["state"]).toBe("pending"); - }); - - test("the checked-out cart names the order it became (#132)", async () => { - server = await startTestServer(); - const { res, cartId } = await checkout(server); - expect(res.status).toBe(201); - const order = (await json(res))["order"] as Record; - - const read = await fetch(`${server.baseUrl}/carts/${cartId}`); - expect(read.status).toBe(200); - const cart = (await json(read))["cart"] as Record; - // The pair, over the real wire: terminal AND stamped, from one statement. - expect({ state: cart["state"], orderId: cart["orderId"] }).toEqual({ - state: "checked_out", - orderId: order["id"], - }); - // The stamp precedes the payment intent, so it is emphatically NOT a - // payment signal: this order is still `pending`. - expect(order["state"]).toBe("pending"); - }); - - test("the DEFAULT (no secretKey) server still returns the deterministic pi_ handle", async () => { - server = await startTestServer(); - const { res } = await checkout(server); - expect(res.status).toBe(201); - const body = await json(res); - const order = body["order"] as Record; - expect(body["intent"]).toEqual({ - gateway: "stripe", - intentId: `pi_${String(order["id"])}`, - clientAction: { - kind: "stripe_client_secret", - clientSecret: expect.stringContaining(`pi_${String(order["id"])}_secret_`), - }, - }); - }); -}); diff --git a/packages/service/test/checkout-quote.http.contract.pg.test.ts b/packages/service/test/checkout-quote.http.contract.pg.test.ts deleted file mode 100644 index 1d17a41d..00000000 --- a/packages/service/test/checkout-quote.http.contract.pg.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test } from "vitest"; -import { startTestServer, type TestServer } from "./helpers/start-test-server.js"; - -// Phase 6 HTTP contract (§6 DoD): /checkout/quote against a LIVE server backed by -// Postgres. Proves the wire format matches the port and that the preview does NOT -// redeem the coupon (read-only, safe to repeat). - -const PG = process.env.PG_CONNECTION_STRING; - -async function json(res: Response): Promise> { - return (await res.json()) as Record; -} - -describe.skipIf(PG === undefined)("checkout quote HTTP contract", () => { - let server: TestServer; - let token: string; - beforeEach(async () => { - server = await startTestServer(); - token = server.internalToken as string; - }); - afterEach(async () => { - await server.stop(); - }); - - async function admin(path: string, body: unknown): Promise { - return fetch(`${server.baseUrl}/admin${path}`, { - method: "POST", - headers: { "content-type": "application/json", "X-Internal-Token": token }, - body: JSON.stringify(body), - }); - } - - async function seedRulesAndCart(): Promise { - // Product $10 physical, stock 10. - await server.seedProduct({ - productId: "p1", - sku: "SKU-1", - priceCents: 1000, - title: "Widget", - kind: "physical", - onHand: 10, - }); - // Shipping: US zone, flat $5.99. - await admin("/shipping/zones", { id: "z-us", name: "US" }); - await admin("/shipping/zones/z-us/methods", { id: "m-flat", name: "Flat", type: "flat_rate" }); - await admin("/shipping/methods/m-flat/rates", { currency: "USD", amountCents: 599 }); - // Tax: standard 10% in z-us. - await admin("/tax/classes", { id: "standard", name: "Standard" }); - await admin("/tax/rates", { id: "t1", taxClassId: "standard", zoneId: "z-us", rateBps: 1000 }); - // Coupon: $5 fixed off. - await admin("/coupons", { - id: "cpn", - code: "SAVE5", - type: "fixed_amount", - amountCents: 500, - currency: "USD", - maxUses: 100, - }); - - // Cart with 2 units. - const createCart = await fetch(`${server.baseUrl}/carts`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ currency: "USD" }), - }); - const cartId = (await json(createCart)).cartId as string; - await fetch(`${server.baseUrl}/carts/${cartId}/lines`, { - method: "POST", - headers: { "content-type": "application/json", "Idempotency-Key": "add-1" }, - body: JSON.stringify({ sku: "SKU-1", qty: 2, productId: "p1" }), - }); - return cartId; - } - - test("quote computes coupon/shipping/tax breakdown and matches computeTotals", async () => { - const cartId = await seedRulesAndCart(); - const res = await fetch(`${server.baseUrl}/checkout/quote`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - cartId, - shippingZoneId: "z-us", - shippingMethodId: "m-flat", - couponCode: "SAVE5", - }), - }); - expect(res.status).toBe(200); - const body = await json(res); - expect(body.ok).toBe(true); - const b = body.breakdown as Record; - // subtotal 2000; -500 coupon ⇒ 1500 discounted; +599 shipping; +150 tax (1500×10%). - expect(b.subtotalCents).toBe(2000); - expect(b.discountCents).toBe(500); - expect(b.shippingCents).toBe(599); - expect(b.taxCents).toBe(150); - expect(b.totalCents).toBe(1500 + 599 + 150); - expect(b.appliedCouponCode).toBe("SAVE5"); - }); - - test("quote is read-only: repeated calls do NOT redeem the coupon (uses_count stays 0)", async () => { - const cartId = await seedRulesAndCart(); - const q = () => - fetch(`${server.baseUrl}/checkout/quote`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ cartId, shippingMethodId: "m-flat", couponCode: "SAVE5" }), - }); - await q(); - await q(); - await q(); - // Read the coupon back — no redemption happened. - const coupon = await json( - await fetch(`${server.baseUrl}/admin/coupons/SAVE5`, { - headers: { "X-Internal-Token": token }, - }), - ); - expect((coupon.coupon as Record).usesCount).toBe(0); - }); - - test("unknown coupon code ⇒ 404 COUPON_NOT_FOUND", async () => { - const cartId = await seedRulesAndCart(); - const res = await fetch(`${server.baseUrl}/checkout/quote`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ cartId, shippingMethodId: "m-flat", couponCode: "NOPE" }), - }); - expect(res.status).toBe(404); - expect((await json(res)).reason).toBe("COUPON_NOT_FOUND"); - }); -}); diff --git a/packages/service/test/config.test.ts b/packages/service/test/config.test.ts deleted file mode 100644 index 4e80ff50..00000000 --- a/packages/service/test/config.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { openWriteGateWarning, parseHoldTtlMs, resolveServiceConfig } from "../src/config.js"; - -// Pure env-parsing unit tests (no DB, no server) — the exact semantics the bin -// entry (`index.ts`) has always had, now extracted so the Worker entry shares -// them (D4). -describe("parseHoldTtlMs", () => { - test("undefined stays undefined (the domain default applies downstream)", () => { - expect(parseHoldTtlMs(undefined)).toBeUndefined(); - }); - - test("a valid numeric string parses to a number", () => { - expect(parseHoldTtlMs("900000")).toBe(900_000); - expect(parseHoldTtlMs("1")).toBe(1); - }); - - test.each(["0", "-5", "abc", ""])('invalid value "%s" throws naming CART_HOLD_TTL_MS', (raw) => { - expect(() => parseHoldTtlMs(raw)).toThrowError(/CART_HOLD_TTL_MS must be a positive number/); - }); -}); - -describe("resolveServiceConfig", () => { - test("empty env resolves to all-undefined (defaults apply, endpoints disabled)", () => { - expect(resolveServiceConfig({})).toEqual({ - ttlMs: undefined, - internalToken: undefined, - serviceToken: undefined, - }); - }); - - test("passes INTERNAL_API_TOKEN through verbatim (empty string included — the route layer decides)", () => { - expect(resolveServiceConfig({ INTERNAL_API_TOKEN: "secret" }).internalToken).toBe("secret"); - expect(resolveServiceConfig({ INTERNAL_API_TOKEN: "" }).internalToken).toBe(""); - }); - - test("passes SERVICE_API_TOKEN through verbatim", () => { - expect(resolveServiceConfig({ SERVICE_API_TOKEN: "svc-token" }).serviceToken).toBe("svc-token"); - expect(resolveServiceConfig({}).serviceToken).toBeUndefined(); - }); - - test("parses CART_HOLD_TTL_MS and rethrows its validation error", () => { - expect(resolveServiceConfig({ CART_HOLD_TTL_MS: "60000" }).ttlMs).toBe(60_000); - expect(() => resolveServiceConfig({ CART_HOLD_TTL_MS: "nope" })).toThrowError( - /CART_HOLD_TTL_MS/, - ); - }); -}); - -// #42 — the shared open-write-gate warning builder. The gate-open condition -// (unset OR empty) mirrors `requireServiceToken` in src/auth.ts; both entries -// call this with their own remedy string. -describe("openWriteGateWarning", () => { - const remedy = "Do the thing."; - - test("an UNSET token warns, names SERVICE_API_TOKEN, says OPEN, and ends with the remedy", () => { - const warning = openWriteGateWarning(undefined, remedy); - expect(warning).toContain("SERVICE_API_TOKEN"); - expect(warning).toContain("OPEN"); - expect(warning?.endsWith(remedy)).toBe(true); - }); - - test("an EMPTY token warns too (empty opens the gate, matching requireServiceToken)", () => { - expect(openWriteGateWarning("", remedy)).toContain("SERVICE_API_TOKEN"); - }); - - test("a SET token never warns (returns undefined)", () => { - expect(openWriteGateWarning("svc-token", remedy)).toBeUndefined(); - }); -}); diff --git a/packages/service/test/customers.http.contract.pg.test.ts b/packages/service/test/customers.http.contract.pg.test.ts deleted file mode 100644 index 465fc69d..00000000 --- a/packages/service/test/customers.http.contract.pg.test.ts +++ /dev/null @@ -1,314 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test } from "vitest"; -import { startTestServer, type TestServer } from "./helpers/start-test-server.js"; - -// Phase 5 HTTP contract (§8 5.6): the new customer/auth/admin surface exercised -// against a LIVE server backed by Postgres. Proves own-orders isolation, the -// magic-link flow, address scoping, admin transitions, and the extended -// expire-orders email — all at the wire level. Postgres-required. - -const PG = process.env.PG_CONNECTION_STRING; - -async function json(res: Response): Promise> { - return (await res.json()) as Record; -} - -function authed(token: string): { Authorization: string } { - return { Authorization: `Bearer ${token}` }; -} - -describe.skipIf(PG === undefined)("customers + auth + admin HTTP contract", () => { - let server: TestServer; - beforeEach(async () => { - server = await startTestServer(); - }); - afterEach(async () => { - await server.stop(); - }); - - function lastLoginToken(): { challengeId: string; token: string } { - const sends = server.emailSender.sends.filter((s) => s.template === "customer-login-link"); - const last = sends[sends.length - 1]!; - return { challengeId: last.data["challengeId"] as string, token: last.data["token"] as string }; - } - - /** Full magic-link login over the wire → returns the bearer session token. */ - async function login(email: string): Promise { - const reqRes = await fetch(`${server.baseUrl}/auth/login/request`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email }), - }); - expect(reqRes.status).toBe(200); - const { challengeId, token } = lastLoginToken(); - const verifyRes = await fetch(`${server.baseUrl}/auth/login/verify`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ challengeId, token }), - }); - expect(verifyRes.status).toBe(200); - return (await json(verifyRes))["sessionToken"] as string; - } - - /** Seed + check out a one-line physical order under `buyerRef`. */ - async function createGuestOrder(input: { - email: string; - sku: string; - productId: string; - }): Promise { - await server.seedProduct({ - productId: input.productId, - sku: input.sku, - priceCents: 1500, - title: "Item", - kind: "physical", - onHand: 5, - }); - const cart = await json( - await fetch(`${server.baseUrl}/carts`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ currency: "USD" }), - }), - ); - const cartId = cart["cartId"] as string; - await fetch(`${server.baseUrl}/carts/${cartId}/lines`, { - method: "POST", - headers: { "Content-Type": "application/json", "Idempotency-Key": `add-${cartId}` }, - body: JSON.stringify({ sku: input.sku, qty: 1, productId: input.productId }), - }); - const coRes = await fetch(`${server.baseUrl}/checkout/orders`, { - method: "POST", - headers: { "Content-Type": "application/json", "Idempotency-Key": `co-${cartId}` }, - body: JSON.stringify({ cartId, paymentMethod: "stripe", buyerRef: input.email }), - }); - expect(coRes.status).toBe(201); - return ((await json(coRes))["order"] as Record)["id"] as string; - } - - test("a customer sees only their own orders; a foreign order id returns 404 (not 403)", async () => { - const orderA = await createGuestOrder({ - email: "a@example.com", - sku: "SKU-A", - productId: "pa", - }); - const orderB = await createGuestOrder({ - email: "b@example.com", - sku: "SKU-B", - productId: "pb", - }); - const tokenA = await login("a@example.com"); - await login("b@example.com"); // links orderB to B - - const mine = await json( - await fetch(`${server.baseUrl}/me/orders`, { headers: authed(tokenA) }), - ); - const orders = mine["orders"] as Array<{ id: string }>; - expect(orders.map((o) => o.id)).toEqual([orderA]); - - // B's order by id, as A → 404 NOT_FOUND (existence not leaked as 403). - const foreign = await fetch(`${server.baseUrl}/me/orders/${orderB}`, { - headers: authed(tokenA), - }); - expect(foreign.status).toBe(404); - // A's own order by id → 200. - const own = await fetch(`${server.baseUrl}/me/orders/${orderA}`, { headers: authed(tokenA) }); - expect(own.status).toBe(200); - }); - - test("the /me surface rejects an unauthenticated request with 401", async () => { - expect((await fetch(`${server.baseUrl}/me/orders`)).status).toBe(401); - expect((await fetch(`${server.baseUrl}/me`)).status).toBe(401); - expect((await fetch(`${server.baseUrl}/me/addresses`)).status).toBe(401); - }); - - test("POST /auth/login/request sends exactly one login email and returns a generic response", async () => { - const res = await fetch(`${server.baseUrl}/auth/login/request`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email: "solo@example.com" }), - }); - expect(res.status).toBe(200); - const body = await json(res); - expect(body["ok"]).toBe(true); - expect(String(body["message"])).toMatch(/if an account exists/i); - expect(server.emailSender.countByTemplate("customer-login-link")).toBe(1); - }); - - test("rapid login requests for one email are rate-limited: no extra email/challenge, response indistinguishable (H1)", async () => { - const bodies: string[] = []; - for (let i = 0; i < 5; i++) { - const res = await fetch(`${server.baseUrl}/auth/login/request`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email: "bomb@example.com" }), - }); - expect(res.status).toBe(200); - bodies.push(await res.text()); - } - // The throttled responses are byte-identical to the issued ones — the - // limiter is not an oracle (§9 Risk 4). - expect(new Set(bodies).size).toBe(1); - // Only the capped number of emails ever went out (default cap: 3); a - // different address is unaffected. - expect(server.emailSender.countByTemplate("customer-login-link")).toBe(3); - await fetch(`${server.baseUrl}/auth/login/request`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email: "someone-else@example.com" }), - }); - expect(server.emailSender.countByTemplate("customer-login-link")).toBe(4); - }); - - test("the internal maintenance tick prunes consumed/expired login challenges (H1)", async () => { - // A completed login leaves exactly one consumed challenge behind. - await login("prune@example.com"); - const res = await fetch(`${server.baseUrl}/internal/dispatch-emails`, { - method: "POST", - headers: { "X-Internal-Token": server.internalToken! }, - }); - expect(res.status).toBe(200); - expect((await json(res))["prunedChallenges"]).toBe(1); - // Idempotent: a second tick finds nothing left to prune. - const again = await fetch(`${server.baseUrl}/internal/dispatch-emails`, { - method: "POST", - headers: { "X-Internal-Token": server.internalToken! }, - }); - expect((await json(again))["prunedChallenges"]).toBe(0); - }); - - test("POST /auth/login/verify with a stale (consumed) challenge returns 401, not customer detail", async () => { - await fetch(`${server.baseUrl}/auth/login/request`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email: "stale@example.com" }), - }); - const { challengeId, token } = lastLoginToken(); - const first = await fetch(`${server.baseUrl}/auth/login/verify`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ challengeId, token }), - }); - expect(first.status).toBe(200); - const replay = await fetch(`${server.baseUrl}/auth/login/verify`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ challengeId, token }), - }); - expect(replay.status).toBe(401); - const body = await json(replay); - expect(body["sessionToken"]).toBeUndefined(); - expect(body["reason"]).toBe("CONSUMED"); - }); - - test("the address book is scoped to the authenticated customer", async () => { - const tokenA = await login("addr-a@example.com"); - const tokenB = await login("addr-b@example.com"); - const created = await json( - await fetch(`${server.baseUrl}/me/addresses`, { - method: "POST", - headers: { "Content-Type": "application/json", ...authed(tokenA) }, - body: JSON.stringify({ - kind: "shipping", - name: "A", - line1: "1 A St", - city: "Town", - postalCode: "0001", - country: "US", - }), - }), - ); - const addressId = (created["address"] as { id: string }).id; - - // B never sees A's address. - const bList = await json( - await fetch(`${server.baseUrl}/me/addresses`, { headers: authed(tokenB) }), - ); - expect(bList["addresses"]).toEqual([]); - // B cannot delete A's address (scoped → 404). - const bDelete = await fetch(`${server.baseUrl}/me/addresses/${addressId}`, { - method: "DELETE", - headers: authed(tokenB), - }); - expect(bDelete.status).toBe(404); - // A sees exactly their own. - const aList = await json( - await fetch(`${server.baseUrl}/me/addresses`, { headers: authed(tokenA) }), - ); - expect((aList["addresses"] as unknown[]).length).toBe(1); - }); - - test("admin transition paid→processing succeeds and enqueues exactly one processing email; an illegal transition is 409", async () => { - const orderId = await createGuestOrder({ - email: "adm@example.com", - sku: "SKU-ADM", - productId: "padm", - }); - // Move it to paid via the admin endpoint first (pending → paid is legal). - const toPaid = await fetch(`${server.baseUrl}/admin/orders/${orderId}/transition`, { - method: "POST", - headers: { "Content-Type": "application/json", "X-Internal-Token": server.internalToken! }, - body: JSON.stringify({ toState: "paid" }), - }); - expect(toPaid.status).toBe(200); - const toProcessing = await fetch(`${server.baseUrl}/admin/orders/${orderId}/transition`, { - method: "POST", - headers: { "Content-Type": "application/json", "X-Internal-Token": server.internalToken! }, - body: JSON.stringify({ toState: "processing" }), - }); - expect(toProcessing.status).toBe(200); - - // Illegal from processing (paid→pending equivalent): processing → paid. - const illegal = await fetch(`${server.baseUrl}/admin/orders/${orderId}/transition`, { - method: "POST", - headers: { "Content-Type": "application/json", "X-Internal-Token": server.internalToken! }, - body: JSON.stringify({ toState: "paid" }), - }); - expect(illegal.status).toBe(409); - - // Unauthorized without the internal token → 401. - const noAuth = await fetch(`${server.baseUrl}/admin/orders/${orderId}/transition`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ toState: "shipped" }), - }); - expect(noAuth.status).toBe(401); - - // Dispatch and assert exactly one processing email (confirmation also queued). - const dispatch = await fetch(`${server.baseUrl}/internal/dispatch-emails`, { - method: "POST", - headers: { "X-Internal-Token": server.internalToken! }, - }); - expect(dispatch.status).toBe(200); - expect(server.emailSender.countByTemplate("order-processing", orderId)).toBe(1); - expect(server.emailSender.countByTemplate("order-confirmation", orderId)).toBe(1); - }); - - test("the expire-orders sweep transitions pending→expired, releases the reservation once, and enqueues exactly one order-expired email", async () => { - const orderId = await createGuestOrder({ - email: "exp@example.com", - sku: "SKU-EXP", - productId: "pexp", - }); - expect(await server.onHand("SKU-EXP")).toBe(4); // 5 seeded − 1 reserved at checkout - - server.advance(31 * 60 * 1000); // past the checkout hold TTL - const expireRes = await fetch(`${server.baseUrl}/internal/expire-orders`, { - method: "POST", - headers: { "X-Internal-Token": server.internalToken! }, - }); - expect(expireRes.status).toBe(200); - expect((await json(expireRes))["expired"]).toBe(1); - - // Reservation released exactly once (Phase-4 behavior unchanged). - expect(await server.onHand("SKU-EXP")).toBe(5); - const order = await json(await fetch(`${server.baseUrl}/orders/${orderId}`)); - expect((order["order"] as Record)["state"]).toBe("expired"); - - // Exactly one order-expired email after dispatch. - await fetch(`${server.baseUrl}/internal/dispatch-emails`, { - method: "POST", - headers: { "X-Internal-Token": server.internalToken! }, - }); - expect(server.emailSender.countByTemplate("order-expired", orderId)).toBe(1); - }); -}); diff --git a/packages/service/test/edit-product-schema.test.ts b/packages/service/test/edit-product-schema.test.ts deleted file mode 100644 index d6e4ad25..00000000 --- a/packages/service/test/edit-product-schema.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { editProductCommerceBody, upsertProductCommerceBody } from "../src/schemas.js"; - -// ADR-0013 rung 3, in the FAST LOOP. The HTTP half of this guard lives in -// `admin-product-edit-http.test.ts`, which is `describe.skipIf(PG === undefined)` -// — so under a bare `pnpm test` it does not run at all, and the ladder's whole -// thesis is defence in depth. These cases need no server and no database, so -// they fire on every local run: if someone deletes `.strict()` from -// `editProductCommerceBody`, this file goes red immediately rather than waiting -// for CI's integration job. -// -// What is NOT asserted here, and must stay in the HTTP test: that the STORED -// title is unchanged. A schema test cannot see a database, and "rejected" vs -// "silently stripped" is only distinguishable by reading the row back. - -const WATERMARK = "2026-07-10T01:00:00.000Z"; - -describe("editProductCommerceBody is strict, and title is not editable (ADR-0013)", () => { - test("REJECTS a body carrying `title`, and the issue NAMES the field", () => { - const res = editProductCommerceBody.safeParse({ - expectedUpdatedAt: WATERMARK, - title: "Renamed from a stale client", - }); - - expect(res.success).toBe(false); - if (res.success) throw new Error("unreachable"); - const unrecognized = res.error.issues.find((i) => i.code === "unrecognized_keys"); - expect(unrecognized).toBeDefined(); - expect(JSON.stringify(unrecognized)).toContain("title"); - }); - - test("does not merely STRIP `title` — the schema must fail, not quietly succeed", () => { - // The regression this pins: without `.strict()`, zod's default object - // behaviour drops the key and returns `success: true`, so a merchant's - // rename vanishes behind a 200. Asserting `success === false` is the only - // thing that tells the two apart at this layer. - const res = editProductCommerceBody.safeParse({ - expectedUpdatedAt: WATERMARK, - price: { amount: 2599, currency: "USD" }, - title: "Renamed from a stale client", - }); - expect(res.success).toBe(false); - }); - - test("any unknown key is rejected, not just `title` — the guard is general", () => { - const res = editProductCommerceBody.safeParse({ - expectedUpdatedAt: WATERMARK, - active: true, - contentUpdatedAt: WATERMARK, - }); - expect(res.success).toBe(false); - }); - - test("a legitimate commerce-owned edit still parses", () => { - const res = editProductCommerceBody.safeParse({ - expectedUpdatedAt: WATERMARK, - sku: "SKU-1", - price: { amount: 2599, currency: "USD" }, - taxClass: "reduced", - productKind: "physical", - inventoryPolicy: "deny", - }); - expect(res.success).toBe(true); - }); -}); - -describe("upsertProductCommerceBody keeps title and is deliberately NOT strict (the asymmetry)", () => { - test("accepts `title` — the CMS content sync's one sanctioned channel", () => { - const res = upsertProductCommerceBody.safeParse({ title: "Renamed by the CMS" }); - expect(res.success).toBe(true); - if (!res.success) throw new Error("unreachable"); - expect(res.data.title).toBe("Renamed by the CMS"); - }); - - test("tolerates an unknown key rather than 400ing an integrator", () => { - // Pins the asymmetry itself, so "tidying up" the two schemas to match - // breaks a test instead of silently changing the integrator contract. - const res = upsertProductCommerceBody.safeParse({ - title: "Renamed by the CMS", - somethingAnIntegratorSent: 1, - }); - expect(res.success).toBe(true); - }); -}); diff --git a/packages/service/test/entitlements-check-auth.app.test.ts b/packages/service/test/entitlements-check-auth.app.test.ts deleted file mode 100644 index bd5788bb..00000000 --- a/packages/service/test/entitlements-check-auth.app.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { - CountingIdGen, - FakeEmailSender, - FixedClock, - InMemoryAddressStore, - InMemoryCartStore, - InMemoryCouponStore, - InMemoryCredentialVerifier, - InMemoryCustomerStore, - InMemoryEntitlementStore, - InMemoryInventoryStore, - InMemoryOrderNotesStore, - InMemoryOrderStore, - InMemoryPaymentEventStore, - InMemoryProductCommerceStore, - InMemoryReportingStore, - InMemorySessionStore, - InMemorySettingsStore, - InMemoryShippingRulesStore, - InMemoryTaxRulesStore, -} from "@otta-sh/domain/testing"; -import { StripePaymentGateway } from "@otta-sh/payments-stripe"; -import type { Hono } from "hono"; -import { describe, expect, test } from "vitest"; -import { createApp } from "../src/app.js"; - -// Issue #33 (ADR-0011): GET /entitlements/check auth-precedence branches that -// need NO entitlement row to exercise — the reviewer flagged that the pg -// contract suite's cases 1, 2, 9, 10, 11, 14 (`entitlements-check-auth.http. -// contract.pg.test.ts`) never actually depend on a paid order or a granted -// entitlement: each returns 400/401/503 from auth/schema checks BEFORE the -// route ever reaches `entitlementStore.check`. Duplicated here at the -// `app.request()` level (IO-free in-memory stores, no server, no PG — same -// harness as `service-token.test.ts`) so plain `pnpm test` exercises this -// precedence logic without the Postgres gate. The PG suite remains the -// source of truth for the full 17-case matrix, including the entitled cases. - -function makeApp(options: { internalToken?: string } = {}): { app: Hono } { - const clock = new FixedClock(new Date("2026-07-14T00:00:00.000Z")); - const inventory = new InMemoryInventoryStore({ idGen: new CountingIdGen("res"), clock }); - const cartStore = new InMemoryCartStore({ - idGen: new CountingIdGen("cart"), - reservationState: (id) => { - try { - return inventory.reservationState(id); - } catch { - return undefined; - } - }, - releaseHold: (id) => { - void inventory.release(id); - }, - }); - const productCommerce = new InMemoryProductCommerceStore({ - clock, - // NOTE: `InMemoryInventoryStore.onHand` returns 0 for an unseeded sku, so - // this wiring COLLAPSES null -> 0. Fine for the coarse `inStock` boolean - // these suites exercise; do NOT assert the products-list `onHand` - // projection through it (the list must distinguish "no inventory row" - // from "out of stock" — see the divergence note in - // `packages/domain/src/ports/inventory-store.ts`'s `getOnHand` doc). - inventoryOnHand: (s) => inventory.onHand(s), - }); - const idGen = new CountingIdGen("id"); - const customerStore = new InMemoryCustomerStore({ idGen, clock }); - const app = createApp({ - store: inventory, - productCommerce, - cartStore, - orderStore: new InMemoryOrderStore({ idGen, clock }), - orderNotesStore: new InMemoryOrderNotesStore({ idGen, clock }), - entitlementStore: new InMemoryEntitlementStore({ idGen, clock }), - paymentEventStore: new InMemoryPaymentEventStore(), - shippingRules: new InMemoryShippingRulesStore(), - taxRules: new InMemoryTaxRulesStore(), - couponStore: new InMemoryCouponStore({ idGen, clock }), - reportingStore: new InMemoryReportingStore(), - settingsStore: new InMemorySettingsStore(), - customerStore, - addressStore: new InMemoryAddressStore({ idGen, clock }), - sessionStore: new InMemorySessionStore({ idGen, clock }), - credentialVerifier: new InMemoryCredentialVerifier({ customerStore, idGen, clock }), - emailSender: new FakeEmailSender(), - idGen, - gateways: { stripe: new StripePaymentGateway({ webhookSecret: "whsec_gate_test", clock }) }, - clock, - internalToken: options.internalToken, - }); - return { app }; -} - -function check(app: Hono, query: Record, headers: Record = {}) { - const qs = new URLSearchParams(query).toString(); - return app.request(`/entitlements/check?${qs}`, { headers }); -} - -describe("GET /entitlements/check auth precedence — no-entitlement-row cases (no PG required)", () => { - test("1. buyerRef scope, no credentials → 401 (oracle closed, no `active`)", async () => { - const { app } = makeApp({ internalToken: "int-secret" }); - const res = await check(app, { buyerRef: "buyer@example.com", sku: "DIG-1" }); - expect(res.status).toBe(401); - const body = await res.json(); - expect(body).toEqual({ ok: false, error: "unauthorized" }); - expect(body).not.toHaveProperty("active"); - }); - - test("2. buyerRef scope, wrong X-Internal-Token → 401", async () => { - const { app } = makeApp({ internalToken: "int-secret" }); - const res = await check( - app, - { buyerRef: "buyer@example.com", sku: "DIG-1" }, - { "X-Internal-Token": "not-the-token" }, - ); - expect(res.status).toBe(401); - }); - - test("4 (fingerprint, kept as-is). buyerRef scope on a server with internalToken DISABLED → 503 (never silently open)", async () => { - const { app } = makeApp(); // internalToken unset - const res = await check(app, { buyerRef: "buyer@example.com", sku: "DIG-1" }); - expect(res.status).toBe(503); - const body = await res.json(); - expect(body).not.toHaveProperty("active"); - }); - - test("9. sku-only + valid X-Internal-Token, no Bearer → 401 (the token gates buyerRef, it is not a scope)", async () => { - const { app } = makeApp({ internalToken: "int-secret" }); - const res = await check(app, { sku: "DIG-1" }, { "X-Internal-Token": "int-secret" }); - expect(res.status).toBe(401); - }); - - test("10. sku missing → 400 (schema)", async () => { - const { app } = makeApp({ internalToken: "int-secret" }); - const res = await check( - app, - { buyerRef: "buyer@example.com" }, - { "X-Internal-Token": "int-secret" }, - ); - expect(res.status).toBe(400); - }); - - test("11. no scope, no credentials → 401", async () => { - const { app } = makeApp({ internalToken: "int-secret" }); - const res = await check(app, { sku: "DIG-1" }); - expect(res.status).toBe(401); - }); - - test("14. session scope: garbage bearer token → 401", async () => { - const { app } = makeApp({ internalToken: "int-secret" }); - const res = await check(app, { sku: "DIG-1" }, { Authorization: "Bearer not-a-real-token" }); - expect(res.status).toBe(401); - }); -}); diff --git a/packages/service/test/entitlements-check-auth.http.contract.pg.test.ts b/packages/service/test/entitlements-check-auth.http.contract.pg.test.ts deleted file mode 100644 index 3df268c2..00000000 --- a/packages/service/test/entitlements-check-auth.http.contract.pg.test.ts +++ /dev/null @@ -1,287 +0,0 @@ -import { signStripeWebhook } from "@otta-sh/payments-stripe"; -import { afterEach, beforeEach, describe, expect, test } from "vitest"; -import { - STRIPE_WEBHOOK_SECRET, - startTestServer, - type TestServer, -} from "./helpers/start-test-server.js"; - -// Issue #33 (ADR-0011): GET /entitlements/check is no longer an unauthenticated -// existence oracle over email. Presence-based precedence, exercised at the wire -// level against a LIVE Postgres-backed server: -// 1. buyerRef present anywhere ⇒ X-Internal-Token required (else 401; 503 if -// the token is unconfigured — never silently open) -// 2. else orderId present ⇒ open bearer capability (unguessable order id) -// 3. else valid session Bearer ⇒ session scope (email derived server-side) -// 4. else ⇒ 401 -// Postgres-required (the grant flow runs through the real Stripe webhook). - -const PG = process.env.PG_CONNECTION_STRING; - -async function json(res: Response): Promise> { - return (await res.json()) as Record; -} - -describe.skipIf(PG === undefined)("entitlements/check auth HTTP contract", () => { - let server: TestServer; - beforeEach(async () => { - server = await startTestServer(); - }); - afterEach(async () => { - await server.stop(); - }); - - function internalHeader(): Record { - return server.internalToken === undefined ? {} : { "X-Internal-Token": server.internalToken }; - } - - function lastLoginToken(): { challengeId: string; token: string } { - const sends = server.emailSender.sends.filter((s) => s.template === "customer-login-link"); - const last = sends[sends.length - 1]!; - return { challengeId: last.data["challengeId"] as string, token: last.data["token"] as string }; - } - - /** Full magic-link login over the wire → the bearer session token. */ - async function login(email: string): Promise { - const reqRes = await fetch(`${server.baseUrl}/auth/login/request`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email }), - }); - expect(reqRes.status).toBe(200); - const { challengeId, token } = lastLoginToken(); - const verifyRes = await fetch(`${server.baseUrl}/auth/login/verify`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ challengeId, token }), - }); - expect(verifyRes.status).toBe(200); - return (await json(verifyRes))["sessionToken"] as string; - } - - /** Seed a digital product, check out under `buyerRef`, and pay it through the - * REAL Stripe webhook so the entitlement is granted by the production path. */ - async function payDigitalOrder(input: { - sku: string; - productId: string; - buyerRef: string; - }): Promise { - await server.seedProduct({ - productId: input.productId, - sku: input.sku, - priceCents: 900, - title: "Digital Widget", - kind: "digital", - }); - const cart = await json( - await fetch(`${server.baseUrl}/carts`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ currency: "USD" }), - }), - ); - const cartId = cart["cartId"] as string; - await fetch(`${server.baseUrl}/carts/${cartId}/lines`, { - method: "POST", - headers: { "Content-Type": "application/json", "Idempotency-Key": `add-${cartId}` }, - body: JSON.stringify({ sku: input.sku, qty: 1, productId: input.productId }), - }); - const coRes = await fetch(`${server.baseUrl}/checkout/orders`, { - method: "POST", - headers: { "Content-Type": "application/json", "Idempotency-Key": `co-${cartId}` }, - body: JSON.stringify({ cartId, paymentMethod: "stripe", buyerRef: input.buyerRef }), - }); - expect(coRes.status).toBe(201); - const order = (await json(coRes))["order"] as Record; - const orderId = order["id"] as string; - const totalCents = (order["totals"] as Record)["totalCents"]!; - const signed = await signStripeWebhook( - { - eventId: `evt_${orderId}`, - type: "payment_intent.succeeded", - paymentIntentId: `pi_${orderId}`, - orderId, - amountCents: totalCents, - currency: "usd", - }, - STRIPE_WEBHOOK_SECRET, - ); - const hookRes = await fetch(`${server.baseUrl}/webhooks/stripe`, { - method: "POST", - headers: { "Content-Type": "application/json", "Stripe-Signature": signed.signatureHeader }, - body: signed.body, - }); - expect(hookRes.status).toBe(200); - return orderId; - } - - function check( - query: Record, - headers: Record = {}, - ): Promise { - const qs = new URLSearchParams(query).toString(); - return fetch(`${server.baseUrl}/entitlements/check?${qs}`, { headers }); - } - - // ── Precedence / oracle-closure ────────────────────────────────────────── - - test("1. buyerRef scope, no credentials → 401 (oracle closed, no `active`)", async () => { - await payDigitalOrder({ sku: "DIG-1", productId: "d1", buyerRef: "buyer@example.com" }); - const res = await check({ buyerRef: "buyer@example.com", sku: "DIG-1" }); - expect(res.status).toBe(401); - const body = await json(res); - expect(body).toEqual({ ok: false, error: "unauthorized" }); - expect(body).not.toHaveProperty("active"); - }); - - test("2. buyerRef scope, wrong X-Internal-Token → 401", async () => { - await payDigitalOrder({ sku: "DIG-1", productId: "d1", buyerRef: "buyer@example.com" }); - const res = await check( - { buyerRef: "buyer@example.com", sku: "DIG-1" }, - { "X-Internal-Token": "not-the-token" }, - ); - expect(res.status).toBe(401); - }); - - test("3. buyerRef scope, valid X-Internal-Token → correct boolean", async () => { - await payDigitalOrder({ sku: "DIG-1", productId: "d1", buyerRef: "buyer@example.com" }); - const owned = await check({ buyerRef: "buyer@example.com", sku: "DIG-1" }, internalHeader()); - expect(owned.status).toBe(200); - expect((await json(owned))["active"]).toBe(true); - const other = await check({ buyerRef: "buyer@example.com", sku: "OTHER" }, internalHeader()); - expect(other.status).toBe(200); - expect((await json(other))["active"]).toBe(false); - }); - - test("4. buyerRef scope on a server with internalToken DISABLED → 503 (never silently open)", async () => { - const disabled = await startTestServer({ internalToken: null }); - try { - const res = await fetch( - `${disabled.baseUrl}/entitlements/check?buyerRef=buyer@example.com&sku=DIG-1`, - ); - expect(res.status).toBe(503); - const body = (await res.json()) as Record; - expect(body).not.toHaveProperty("active"); - } finally { - await disabled.stop(); - } - }); - - test("5. buyerRef + valid session, no operator token → 401 (a session never unlocks arbitrary-email checks)", async () => { - await payDigitalOrder({ sku: "DIG-1", productId: "d1", buyerRef: "buyer@example.com" }); - const session = await login("buyer@example.com"); - const res = await check( - { buyerRef: "buyer@example.com", sku: "DIG-1" }, - { Authorization: `Bearer ${session}` }, - ); - expect(res.status).toBe(401); - }); - - test("6. orderId + buyerRef, no token → 401 (presence-based: closes 'does order X belong to email Y')", async () => { - const orderId = await payDigitalOrder({ - sku: "DIG-1", - productId: "d1", - buyerRef: "buyer@example.com", - }); - const res = await check({ orderId, buyerRef: "buyer@example.com", sku: "DIG-1" }); - expect(res.status).toBe(401); - }); - - test("7. orderId + buyerRef + valid X-Internal-Token → ANDed boolean", async () => { - const orderId = await payDigitalOrder({ - sku: "DIG-1", - productId: "d1", - buyerRef: "buyer@example.com", - }); - const match = await check( - { orderId, buyerRef: "buyer@example.com", sku: "DIG-1" }, - internalHeader(), - ); - expect((await json(match))["active"]).toBe(true); - const mismatch = await check( - { orderId, buyerRef: "someone-else@example.com", sku: "DIG-1" }, - internalHeader(), - ); - expect((await json(mismatch))["active"]).toBe(false); - }); - - test("8. orderId + valid Bearer of an UNRELATED customer → orderId capability (Bearer ignored, active:true)", async () => { - const orderId = await payDigitalOrder({ - sku: "DIG-1", - productId: "d1", - buyerRef: "buyer@example.com", - }); - const strangerSession = await login("stranger@example.com"); - const res = await check( - { orderId, sku: "DIG-1" }, - { Authorization: `Bearer ${strangerSession}` }, - ); - expect(res.status).toBe(200); - expect((await json(res))["active"]).toBe(true); - }); - - test("9. sku-only + valid X-Internal-Token, no Bearer → 401 (the token gates buyerRef, it is not a scope)", async () => { - const res = await check({ sku: "DIG-1" }, internalHeader()); - expect(res.status).toBe(401); - }); - - test("10. sku missing → 400 (schema)", async () => { - const res = await check({ buyerRef: "buyer@example.com" }, internalHeader()); - expect(res.status).toBe(400); - }); - - test("11. no scope, no credentials → 401", async () => { - const res = await check({ sku: "DIG-1" }); - expect(res.status).toBe(401); - }); - - // ── Session scope ──────────────────────────────────────────────────────── - - test("12. session scope: owned sku active:true, unowned sku active:false", async () => { - await payDigitalOrder({ sku: "DIG-1", productId: "d1", buyerRef: "buyer@example.com" }); - const session = await login("buyer@example.com"); - const owned = await check({ sku: "DIG-1" }, { Authorization: `Bearer ${session}` }); - expect(owned.status).toBe(200); - expect((await json(owned))["active"]).toBe(true); - const unowned = await check({ sku: "NOPE" }, { Authorization: `Bearer ${session}` }); - expect((await json(unowned))["active"]).toBe(false); - }); - - test("13. session scope: a different customer's session → active:false (no cross-buyer leak)", async () => { - await payDigitalOrder({ sku: "DIG-1", productId: "d1", buyerRef: "buyer@example.com" }); - const stranger = await login("stranger@example.com"); - const res = await check({ sku: "DIG-1" }, { Authorization: `Bearer ${stranger}` }); - expect(res.status).toBe(200); - expect((await json(res))["active"]).toBe(false); - }); - - test("14. session scope: garbage bearer token → 401", async () => { - const res = await check({ sku: "DIG-1" }, { Authorization: "Bearer not-a-real-token" }); - expect(res.status).toBe(401); - }); - - test("15. session scope: a revoked session (logout, then replay) → 401", async () => { - const session = await login("buyer@example.com"); - await fetch(`${server.baseUrl}/auth/logout`, { - method: "POST", - headers: { Authorization: `Bearer ${session}` }, - }); - const res = await check({ sku: "DIG-1" }, { Authorization: `Bearer ${session}` }); - expect(res.status).toBe(401); - }); - - test("16. session scope: an expired session → 401", async () => { - const session = await login("buyer@example.com"); - server.advance(31 * 24 * 60 * 60 * 1000); // past the 30-day session TTL - const res = await check({ sku: "DIG-1" }, { Authorization: `Bearer ${session}` }); - expect(res.status).toBe(401); - }); - - test("17. case-insensitive end-to-end: mixed-case checkout ref, lower-cased login email → active:true", async () => { - await payDigitalOrder({ sku: "DIG-1", productId: "d1", buyerRef: "Buyer@Example.COM" }); - const session = await login("buyer@example.com"); - const res = await check({ sku: "DIG-1" }, { Authorization: `Bearer ${session}` }); - expect(res.status).toBe(200); - expect((await json(res))["active"]).toBe(true); - }); -}); diff --git a/packages/service/test/expire-holds.test.ts b/packages/service/test/expire-holds.test.ts deleted file mode 100644 index 21707d08..00000000 --- a/packages/service/test/expire-holds.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { afterAll, beforeAll, describe, expect, test } from "vitest"; -import { startTestServer, type TestServer } from "./helpers/start-test-server.js"; - -const PG = process.env.PG_CONNECTION_STRING; - -// D2 — POST /internal/expire-holds reclaims globally-expired holds (the sweep). -// S5 — the endpoint is auth'd/internal: X-Internal-Token shared secret, 401 on -// mismatch, 503 (disabled) when no token is configured. -describe.skipIf(PG === undefined)("POST /internal/expire-holds [live server, Postgres]", () => { - let server: TestServer; - - beforeAll(async () => { - server = await startTestServer(); - }); - afterAll(async () => { - await server.stop(); - }); - - async function req( - method: string, - path: string, - body?: unknown, - headers: Record = {}, - ): Promise<{ status: number; body: Record }> { - const res = await fetch(`${server.baseUrl}${path}`, { - method, - headers: { "content-type": "application/json", ...headers }, - body: body === undefined ? undefined : JSON.stringify(body), - }); - return { status: res.status, body: (await res.json()) as Record }; - } - - function sweep(headers: Record = {}): ReturnType { - return req("POST", "/internal/expire-holds", undefined, headers); - } - - test("the sweep endpoint reclaims a lapsed hold's stock", async () => { - const token = server.internalToken ?? ""; - await server.seed("SKU-SWEEP", 5); - const cart = await req("POST", "/carts", {}); - const cartId = cart.body.cartId as string; - await req( - "POST", - `/carts/${cartId}/lines`, - { sku: "SKU-SWEEP", qty: 3 }, - { "Idempotency-Key": "s1" }, - ); - expect(await server.onHand("SKU-SWEEP")).toBe(2); - - // Before the TTL, the sweep reclaims nothing. - const early = await sweep({ "X-Internal-Token": token }); - expect(early.status).toBe(200); - expect(early.body).toEqual({ ok: true, reclaimed: 0 }); - expect(await server.onHand("SKU-SWEEP")).toBe(2); - - // Past the TTL, the sweep reclaims the hold and returns its stock. - server.advance(16 * 60 * 1000); - const swept = await sweep({ "X-Internal-Token": token }); - expect(swept.status).toBe(200); - expect(swept.body).toEqual({ ok: true, reclaimed: 1 }); - expect(await server.onHand("SKU-SWEEP")).toBe(5); - }); - - test("a missing or wrong X-Internal-Token is 401 and sweeps nothing", async () => { - await server.seed("SKU-AUTH", 5); - const cart = await req("POST", "/carts", {}); - const cartId = cart.body.cartId as string; - await req( - "POST", - `/carts/${cartId}/lines`, - { sku: "SKU-AUTH", qty: 2 }, - { "Idempotency-Key": "a1" }, - ); - server.advance(16 * 60 * 1000); - - const missing = await sweep(); - expect(missing.status).toBe(401); - const wrong = await sweep({ "X-Internal-Token": "not-the-token" }); - expect(wrong.status).toBe(401); - expect(await server.onHand("SKU-AUTH")).toBe(3); // hold untouched - }); - - test("with no token configured the endpoint is disabled (503), never open", async () => { - const disabled = await startTestServer({ internalToken: null }); - try { - const res = await fetch(`${disabled.baseUrl}/internal/expire-holds`, { method: "POST" }); - expect(res.status).toBe(503); - } finally { - await disabled.stop(); - } - }); -}); diff --git a/packages/service/test/helpers/start-test-server.ts b/packages/service/test/helpers/start-test-server.ts deleted file mode 100644 index 63c80f24..00000000 --- a/packages/service/test/helpers/start-test-server.ts +++ /dev/null @@ -1,453 +0,0 @@ -import { serve } from "@hono/node-server"; -import { - cents, - currency, - idempotencyKey, - money, - type PaymentGateway, - type PaymentMethod, - productId, - sku, -} from "@otta-sh/domain"; -import { - FakeEmailSender, - FIXTURE_INVENTORY, - FIXTURE_ITEMS, - FIXTURE_ORDERS, - FIXTURE_REFUNDS, - FixedClock, -} from "@otta-sh/domain/testing"; -import { StripePaymentGateway, type StripeTransport } from "@otta-sh/payments-stripe"; -import { createTestFacilitator, X402PaymentGateway } from "@otta-sh/payments-x402"; -import { - KyselyAddressStore, - KyselyCartStore, - KyselyCouponStore, - KyselyCredentialVerifier, - KyselyCustomerStore, - KyselyEntitlementStore, - KyselyInventoryStore, - KyselyOrderNotesStore, - KyselyOrderStore, - KyselyPaymentEventStore, - KyselyProductCommerceStore, - KyselyReportingStore, - KyselySessionStore, - KyselySettingsStore, - KyselyShippingRulesStore, - KyselyTaxRulesStore, - uuidIdGen, -} from "@otta-sh/store-postgres"; -import { createIsolatedPgSchema } from "@otta-sh/store-postgres/testing"; -import { createApp } from "../../src/app.js"; - -/** Known test secrets so tests can sign valid Stripe webhooks / x402 proofs. */ -export const STRIPE_WEBHOOK_SECRET = "whsec_test_service_phase4"; -export const X402_FACILITATOR_SECRET = "x402_facilitator_test_service"; - -export interface TestServer { - baseUrl: string; - /** The X-Internal-Token value the server accepts (undefined ⇒ disabled). */ - internalToken: string | undefined; - /** The in-memory email sender the server sends through (Phase 5) — tests read - * the emitted magic-link token and assert exactly-once status emails. */ - emailSender: FakeEmailSender; - seed(sku: string, qty: number): Promise; - onHand(sku: string): Promise; - /** Seed a priced product (with title) + optional stock, for checkout tests. */ - seedProduct(input: { - productId: string; - sku: string; - priceCents: number; - title: string; - kind: "physical" | "digital"; - onHand?: number; - }): Promise; - /** Advance the server's injected Clock (fast-forward past a hold TTL). */ - advance(ms: number): void; - /** Seed the shared Phase-7 reporting fixture (orders/totals/items/inventory) - * so the reports HTTP contract asserts the same hand-computed numbers. */ - seedReportingFixture(): Promise; - /** Seed a bare order (orders + order_totals) with an EXACT - * state/currency/buyerRef/createdAt/total for the admin Orders list tests. */ - seedOrder(row: { - id: string; - state: string; - currency: string; - buyerRef: string; - customerId?: string | null; - paymentMethod?: string | null; - createdAt: string; - totalCents: number; - reconciliationFlag?: string | null; - }): Promise; - /** Seed a captured `payments` row for an order (ADR-0008 refund tests) — the - * ceiling's `Σ captured` source + the gateway refund's `providerRef` target. */ - seedPayment(row: { - orderId: string; - gateway: string; - providerRef: string; - amountCents: number; - currency: string; - status?: string; - }): Promise; - /** Seed a bare `product_commerce` row with an EXACT `createdAt` (admin-UX - * Increment 2, product list tests) — a direct insert, no upsert/ - * idempotency-key dance, mirroring `seedOrder`. `taxClass` (Increment 3 - * closeout) lets the tax-class delete-in-use tests seed a LIVE product - * reference without going through the full upsert+edit dance. */ - seedProductRow(row: { - id: string; - sku?: string | null; - title?: string | null; - priceCents?: number | null; - currency?: string; - productKind?: "physical" | "digital"; - active?: boolean; - createdAt: string; - deletedAt?: string | null; - taxClass?: string | null; - }): Promise; - /** Seed a bare `coupons` row with an EXACT `createdAt` (admin-UX Increment 3, - * coupon list tests) — a direct insert, no `create()`/clock dance, - * mirroring `seedProductRow`. */ - seedCouponRow(row: { - id: string; - code: string; - type?: "fixed_amount" | "percentage"; - amountCents?: number | null; - rateBps?: number | null; - capCents?: number | null; - currency?: string | null; - minSubtotalCents?: number | null; - startsAt?: string | null; - expiresAt?: string | null; - maxUses?: number | null; - maxUsesPerCustomer?: number | null; - usesCount?: number; - createdAt: string; - }): Promise; - stop(): Promise; -} - -export interface TestServerOptions { - /** Shared secret for /internal/*; defaults to a per-server random token. - * Pass `null` to start the server with the internal endpoints DISABLED. */ - internalToken?: string | null; - /** SERVICE_API_TOKEN write gate; default unset (gate open — existing suites - * exercise the ungated surface). */ - serviceToken?: string; - /** ADR-0008: a Stripe `secretKey` + injected offline `transport` to make the - * Stripe gateway `refundable:true` for the refund HTTP contract (default: no - * secretKey ⇒ refundable:false ⇒ the manual record-only path). */ - stripeSecretKey?: string; - stripeTransport?: StripeTransport; -} - -/** - * Boot `createApp(deps)` on an ephemeral port with Postgres-backed stores in - * an isolated schema (§0.6). Returns the base URL plus seed/onHand helpers - * (there is no HTTP endpoint to seed stock). - */ -export async function startTestServer(options: TestServerOptions = {}): Promise { - const connectionString = process.env.PG_CONNECTION_STRING; - if (connectionString === undefined) throw new Error("PG_CONNECTION_STRING is not set"); - const iso = await createIsolatedPgSchema(connectionString, { poolMax: 8 }); - const db = iso.db; - - const internalToken = - options.internalToken === null ? undefined : (options.internalToken ?? crypto.randomUUID()); - const clock = new FixedClock(new Date("2026-07-10T00:00:00.000Z")); - - const store = new KyselyInventoryStore({ db, idGen: uuidIdGen, clock }); - const productCommerce = new KyselyProductCommerceStore({ db, clock }); - const cartStore = new KyselyCartStore({ db, idGen: uuidIdGen, clock }); - const orderStore = new KyselyOrderStore({ db, idGen: uuidIdGen, clock }); - const orderNotesStore = new KyselyOrderNotesStore({ db, idGen: uuidIdGen, clock }); - const entitlementStore = new KyselyEntitlementStore({ db, idGen: uuidIdGen, clock }); - const paymentEventStore = new KyselyPaymentEventStore({ db, idGen: uuidIdGen }); - const customerStore = new KyselyCustomerStore({ db, idGen: uuidIdGen, clock }); - const addressStore = new KyselyAddressStore({ db, idGen: uuidIdGen, clock }); - const sessionStore = new KyselySessionStore({ db, idGen: uuidIdGen, clock }); - const credentialVerifier = new KyselyCredentialVerifier({ - db, - customerStore, - idGen: uuidIdGen, - clock, - }); - const emailSender = new FakeEmailSender(); - const gateways: Partial> = { - stripe: new StripePaymentGateway({ - webhookSecret: STRIPE_WEBHOOK_SECRET, - ...(options.stripeSecretKey !== undefined ? { secretKey: options.stripeSecretKey } : {}), - ...(options.stripeTransport !== undefined ? { transport: options.stripeTransport } : {}), - }), - x402: new X402PaymentGateway({ - facilitator: createTestFacilitator(X402_FACILITATOR_SECRET), - payTo: "0xTEST", - accepts: ["eip155:8453"], - }), - }; - const shippingRules = new KyselyShippingRulesStore({ db }); - const taxRules = new KyselyTaxRulesStore({ db }); - const couponStore = new KyselyCouponStore({ db, idGen: uuidIdGen, clock }); - const reportingStore = new KyselyReportingStore({ db, dialect: "postgres" }); - const settingsStore = new KyselySettingsStore({ db, clock }); - const app = createApp({ - store, - productCommerce, - cartStore, - orderStore, - orderNotesStore, - entitlementStore, - paymentEventStore, - shippingRules, - taxRules, - couponStore, - reportingStore, - settingsStore, - customerStore, - addressStore, - sessionStore, - credentialVerifier, - emailSender, - idGen: uuidIdGen, - gateways, - clock, - internalToken, - serviceToken: options.serviceToken, - }); - - const server = await new Promise>((resolve) => { - const s = serve({ fetch: app.fetch, port: 0 }, () => resolve(s)); - }); - const address = server.address(); - const port = typeof address === "object" && address !== null ? address.port : 0; - - return { - baseUrl: `http://127.0.0.1:${port}`, - internalToken, - emailSender, - async seed(skuValue, qty) { - await db - .insertInto("inventory") - .values({ sku: skuValue, on_hand: qty }) - .onConflict((oc) => oc.column("sku").doUpdateSet({ on_hand: qty })) - .execute(); - }, - async onHand(skuValue) { - const row = await db - .selectFrom("inventory") - .select("on_hand") - .where("sku", "=", skuValue) - .executeTakeFirst(); - return row?.on_hand ?? 0; - }, - async seedProduct(input) { - await productCommerce.upsert( - { - productId: productId(input.productId), - sku: sku(input.sku), - price: money(cents(input.priceCents), currency("USD")), - title: input.title, - productKind: input.kind, - }, - idempotencyKey(`seed-${input.productId}`), - ); - if (input.kind === "physical") { - await store.seedOnHand(input.sku, input.onHand ?? 0); - } - }, - advance(ms) { - clock.advance(ms); - }, - async seedReportingFixture() { - for (const o of FIXTURE_ORDERS) { - await db - .insertInto("orders") - .values({ - id: o.id, - cart_id: null, - currency: o.currency, - state: o.state as never, - idempotency_key: `seed-${o.id}`, - hold_expires_at: o.createdAt, - payment_method: null, - buyer_ref: "seed", - created_at: o.createdAt, - updated_at: o.createdAt, - }) - .execute(); - await db - .insertInto("order_totals") - .values({ - order_id: o.id, - currency: o.currency, - subtotal_cents: o.totalCents, - discount_cents: 0, - shipping_cents: 0, - tax_cents: 0, - total_cents: o.totalCents, - applied_coupon_code: null, - shipping_method_snapshot: null, - tax_breakdown: null, - }) - .execute(); - } - for (const it of FIXTURE_ITEMS) { - await db - .insertInto("order_items") - .values({ - id: `${it.orderId}-${it.productId}`, - order_id: it.orderId, - product_id: it.productId, - sku: `sku-${it.productId}`, - title: it.title, - unit_price_cents: it.unitPriceCents, - currency: "USD", - quantity: it.quantity, - fulfillment_kind: "physical", - reservation_id: null, - }) - .execute(); - } - for (const inv of FIXTURE_INVENTORY) { - await db - .insertInto("inventory") - .values({ sku: inv.sku, on_hand: inv.onHand }) - .onConflict((oc) => oc.column("sku").doUpdateSet({ on_hand: inv.onHand })) - .execute(); - } - // The refunds ledger behind those same orders (INC-23), so the WIRE test - // exercises `refundedCents` against the same hand-computed figures the - // store contract pins. `created_at` sits deliberately outside the - // reporting window: the bucket comes from the ORDER's timestamp. - let refundSeq = 0; - for (const r of FIXTURE_REFUNDS) { - const id = `seed-refund-${String(refundSeq++)}`; - await db - .insertInto("refunds") - .values({ - id, - order_id: r.orderId, - amount_cents: r.amountCents, - currency: r.currency, - kind: "manual", - gateway: "stripe", - refund_ref: null, - reason: null, - refunded_by: "seed", - idempotency_key: id, - status: r.status ?? "recorded", - created_at: "2030-01-01T00:00:00.000Z", - }) - .execute(); - } - }, - async seedOrder(row) { - await db - .insertInto("orders") - .values({ - id: row.id, - cart_id: null, - currency: row.currency, - state: row.state as never, - idempotency_key: `seed-${row.id}`, - hold_expires_at: row.createdAt, - payment_method: row.paymentMethod ?? null, - buyer_ref: row.buyerRef, - customer_id: row.customerId ?? null, - reconciliation_flag: row.reconciliationFlag ?? null, - created_at: row.createdAt, - updated_at: row.createdAt, - }) - .execute(); - await db - .insertInto("order_totals") - .values({ - order_id: row.id, - currency: row.currency, - subtotal_cents: row.totalCents, - discount_cents: 0, - shipping_cents: 0, - tax_cents: 0, - total_cents: row.totalCents, - applied_coupon_code: null, - shipping_method_snapshot: null, - tax_breakdown: null, - }) - .execute(); - }, - async seedPayment(row) { - await db - .insertInto("payments") - .values({ - id: `pay-${row.orderId}-${row.providerRef}`, - order_id: row.orderId, - gateway: row.gateway, - provider_ref: row.providerRef, - amount_cents: row.amountCents, - currency: row.currency, - status: row.status ?? "succeeded", - created_at: "2026-07-10T00:00:00.000Z", - }) - .execute(); - }, - async seedProductRow(row) { - await db - .insertInto("product_commerce") - .values({ - product_id: row.id, - sku: row.sku ?? null, - price_cents: row.priceCents ?? null, - price_currency: - row.priceCents !== undefined && row.priceCents !== null - ? (row.currency ?? "USD") - : null, - title: row.title ?? null, - tax_class: row.taxClass ?? null, - inventory_policy: "deny", - weight_grams: null, - length_mm: null, - width_mm: null, - height_mm: null, - product_kind: row.productKind ?? "physical", - active: (row.active ?? false) ? 1 : 0, - deleted_at: row.deletedAt ?? null, - idempotency_key: `seed-${row.id}`, - content_updated_at: null, - active_updated_at: null, - created_at: row.createdAt, - updated_at: row.createdAt, - }) - .execute(); - }, - async seedCouponRow(row) { - await db - .insertInto("coupons") - .values({ - id: row.id, - code: row.code, - type: row.type ?? "fixed_amount", - amount_cents: row.amountCents ?? null, - rate_bps: row.rateBps ?? null, - cap_cents: row.capCents ?? null, - currency: row.currency ?? null, - min_subtotal_cents: row.minSubtotalCents ?? null, - starts_at: row.startsAt ?? null, - expires_at: row.expiresAt ?? null, - max_uses: row.maxUses ?? null, - max_uses_per_customer: row.maxUsesPerCustomer ?? null, - uses_count: row.usesCount ?? 0, - created_at: row.createdAt, - }) - .execute(); - }, - async stop() { - await new Promise((resolve, reject) => { - server.close((err) => (err ? reject(err) : resolve())); - }); - await iso.teardown(); - }, - }; -} diff --git a/packages/service/test/http-inventory-contract.pg.test.ts b/packages/service/test/http-inventory-contract.pg.test.ts deleted file mode 100644 index 67b3a722..00000000 --- a/packages/service/test/http-inventory-contract.pg.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { afterAll, beforeAll, describe, expect, test } from "vitest"; -import { startTestServer, type TestServer } from "./helpers/start-test-server.js"; - -const PG = process.env.PG_CONNECTION_STRING; - -interface JsonResponse { - status: number; - body: Record; -} - -describe.skipIf(PG === undefined)("HTTP inventory contract [live server, Postgres]", () => { - let server: TestServer; - - beforeAll(async () => { - server = await startTestServer(); - }); - afterAll(async () => { - await server.stop(); - }); - - async function post( - path: string, - body: unknown, - headers: Record = {}, - ): Promise { - const res = await fetch(`${server.baseUrl}${path}`, { - method: "POST", - headers: { "content-type": "application/json", ...headers }, - body: JSON.stringify(body), - }); - return { status: res.status, body: (await res.json()) as Record }; - } - - function reserve(sku: string, qty: number, key: string): Promise { - return post("/inventory/reserve", { sku, qty }, { "Idempotency-Key": key }); - } - - test("reserve within stock returns 200 { ok: true, reservationId }", async () => { - await server.seed("SKU-1", 5); - const res = await reserve("SKU-1", 2, "k1"); - expect(res.status).toBe(200); - expect(res.body.ok).toBe(true); - expect(typeof res.body.reservationId).toBe("string"); - expect(await server.onHand("SKU-1")).toBe(3); - }); - - test("reserve beyond stock returns 200 { ok: false, reason: OUT_OF_STOCK } — no status-code-as-logic", async () => { - await server.seed("SKU-2", 1); - const res = await reserve("SKU-2", 5, "k2"); - expect(res.status).toBe(200); - expect(res.body).toEqual({ ok: false, reason: "OUT_OF_STOCK" }); - expect(await server.onHand("SKU-2")).toBe(1); - }); - - test("replay with the same Idempotency-Key returns the same reservationId and decrements once", async () => { - await server.seed("SKU-3", 5); - const first = await reserve("SKU-3", 2, "k3"); - const replay = await reserve("SKU-3", 2, "k3"); - expect(first.body.ok).toBe(true); - expect(replay.body).toEqual(first.body); - expect(await server.onHand("SKU-3")).toBe(3); - }); - - test("commit finalizes the hold; release returns stock", async () => { - await server.seed("SKU-4", 5); - const a = await reserve("SKU-4", 2, "k4a"); - const b = await reserve("SKU-4", 1, "k4b"); - expect(await server.onHand("SKU-4")).toBe(2); - - const commit = await post("/inventory/commit", { reservationId: a.body.reservationId }); - expect(commit.status).toBe(200); - expect(await server.onHand("SKU-4")).toBe(2); // commit does not restock - - const release = await post("/inventory/release", { reservationId: b.body.reservationId }); - expect(release.status).toBe(200); - expect(await server.onHand("SKU-4")).toBe(3); // release returns the held stock - }); - - // PR B: an unknown reservationId is a typed ReservationNotFoundError, mapped - // to a 404 — distinct from ReservationCommitLostError's 500 (a reservation - // that existed but was lost; see the "non-held reservation" test below). - test("commit on an unknown reservation returns a structured 404, no internal/stack leak", async () => { - const res = await post("/inventory/commit", { reservationId: "does-not-exist" }); - expect(res.status).toBe(404); - expect(res.body).toEqual({ ok: false, reason: "RESERVATION_NOT_FOUND" }); - // The raw domain message ("unknown reservation: does-not-exist") must not leak. - expect(JSON.stringify(res.body)).not.toContain("does-not-exist"); - expect(res.body).not.toHaveProperty("stack"); - }); - - test("release on an unknown reservation returns a structured 404, no internal/stack leak", async () => { - const res = await post("/inventory/release", { reservationId: "does-not-exist" }); - expect(res.status).toBe(404); - expect(res.body).toEqual({ ok: false, reason: "RESERVATION_NOT_FOUND" }); - expect(JSON.stringify(res.body)).not.toContain("does-not-exist"); - expect(res.body).not.toHaveProperty("stack"); - }); - - test("release on a non-held reservation returns the structured 500 envelope", async () => { - await server.seed("SKU-5", 3); - const r = await reserve("SKU-5", 1, "k5"); - const reservationId = r.body.reservationId; - await post("/inventory/commit", { reservationId }); // now committed, not held - const res = await post("/inventory/release", { reservationId }); - expect(res.status).toBe(500); - expect(res.body).toEqual({ ok: false, error: "internal_error" }); - }); - - test("schema-invalid body returns 400", async () => { - const res = await post("/inventory/reserve", { sku: "", qty: -1 }, { "Idempotency-Key": "kx" }); - expect(res.status).toBe(400); - }); - - test("missing Idempotency-Key header returns 400", async () => { - const res = await post("/inventory/reserve", { sku: "SKU-1", qty: 1 }); - expect(res.status).toBe(400); - }); -}); diff --git a/packages/service/test/inventory-not-found.test.ts b/packages/service/test/inventory-not-found.test.ts deleted file mode 100644 index df471874..00000000 --- a/packages/service/test/inventory-not-found.test.ts +++ /dev/null @@ -1,258 +0,0 @@ -import { type InventoryStore, ReservationNotFoundError } from "@otta-sh/domain"; -import { - CountingIdGen, - FakeEmailSender, - FixedClock, - InMemoryAddressStore, - InMemoryCartStore, - InMemoryCouponStore, - InMemoryCredentialVerifier, - InMemoryCustomerStore, - InMemoryEntitlementStore, - InMemoryInventoryStore, - InMemoryOrderNotesStore, - InMemoryOrderStore, - InMemoryPaymentEventStore, - InMemoryProductCommerceStore, - InMemoryReportingStore, - InMemorySessionStore, - InMemorySettingsStore, - InMemoryShippingRulesStore, - InMemoryTaxRulesStore, -} from "@otta-sh/domain/testing"; -import { StripePaymentGateway } from "@otta-sh/payments-stripe"; -import type { Hono } from "hono"; -import { describe, expect, test } from "vitest"; -import { createApp } from "../src/app.js"; - -// PR B (typed 404 for unknown reservation): IO-free HTTP tests over -// `app.request()` — no server, no PG — proving the route mapping introduced -// alongside `ReservationNotFoundError` (see the domain port contract tests in -// `packages/domain/src/testing/inventory-store-contract.ts` for the store-level -// behavior, run against all three harnesses). -interface TestApp { - app: Hono; - inventory: InMemoryInventoryStore; -} - -/** A mutable box so the vanished id can be set AFTER the store (and app) are - * constructed — the reservation only exists once a real `reserve()` runs. */ -interface VanishedIdBox { - id: string | undefined; -} - -/** Wraps a real InMemoryInventoryStore, forcing `adjust` to throw - * `ReservationNotFoundError` for one chosen reservation id while every other - * call (including `reservationState`, used by the cart store's live fence - * read) passes through untouched. Models the KNOWN ASYMMETRY the port - * docblock documents: `adjust` shares `commit`/`release`'s choke point and - * throws the same typed error on a vanished reservation, but nothing at the - * HTTP boundary maps it — so the cart PATCH still 500s. */ -function withVanishingAdjust(inner: InMemoryInventoryStore, box: VanishedIdBox): InventoryStore { - return new Proxy(inner, { - get(target, prop, receiver) { - if (prop === "adjust") { - return async (reservationId: string, newQty: number, key: unknown) => { - if (reservationId === box.id) { - throw new ReservationNotFoundError(reservationId); - } - return (target as unknown as InventoryStore).adjust( - reservationId, - newQty, - key as Parameters[2], - ); - }; - } - const value = Reflect.get(target, prop, receiver) as unknown; - return typeof value === "function" - ? (value as (...a: unknown[]) => unknown).bind(target) - : value; - }, - }) as unknown as InventoryStore; -} - -function makeApp(store: InventoryStore, inventory: InMemoryInventoryStore): TestApp { - const clock = new FixedClock(new Date("2026-07-10T00:00:00.000Z")); - const cartStore = new InMemoryCartStore({ - idGen: new CountingIdGen("cart"), - reservationState: (id) => { - try { - return inventory.reservationState(id); - } catch { - return undefined; - } - }, - releaseHold: (id) => { - void inventory.release(id); - }, - }); - const productCommerce = new InMemoryProductCommerceStore({ - clock, - // NOTE: `InMemoryInventoryStore.onHand` returns 0 for an unseeded sku, so - // this wiring COLLAPSES null -> 0. Fine for the coarse `inStock` boolean - // these suites exercise; do NOT assert the products-list `onHand` - // projection through it (the list must distinguish "no inventory row" - // from "out of stock" — see the divergence note in - // `packages/domain/src/ports/inventory-store.ts`'s `getOnHand` doc). - inventoryOnHand: (s) => inventory.onHand(s), - }); - const idGen = new CountingIdGen("id"); - const customerStore = new InMemoryCustomerStore({ idGen, clock }); - const app = createApp({ - store, - productCommerce, - cartStore, - orderStore: new InMemoryOrderStore({ idGen, clock }), - orderNotesStore: new InMemoryOrderNotesStore({ idGen, clock }), - entitlementStore: new InMemoryEntitlementStore({ idGen, clock }), - paymentEventStore: new InMemoryPaymentEventStore(), - shippingRules: new InMemoryShippingRulesStore(), - taxRules: new InMemoryTaxRulesStore(), - couponStore: new InMemoryCouponStore({ idGen, clock }), - reportingStore: new InMemoryReportingStore(), - settingsStore: new InMemorySettingsStore(), - customerStore, - addressStore: new InMemoryAddressStore({ idGen, clock }), - sessionStore: new InMemorySessionStore({ idGen, clock }), - credentialVerifier: new InMemoryCredentialVerifier({ customerStore, idGen, clock }), - emailSender: new FakeEmailSender(), - idGen, - gateways: { stripe: new StripePaymentGateway({ webhookSecret: "whsec_gate_test", clock }) }, - clock, - }); - return { app, inventory }; -} - -function newInventory(): InMemoryInventoryStore { - return new InMemoryInventoryStore({ - idGen: new CountingIdGen("res"), - clock: new FixedClock(new Date("2026-07-10T00:00:00.000Z")), - }); -} - -const json = { "content-type": "application/json" }; - -describe("POST /inventory/commit and /release: unknown reservationId", () => { - test("commit of an unknown reservationId is 404 RESERVATION_NOT_FOUND", async () => { - const inventory = newInventory(); - const { app } = makeApp(inventory, inventory); - const res = await app.request("/inventory/commit", { - method: "POST", - headers: json, - body: JSON.stringify({ reservationId: "no-such-reservation" }), - }); - expect(res.status).toBe(404); - expect(await res.json()).toEqual({ ok: false, reason: "RESERVATION_NOT_FOUND" }); - }); - - test("release of an unknown reservationId is 404 RESERVATION_NOT_FOUND", async () => { - const inventory = newInventory(); - const { app } = makeApp(inventory, inventory); - const res = await app.request("/inventory/release", { - method: "POST", - headers: json, - body: JSON.stringify({ reservationId: "no-such-reservation" }), - }); - expect(res.status).toBe(404); - expect(await res.json()).toEqual({ ok: false, reason: "RESERVATION_NOT_FOUND" }); - }); - - test("anomaly path unchanged: commit of a released reservation still 500s internal_error", async () => { - const inventory = newInventory(); - const { app } = makeApp(inventory, inventory); - await inventory.seedOnHand("SKU-1", 5); - const reserveRes = await app.request("/inventory/reserve", { - method: "POST", - headers: { ...json, "Idempotency-Key": "k1" }, - body: JSON.stringify({ sku: "SKU-1", qty: 1 }), - }); - const reserved = (await reserveRes.json()) as { ok: true; reservationId: string }; - expect(reserved.ok).toBe(true); - - const releaseRes = await app.request("/inventory/release", { - method: "POST", - headers: json, - body: JSON.stringify({ reservationId: reserved.reservationId }), - }); - expect(releaseRes.status).toBe(200); - - const commitRes = await app.request("/inventory/commit", { - method: "POST", - headers: json, - body: JSON.stringify({ reservationId: reserved.reservationId }), - }); - expect(commitRes.status).toBe(500); - expect(await commitRes.json()).toEqual({ ok: false, error: "internal_error" }); - }); - - test("happy path regression: reserve -> commit is 200; reserve -> release is 200 and returns stock", async () => { - const inventory = newInventory(); - const { app } = makeApp(inventory, inventory); - await inventory.seedOnHand("SKU-1", 5); - - const a = await app.request("/inventory/reserve", { - method: "POST", - headers: { ...json, "Idempotency-Key": "ka" }, - body: JSON.stringify({ sku: "SKU-1", qty: 2 }), - }); - const aBody = (await a.json()) as { ok: true; reservationId: string }; - expect(aBody.ok).toBe(true); - const commitRes = await app.request("/inventory/commit", { - method: "POST", - headers: json, - body: JSON.stringify({ reservationId: aBody.reservationId }), - }); - expect(commitRes.status).toBe(200); - expect(await commitRes.json()).toEqual({ ok: true }); - - const b = await app.request("/inventory/reserve", { - method: "POST", - headers: { ...json, "Idempotency-Key": "kb" }, - body: JSON.stringify({ sku: "SKU-1", qty: 1 }), - }); - const bBody = (await b.json()) as { ok: true; reservationId: string }; - expect(bBody.ok).toBe(true); - expect(await inventory.onHand("SKU-1")).toBe(2); - const releaseRes = await app.request("/inventory/release", { - method: "POST", - headers: json, - body: JSON.stringify({ reservationId: bBody.reservationId }), - }); - expect(releaseRes.status).toBe(200); - expect(await releaseRes.json()).toEqual({ ok: true }); - expect(await inventory.onHand("SKU-1")).toBe(3); - }); -}); - -describe("known asymmetry (out of scope): cart PATCH against a vanished reservation still 500s", () => { - test("PATCH /carts/:cartId/lines/:lineId whose reservation vanished is 500 internal_error, not 404", async () => { - const inventory = newInventory(); - await inventory.seedOnHand("SKU-1", 5); - - const box: VanishedIdBox = { id: undefined }; - const store = withVanishingAdjust(inventory, box); - const { app } = makeApp(store, inventory); - - const cartRes = await app.request("/carts", { method: "POST", headers: json, body: "{}" }); - const { cartId } = (await cartRes.json()) as { cartId: string }; - const lineRes = await app.request(`/carts/${cartId}/lines`, { - method: "POST", - headers: { ...json, "Idempotency-Key": "add1" }, - body: JSON.stringify({ sku: "SKU-1", qty: 1 }), - }); - const lineBody = (await lineRes.json()) as { - ok: true; - line: { lineId: string; reservationId: string }; - }; - expect(lineBody.ok).toBe(true); - box.id = lineBody.line.reservationId; - - const patchRes = await app.request(`/carts/${cartId}/lines/${lineBody.line.lineId}`, { - method: "PATCH", - headers: { ...json, "Idempotency-Key": "patch1" }, - body: JSON.stringify({ qty: 2 }), - }); - expect(patchRes.status).toBe(500); - expect(await patchRes.json()).toEqual({ ok: false, error: "internal_error" }); - }); -}); diff --git a/packages/service/test/orders.http.contract.pg.test.ts b/packages/service/test/orders.http.contract.pg.test.ts deleted file mode 100644 index 39a5fe3b..00000000 --- a/packages/service/test/orders.http.contract.pg.test.ts +++ /dev/null @@ -1,280 +0,0 @@ -import { signStripeWebhook } from "@otta-sh/payments-stripe"; -import { afterEach, beforeEach, describe, expect, test } from "vitest"; -import { - STRIPE_WEBHOOK_SECRET, - startTestServer, - type TestServer, -} from "./helpers/start-test-server.js"; - -// The client-side HTTP contract (§8 step 4.8): the new endpoints exercised -// against a LIVE server backed by Postgres, using the offline fake-Stripe driver -// to POST a signed webhook. Proves the wire format does not drift from the ports. - -const PG = process.env.PG_CONNECTION_STRING; - -async function json(res: Response): Promise> { - return (await res.json()) as Record; -} - -describe.skipIf(PG === undefined)("orders + webhook + entitlements HTTP contract", () => { - let server: TestServer; - beforeEach(async () => { - server = await startTestServer(); - }); - afterEach(async () => { - await server.stop(); - }); - - async function createOrder(input: { - sku: string; - productId: string; - kind: "physical" | "digital"; - priceCents: number; - paymentMethod: "stripe" | "x402"; - }): Promise<{ orderId: string; totalCents: number }> { - await server.seedProduct({ - productId: input.productId, - sku: input.sku, - priceCents: input.priceCents, - title: "Item", - kind: input.kind, - onHand: input.kind === "physical" ? 5 : undefined, - }); - const cart = await json( - await fetch(`${server.baseUrl}/carts`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ currency: "USD" }), - }), - ); - const cartId = cart["cartId"] as string; - const addRes = await fetch(`${server.baseUrl}/carts/${cartId}/lines`, { - method: "POST", - headers: { "Content-Type": "application/json", "Idempotency-Key": `add-${cartId}` }, - body: JSON.stringify({ sku: input.sku, qty: 1, productId: input.productId }), - }); - expect(addRes.status).toBe(200); - const coRes = await fetch(`${server.baseUrl}/checkout/orders`, { - method: "POST", - headers: { "Content-Type": "application/json", "Idempotency-Key": `co-${cartId}` }, - body: JSON.stringify({ - cartId, - paymentMethod: input.paymentMethod, - buyerRef: "buyer@example.com", - }), - }); - expect(coRes.status).toBe(201); - const order = (await json(coRes))["order"] as Record; - const totals = order["totals"] as Record; - return { orderId: order["id"] as string, totalCents: totals["totalCents"]! }; - } - - async function stripeWebhook( - orderId: string, - amountCents: number, - opts: { eventId?: string; badSecret?: boolean } = {}, - ) { - const signed = await signStripeWebhook( - { - eventId: opts.eventId ?? `evt_${orderId}`, - type: "payment_intent.succeeded", - paymentIntentId: `pi_${orderId}`, - orderId, - amountCents, - currency: "usd", - }, - opts.badSecret ? "whsec_wrong" : STRIPE_WEBHOOK_SECRET, - ); - return fetch(`${server.baseUrl}/webhooks/stripe`, { - method: "POST", - headers: { "Content-Type": "application/json", "Stripe-Signature": signed.signatureHeader }, - body: signed.body, - }); - } - - async function orderState(orderId: string): Promise { - const res = await fetch(`${server.baseUrl}/orders/${orderId}`); - const body = await json(res); - return (body["order"] as Record)["state"] as string; - } - - test("POST /webhooks/stripe with a signed payment_intent.succeeded flips the order to paid", async () => { - const { orderId, totalCents } = await createOrder({ - sku: "SKU-1", - productId: "p1", - kind: "physical", - priceCents: 1500, - paymentMethod: "stripe", - }); - const res = await stripeWebhook(orderId, totalCents); - expect(res.status).toBe(200); - expect(await orderState(orderId)).toBe("paid"); - }); - - test("redelivering the same event returns 200 and settles once", async () => { - const { orderId, totalCents } = await createOrder({ - sku: "SKU-2", - productId: "p2", - kind: "physical", - priceCents: 1500, - paymentMethod: "stripe", - }); - const first = await stripeWebhook(orderId, totalCents); - const second = await stripeWebhook(orderId, totalCents); - expect(first.status).toBe(200); - expect(second.status).toBe(200); - expect(await orderState(orderId)).toBe("paid"); - }); - - test("bad signature returns 400 and does not settle", async () => { - const { orderId, totalCents } = await createOrder({ - sku: "SKU-3", - productId: "p3", - kind: "physical", - priceCents: 1500, - paymentMethod: "stripe", - }); - const res = await stripeWebhook(orderId, totalCents, { badSecret: true }); - expect(res.status).toBe(400); - expect(await orderState(orderId)).toBe("pending"); - }); - - test("GET /orders/:id reflects paid after the webhook (redirect poll)", async () => { - const { orderId, totalCents } = await createOrder({ - sku: "SKU-4", - productId: "p4", - kind: "physical", - priceCents: 2000, - paymentMethod: "stripe", - }); - expect(await orderState(orderId)).toBe("pending"); // poll before payment - await stripeWebhook(orderId, totalCents); - expect(await orderState(orderId)).toBe("paid"); // poll after webhook - }); - - // ADR-0009: checkout address capture, end-to-end over the wire. - async function checkoutWithBody( - body: Record, - ): Promise<{ status: number; json: Record }> { - await server.seedProduct({ - productId: "pa", - sku: "SKU-A", - priceCents: 1200, - title: "Widget A", - kind: "physical", - onHand: 5, - }); - const cart = await json( - await fetch(`${server.baseUrl}/carts`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ currency: "USD" }), - }), - ); - const cartId = cart["cartId"] as string; - await fetch(`${server.baseUrl}/carts/${cartId}/lines`, { - method: "POST", - headers: { "Content-Type": "application/json", "Idempotency-Key": `add-${cartId}` }, - body: JSON.stringify({ sku: "SKU-A", qty: 1, productId: "pa" }), - }); - const res = await fetch(`${server.baseUrl}/checkout/orders`, { - method: "POST", - headers: { "Content-Type": "application/json", "Idempotency-Key": `co-${cartId}` }, - body: JSON.stringify({ - cartId, - paymentMethod: "stripe", - buyerRef: "buyer@example.com", - ...body, - }), - }); - return { status: res.status, json: await json(res) }; - } - - test("POST /checkout/orders captures a shipping address; an AUTHENTICATED GET /orders/:id serializes the frozen snapshot", async () => { - const shippingAddress = { - name: "Ada Lovelace", - line1: "12 Analytical Way", - city: "London", - postalCode: "EC1A 1BB", - country: "GB", - email: "ada@example.com", - }; - const { status, json: created } = await checkoutWithBody({ shippingAddress }); - expect(status).toBe(201); - const orderId = (created["order"] as Record)["id"] as string; - // ADR-0010 §2 / PR D: the bare, unauthenticated GET is redacted — the - // ADR-0009 capture assertion moves to the internal-token-gated read. - const read = await json( - await fetch(`${server.baseUrl}/orders/${orderId}`, { - headers: { "X-Internal-Token": server.internalToken! }, - }), - ); - const order = read["order"] as Record; - expect(order["shippingAddress"]).toEqual({ - name: "Ada Lovelace", - line1: "12 Analytical Way", - line2: null, - city: "London", - region: null, - postalCode: "EC1A 1BB", - country: "GB", - email: "ada@example.com", - phone: null, - }); - }); - - test("the UNAUTHENTICATED GET /orders/:id omits shippingAddress (and buyerRef/customerId) entirely (PR D)", async () => { - const shippingAddress = { - name: "Ada Lovelace", - line1: "12 Analytical Way", - city: "London", - postalCode: "EC1A 1BB", - country: "GB", - email: "ada@example.com", - }; - const { status, json: created } = await checkoutWithBody({ shippingAddress }); - expect(status).toBe(201); - const orderId = (created["order"] as Record)["id"] as string; - const publicRead = await json(await fetch(`${server.baseUrl}/orders/${orderId}`)); - const order = publicRead["order"] as Record; - expect(order).not.toHaveProperty("shippingAddress"); - expect(order).not.toHaveProperty("buyerRef"); - expect(order).not.toHaveProperty("customerId"); - // The guest-confirmation payload stays intact. - expect(order["id"]).toBe(orderId); - expect(order["state"]).toBe("pending"); - }); - - test("POST /checkout/orders with no address yields a null ship-to (capture optional this slice)", async () => { - const { status, json: created } = await checkoutWithBody({}); - expect(status).toBe(201); - expect((created["order"] as Record)["shippingAddress"]).toBeNull(); - }); - - test("POST /checkout/orders rejects a malformed address (missing required field) with 400", async () => { - const { status } = await checkoutWithBody({ - shippingAddress: { name: "Ada", line1: "12 Analytical Way", city: "London", country: "GB" }, - }); - // Missing postalCode ⇒ zod 400 (never a half-written order). - expect(status).toBe(400); - }); - - test("GET /entitlements/check returns active after a digital order is paid", async () => { - const { orderId, totalCents } = await createOrder({ - sku: "DIG-1", - productId: "d1", - kind: "digital", - priceCents: 900, - paymentMethod: "stripe", - }); - const before = await json( - await fetch(`${server.baseUrl}/entitlements/check?orderId=${orderId}&sku=DIG-1`), - ); - expect(before["active"]).toBe(false); - await stripeWebhook(orderId, totalCents); - const after = await json( - await fetch(`${server.baseUrl}/entitlements/check?orderId=${orderId}&sku=DIG-1`), - ); - expect(after["active"]).toBe(true); - }); -}); diff --git a/packages/service/test/product-commerce-http.test.ts b/packages/service/test/product-commerce-http.test.ts deleted file mode 100644 index fd278e8b..00000000 --- a/packages/service/test/product-commerce-http.test.ts +++ /dev/null @@ -1,1055 +0,0 @@ -import { afterAll, beforeAll, describe, expect, test } from "vitest"; -import { startTestServer, type TestServer } from "./helpers/start-test-server.js"; - -const PG = process.env.PG_CONNECTION_STRING; - -interface JsonResponse { - status: number; - body: Record | null; -} - -describe.skipIf(PG === undefined)("HTTP product-commerce contract [live server, Postgres]", () => { - let server: TestServer; - - beforeAll(async () => { - server = await startTestServer(); - }); - afterAll(async () => { - await server.stop(); - }); - - async function put( - id: string, - body: unknown, - headers: Record = {}, - ): Promise { - const res = await fetch(`${server.baseUrl}/products/${id}/commerce`, { - method: "PUT", - headers: { "content-type": "application/json", ...headers }, - body: JSON.stringify(body), - }); - return { status: res.status, body: (await res.json()) as Record | null }; - } - - function get(id: string): Promise { - return fetch(`${server.baseUrl}/products/${id}/commerce`).then(async (res) => ({ - status: res.status, - body: (await res.json()) as Record | null, - })); - } - - function del(id: string, headers: Record = {}): Promise { - return fetch(`${server.baseUrl}/products/${id}/commerce`, { method: "DELETE", headers }).then( - async (res) => ({ status: res.status, body: (await res.json()) as Record }), - ); - } - - // Default publish-gate watermark for lifecycle cases whose intent is NOT - // ordering; convergence cases below pass explicit distinct timestamps. - const WM = "2026-07-11T00:00:00.000Z"; - - function activate( - id: string, - headers: Record = {}, - contentUpdatedAt: string = WM, - ): Promise { - return fetch(`${server.baseUrl}/products/${id}/commerce/activate`, { - method: "POST", - headers: { "content-type": "application/json", ...headers }, - body: JSON.stringify({ contentUpdatedAt }), - }).then(async (res) => ({ - status: res.status, - body: (await res.json()) as Record, - })); - } - - function deactivate( - id: string, - headers: Record = {}, - contentUpdatedAt: string = WM, - ): Promise { - return fetch(`${server.baseUrl}/products/${id}/commerce/deactivate`, { - method: "POST", - headers: { "content-type": "application/json", ...headers }, - body: JSON.stringify({ contentUpdatedAt }), - }).then(async (res) => ({ - status: res.status, - body: (await res.json()) as Record, - })); - } - - test("PUT upserts a product_commerce row keyed by the CMS id (wire ⇄ port fidelity)", async () => { - const res = await put( - "prod-http-1", - { - sku: "SKU-H1", - price: { amount: 1999, currency: "USD" }, - productKind: "physical", - }, - { "Idempotency-Key": "k1" }, - ); - expect(res.status).toBe(200); - expect(res.body).toMatchObject({ - productId: "prod-http-1", - sku: "SKU-H1", - price: { amount: 1999, currency: "USD" }, - productKind: "physical", - active: false, - deletedAt: null, - }); - }); - - test("replay with the same Idempotency-Key is a no-op returning the existing row unchanged", async () => { - const first = await put( - "prod-http-2", - { sku: "SKU-H2", price: { amount: 500, currency: "USD" } }, - { "Idempotency-Key": "k2" }, - ); - const replay = await put( - "prod-http-2", - { sku: "SKU-H2-CHANGED", price: { amount: 999999, currency: "USD" } }, - { "Idempotency-Key": "k2" }, - ); - expect(replay.body).toEqual(first.body); - }); - - test("PUT on first creation with initialOnHand seeds inventory on_hand once", async () => { - await put( - "prod-http-3", - { sku: "SKU-H3", price: { amount: 100, currency: "USD" }, initialOnHand: 25 }, - { "Idempotency-Key": "k3" }, - ); - expect(await server.onHand("SKU-H3")).toBe(25); - - // A later edit must not reseed even if it supplies a new figure. - await put( - "prod-http-3", - { price: { amount: 150, currency: "USD" }, initialOnHand: 999 }, - { "Idempotency-Key": "k3b" }, - ); - expect(await server.onHand("SKU-H3")).toBe(25); - }); - - test("GET reads the row back; unknown product_id returns 200 with a null body (not purchasable, not a hard 404)", async () => { - await put( - "prod-http-4", - { sku: "SKU-H4", price: { amount: 250, currency: "USD" } }, - { "Idempotency-Key": "k4" }, - ); - const found = await get("prod-http-4"); - expect(found.status).toBe(200); - expect(found.body).toMatchObject({ productId: "prod-http-4", sku: "SKU-H4" }); - - const missing = await get("does-not-exist"); - expect(missing.status).toBe(200); - expect(missing.body).toBeNull(); - }); - - test("DELETE soft-deletes: deletedAt set, active false, row retained (readable via GET)", async () => { - await put( - "prod-http-5", - { sku: "SKU-H5", price: { amount: 400, currency: "USD" } }, - { "Idempotency-Key": "k5" }, - ); - const del1 = await del("prod-http-5", { "Idempotency-Key": "del-1" }); - expect(del1.status).toBe(200); - expect(del1.body).toEqual({ ok: true }); - - const read = await get("prod-http-5"); - expect(read.body).toMatchObject({ active: false, sku: "SKU-H5" }); - expect(read.body?.deletedAt).not.toBeNull(); - }); - - test("PUT with a missing Idempotency-Key header returns 400", async () => { - const res = await put("prod-http-6", { sku: "SKU-H6" }); - expect(res.status).toBe(400); - }); - - test("PUT with a schema-invalid body (bad currency) returns 400", async () => { - const res = await put( - "prod-http-7", - { price: { amount: 100, currency: "usd" } }, - { "Idempotency-Key": "k7" }, - ); - expect(res.status).toBe(400); - }); - - test("DELETE with a missing Idempotency-Key header returns 400", async () => { - const res = await del("prod-http-8"); - expect(res.status).toBe(400); - }); - - test("a save carrying a sku but NO stock figure still creates the inventory row at 0 (PR 1a, Postgres)", async () => { - // PR 1a: the invariant is "a product with a sku has an inventory row", - // so this path can no longer mint a sku with nothing behind it — the - // stranded state this test used to construct is now unreachable here. - await put( - "prod-http-b1", - { sku: "SKU-HB1", price: { amount: 300, currency: "USD" } }, - { "Idempotency-Key": "kb1" }, - ); - expect(await server.onHand("SKU-HB1")).toBe(0); - - // `onHand` reads a MISSING row as 0 too, so that assertion alone proves - // nothing. Restock never auto-creates a row, so a successful restock is - // the real proof the row exists — and this exact call was a 409 - // NO_INVENTORY_ROW before 1a. - const restocked = await fetch(`${server.baseUrl}/admin/products/prod-http-b1/restock`, { - method: "POST", - headers: { - "content-type": "application/json", - "X-Internal-Token": server.internalToken as string, - "Idempotency-Key": "kb1-restock", - }, - body: JSON.stringify({ qty: 4 }), - }); - expect(restocked.status).toBe(200); - expect(await server.onHand("SKU-HB1")).toBe(4); - - // And because the seed is create-if-absent, a LATER save's initialOnHand - // is silently discarded rather than clobbering the live count — in either - // direction. (Before 1a this same call healed a stranded row to 12.) - await put( - "prod-http-b1", - { sku: "SKU-HB1", price: { amount: 300, currency: "USD" }, initialOnHand: 12 }, - { "Idempotency-Key": "kb1" }, - ); - expect(await server.onHand("SKU-HB1")).toBe(4); - await put("prod-http-b1", { initialOnHand: 999 }, { "Idempotency-Key": "kb1-later" }); - expect(await server.onHand("SKU-HB1")).toBe(4); - }); - - test("a stale sync PUT (older contentUpdatedAt) arriving after a newer one is a no-op over the wire (S1)", async () => { - const newer = await put( - "prod-http-s1", - { - sku: "SKU-HS1", - price: { amount: 2000, currency: "USD" }, - contentUpdatedAt: "2026-07-10T02:00:00.000Z", - }, - { "Idempotency-Key": "ks1-newer" }, - ); - const stale = await put( - "prod-http-s1", - { price: { amount: 1, currency: "USD" }, contentUpdatedAt: "2026-07-10T01:00:00.000Z" }, - { "Idempotency-Key": "ks1-stale" }, - ); - expect(stale.status).toBe(200); - expect(stale.body).toEqual(newer.body); - - const read = await get("prod-http-s1"); - expect(read.body).toMatchObject({ price: { amount: 2000, currency: "USD" } }); - }); - - test("panel-style PUTs (no contentUpdatedAt) are last-writer-wins — the documented lost-update semantics (S1)", async () => { - await put( - "prod-http-s1b", - { sku: "SKU-HS1B", price: { amount: 100, currency: "USD" } }, - { "Idempotency-Key": "ks1b-1" }, - ); - // A second explicit merchant save (e.g. a slower tab finishing later) - // overwrites — accepted and pinned deliberately: explicit human saves - // carry no ordering watermark, so the last write wins. - const second = await put( - "prod-http-s1b", - { price: { amount: 200, currency: "USD" } }, - { "Idempotency-Key": "ks1b-2" }, - ); - expect(second.body).toMatchObject({ price: { amount: 200, currency: "USD" } }); - }); - - test("a malformed contentUpdatedAt (non-ISO / garbage high-sorting value) is a 400 and writes nothing (F1)", async () => { - // The watermark feeds a raw lexicographic SQL comparison — a stored - // "ZZZZ" would make every future legitimate sync a stale no-op forever - // (panel saves preserve, never heal, the watermark). - for (const bad of ["ZZZZ", "2026-07-10", "2026-07-10T02:00:00Z", "not-a-date", " "]) { - const res = await put( - "prod-http-f1", - { sku: "SKU-HF1", contentUpdatedAt: bad }, - { "Idempotency-Key": `kf1-${bad}` }, - ); - expect(res.status, `contentUpdatedAt=${JSON.stringify(bad)}`).toBe(400); - } - // Nothing was minted by any of the rejected requests. - const read = await get("prod-http-f1"); - expect(read.body).toBeNull(); - - // The exact Date.toISOString() shape is accepted. - const ok = await put( - "prod-http-f1", - { sku: "SKU-HF1", contentUpdatedAt: "2026-07-10T02:00:00.000Z" }, - { "Idempotency-Key": "kf1-ok" }, - ); - expect(ok.status).toBe(200); - }); - - test("two live products contending a SKU is a structured 409 SKU_TAKEN — nothing leaked (F2, Postgres)", async () => { - await put( - "prod-http-f2a", - { sku: "SKU-HF2", price: { amount: 100, currency: "USD" } }, - { "Idempotency-Key": "kf2a" }, - ); - const conflict = await put("prod-http-f2b", { sku: "SKU-HF2" }, { "Idempotency-Key": "kf2b" }); - expect(conflict.status).toBe(409); - expect(conflict.body).toEqual({ ok: false, error: "SKU_TAKEN", sku: "SKU-HF2" }); - // No internal message/stack/constraint detail leaks. - expect(JSON.stringify(conflict.body)).not.toMatch(/constraint|violates|duplicate key/i); - expect(conflict.body).not.toHaveProperty("stack"); - // No row was minted for the loser. - const read = await get("prod-http-f2b"); - expect(read.body).toBeNull(); - - // Soft-deleting the holder frees the sku — the same PUT now succeeds. - await del("prod-http-f2a", { "Idempotency-Key": "kf2-del" }); - const retry = await put("prod-http-f2b", { sku: "SKU-HF2" }, { "Idempotency-Key": "kf2c" }); - expect(retry.status).toBe(200); - }); - - // -- the two RENAME refusals, on the integrator's own upsert --------------- - // The sync PUT can rename a sku exactly as the admin edit can, so it meets the - // same two refusals and answers them in this route's own envelope (`error`, - // beside `SKU_TAKEN`) rather than falling through to an opaque 500. - - test("a rename ONTO an occupied inventory sku is a structured 409 SKU_STOCK_CONFLICT — nothing leaked, nothing moved", async () => { - await put( - "prod-http-f3", - { sku: "SKU-HF3", price: { amount: 100, currency: "USD" } }, - { "Idempotency-Key": "kf3a" }, - ); - await server.seed("SKU-HF3", 7); - // An inventory row under no live product: what a sku renamed away from - // leaves behind (retained at zero), or a deleted product's sku. - await server.seed("SKU-HF3-TAKEN", 2); - - const refused = await put( - "prod-http-f3", - { sku: "SKU-HF3-TAKEN" }, - { "Idempotency-Key": "kf3b" }, - ); - expect(refused.status).toBe(409); - expect(refused.body).toEqual({ - ok: false, - error: "SKU_STOCK_CONFLICT", - fromSku: "SKU-HF3", - toSku: "SKU-HF3-TAKEN", - }); - expect(refused.body).not.toHaveProperty("stack"); - expect(JSON.stringify(refused.body)).not.toMatch( - /constraint|violates|duplicate key|inventory|reservation|SkuStockConflict|SkuHeldStock|\.ts:/i, - ); - - // The rename and the carry are one transaction: the product still holds its - // sku, and neither count moved by a unit. - expect((await get("prod-http-f3")).body).toMatchObject({ sku: "SKU-HF3" }); - expect(await server.onHand("SKU-HF3")).toBe(7); - expect(await server.onHand("SKU-HF3-TAKEN")).toBe(2); - }); - - test("a rename with LIVE HOLDS against the source is a structured 409 SKU_HELD_STOCK carrying the count", async () => { - await put( - "prod-http-f4", - { sku: "SKU-HF4", price: { amount: 100, currency: "USD" } }, - { "Idempotency-Key": "kf4a" }, - ); - await server.seed("SKU-HF4", 9); - const reserved = await fetch(`${server.baseUrl}/inventory/reserve`, { - method: "POST", - headers: { "content-type": "application/json", "Idempotency-Key": "kf4-hold" }, - body: JSON.stringify({ sku: "SKU-HF4", qty: 3 }), - }); - expect(reserved.status).toBe(200); - - const refused = await put( - "prod-http-f4", - { sku: "SKU-HF4-NEW" }, - { "Idempotency-Key": "kf4b" }, - ); - expect(refused.status).toBe(409); - expect(refused.body).toEqual({ - ok: false, - error: "SKU_HELD_STOCK", - sku: "SKU-HF4", - liveHolds: 1, - }); - expect(refused.body).not.toHaveProperty("stack"); - - expect((await get("prod-http-f4")).body).toMatchObject({ sku: "SKU-HF4" }); - // The hold's units are already out of on_hand and stay out of it. - expect(await server.onHand("SKU-HF4")).toBe(6); - }); - - // -- POST /products/:id/commerce/activate (the afterPublish→activate follow-up) -- - - test("POST .../commerce/activate flips a row to active=true", async () => { - await put( - "prod-http-act1", - { sku: "SKU-HACT1", price: { amount: 100, currency: "USD" } }, - { "Idempotency-Key": "kact1" }, - ); - const res = await activate("prod-http-act1", { "Idempotency-Key": "pub-1" }); - expect(res.status).toBe(200); - expect(res.body).toEqual({ ok: true }); - - const read = await get("prod-http-act1"); - expect(read.body).toMatchObject({ active: true, sku: "SKU-HACT1" }); - }); - - test("POST .../commerce/activate replayed (or called on an already-active row) is a stable no-op", async () => { - await put( - "prod-http-act2", - { sku: "SKU-HACT2", price: { amount: 100, currency: "USD" } }, - { "Idempotency-Key": "kact2" }, - ); - await activate("prod-http-act2", { "Idempotency-Key": "pub-1" }); - const first = await get("prod-http-act2"); - - await activate("prod-http-act2", { "Idempotency-Key": "pub-2" }); - const again = await get("prod-http-act2"); - - expect(again.body).toMatchObject({ active: true }); - expect(again.body?.["updatedAt"]).toBe(first.body?.["updatedAt"]); - }); - - test("POST .../commerce/activate on a SOFT-DELETED product does NOT resurrect it", async () => { - await put( - "prod-http-act3", - { sku: "SKU-HACT3", price: { amount: 100, currency: "USD" } }, - { "Idempotency-Key": "kact3" }, - ); - await del("prod-http-act3", { "Idempotency-Key": "del-1" }); - - const res = await activate("prod-http-act3", { "Idempotency-Key": "pub-1" }); - expect(res.status).toBe(200); // fire-and-forget action route: never a hard error - - const read = await get("prod-http-act3"); - expect(read.body).toMatchObject({ active: false }); - expect(read.body?.["deletedAt"]).not.toBeNull(); - }); - - test("POST .../commerce/activate on an unknown product_id is a no-op (200, no row minted)", async () => { - const res = await activate("prod-http-act-unknown", { "Idempotency-Key": "pub-1" }); - expect(res.status).toBe(200); - expect(res.body).toEqual({ ok: true }); - const read = await get("prod-http-act-unknown"); - expect(read.body).toBeNull(); - }); - - test("POST .../commerce/activate with a missing Idempotency-Key header returns 400", async () => { - const res = await activate("prod-http-act4"); - expect(res.status).toBe(400); - }); - - // -- honest end-to-end wire proof: unpublished stays inactive ----------- - - test("a saved (priced) product that is never activated stays inactive over the wire", async () => { - await put( - "prod-http-act5", - { sku: "SKU-HACT5", price: { amount: 100, currency: "USD" } }, - { "Idempotency-Key": "kact5" }, - ); - const read = await get("prod-http-act5"); - expect(read.body).toMatchObject({ active: false }); - }); - - // -- POST /products/:id/commerce/deactivate (the afterUnpublish→deactivate follow-up) -- - - test("POST .../commerce/deactivate flips an active row back to active=false", async () => { - await put( - "prod-http-deact1", - { sku: "SKU-HDEACT1", price: { amount: 100, currency: "USD" } }, - { "Idempotency-Key": "kdeact1" }, - ); - await activate("prod-http-deact1", { "Idempotency-Key": "pub-1" }); - expect((await get("prod-http-deact1")).body).toMatchObject({ active: true }); - - const res = await deactivate("prod-http-deact1", { "Idempotency-Key": "unpub-1" }); - expect(res.status).toBe(200); - expect(res.body).toEqual({ ok: true }); - - const read = await get("prod-http-deact1"); - // The publish gate closes; the row stays live (not soft-deleted). - expect(read.body).toMatchObject({ active: false, sku: "SKU-HDEACT1" }); - expect(read.body?.["deletedAt"]).toBeNull(); - }); - - test("POST .../commerce/deactivate replayed (or on an already-inactive row) is a stable no-op", async () => { - await put( - "prod-http-deact2", - { sku: "SKU-HDEACT2", price: { amount: 100, currency: "USD" } }, - { "Idempotency-Key": "kdeact2" }, - ); - await activate("prod-http-deact2", { "Idempotency-Key": "pub-1" }); - await deactivate("prod-http-deact2", { "Idempotency-Key": "unpub-1" }); - const first = await get("prod-http-deact2"); - - await deactivate("prod-http-deact2", { "Idempotency-Key": "unpub-2" }); - const again = await get("prod-http-deact2"); - - expect(again.body).toMatchObject({ active: false }); - expect(again.body?.["updatedAt"]).toBe(first.body?.["updatedAt"]); - }); - - test("POST .../commerce/deactivate on a SOFT-DELETED product leaves it soft-deleted", async () => { - await put( - "prod-http-deact3", - { sku: "SKU-HDEACT3", price: { amount: 100, currency: "USD" } }, - { "Idempotency-Key": "kdeact3" }, - ); - await del("prod-http-deact3", { "Idempotency-Key": "del-1" }); - - const res = await deactivate("prod-http-deact3", { "Idempotency-Key": "unpub-1" }); - expect(res.status).toBe(200); // fire-and-forget action route: never a hard error - - const read = await get("prod-http-deact3"); - expect(read.body).toMatchObject({ active: false }); - expect(read.body?.["deletedAt"]).not.toBeNull(); - }); - - test("POST .../commerce/deactivate on an unknown product_id is a no-op (200, no row minted)", async () => { - const res = await deactivate("prod-http-deact-unknown", { "Idempotency-Key": "unpub-1" }); - expect(res.status).toBe(200); - expect(res.body).toEqual({ ok: true }); - const read = await get("prod-http-deact-unknown"); - expect(read.body).toBeNull(); - }); - - test("POST .../commerce/deactivate with a missing Idempotency-Key header returns 400", async () => { - const res = await deactivate("prod-http-deact4"); - expect(res.status).toBe(400); - }); - - // -- publish-gate convergence under out-of-order delivery (over the wire) -- - - test("out-of-order over the wire: deactivate@T2 then a STALE activate@T1 leaves the product NON-purchasable (active=false)", async () => { - const T1 = "2026-07-11T01:00:00.000Z"; - const T2 = "2026-07-11T02:00:00.000Z"; - const T3 = "2026-07-11T03:00:00.000Z"; - await put( - "prod-http-conv1", - { sku: "SKU-HCONV1", price: { amount: 100, currency: "USD" } }, - { "Idempotency-Key": "kconv1" }, - ); - // publish@T1 then unpublish@T2 applied in order → inactive. - await activate("prod-http-conv1", { "Idempotency-Key": "pub-early" }, T1); - await deactivate("prod-http-conv1", { "Idempotency-Key": "unpub-2" }, T2); - expect((await get("prod-http-conv1")).body).toMatchObject({ active: false }); - - // A DELAYED, re-ordered stale activate (older T1) must NOT re-latch it. - const stale = await activate("prod-http-conv1", { "Idempotency-Key": "pub-1-late" }, T1); - expect(stale.status).toBe(200); - expect((await get("prod-http-conv1")).body).toMatchObject({ active: false }); - - // A genuinely newer publish (T3 > T2) still wins — the gate advanced, - // it is not stuck. - await activate("prod-http-conv1", { "Idempotency-Key": "pub-3" }, T3); - expect((await get("prod-http-conv1")).body).toMatchObject({ active: true }); - }); - - test("out-of-order over the wire: activate@T2 then a STALE deactivate@T1 keeps the product active=true", async () => { - const T1 = "2026-07-11T01:00:00.000Z"; - const T2 = "2026-07-11T02:00:00.000Z"; - await put( - "prod-http-conv2", - { sku: "SKU-HCONV2", price: { amount: 100, currency: "USD" } }, - { "Idempotency-Key": "kconv2" }, - ); - await deactivate("prod-http-conv2", { "Idempotency-Key": "unpub-early" }, T1); - await activate("prod-http-conv2", { "Idempotency-Key": "pub-2" }, T2); - expect((await get("prod-http-conv2")).body).toMatchObject({ active: true }); - - const stale = await deactivate("prod-http-conv2", { "Idempotency-Key": "unpub-1-late" }, T1); - expect(stale.status).toBe(200); - expect((await get("prod-http-conv2")).body).toMatchObject({ active: true }); - }); - - test("POST .../commerce/activate|deactivate with a missing/malformed contentUpdatedAt body is a 400 (F1 — the gate watermark must be exact)", async () => { - await put( - "prod-http-conv3", - { sku: "SKU-HCONV3", price: { amount: 100, currency: "USD" } }, - { "Idempotency-Key": "kconv3" }, - ); - for (const bad of ["ZZZZ", "2026-07-11", "2026-07-11T02:00:00Z", "not-a-date"]) { - const a = await fetch(`${server.baseUrl}/products/prod-http-conv3/commerce/activate`, { - method: "POST", - headers: { "content-type": "application/json", "Idempotency-Key": `kbad-${bad}` }, - body: JSON.stringify({ contentUpdatedAt: bad }), - }); - expect(a.status, `activate contentUpdatedAt=${JSON.stringify(bad)}`).toBe(400); - } - // A body with no contentUpdatedAt at all is also rejected (required). - const missing = await fetch(`${server.baseUrl}/products/prod-http-conv3/commerce/deactivate`, { - method: "POST", - headers: { "content-type": "application/json", "Idempotency-Key": "kmissing" }, - body: JSON.stringify({}), - }); - expect(missing.status).toBe(400); - // None of the rejected requests changed state — never activated. - expect((await get("prod-http-conv3")).body).toMatchObject({ active: false }); - }); - // -- Variants: the wire half of the two-writer split (ADR-0016) ----------- - // - // The port's own contract suite is the spec for what these operations MEAN; - // what is pinned here is what an INTEGRATOR sees — the routes, the two - // bodies that cannot reach each other's columns, the money serialization, - // and the fact that every documented refusal arrives as a typed envelope - // with a machine code rather than as an opaque 500. - - async function request( - method: string, - path: string, - body?: unknown, - headers: Record = {}, - ): Promise { - const res = await fetch(`${server.baseUrl}${path}`, { - method, - headers: { "content-type": "application/json", ...headers }, - body: body === undefined ? undefined : JSON.stringify(body), - }); - return { status: res.status, body: (await res.json()) as Record | null }; - } - - const VWM = "2026-08-08T00:00:00.000Z"; - - function declare( - id: string, - variantKey: string, - body: Record = { title: variantKey, contentUpdatedAt: VWM }, - key = `dcl-${id}-${variantKey}`, - ): Promise { - return request("PUT", `/products/${id}/variants/${variantKey}`, body, { - "Idempotency-Key": key, - }); - } - - function edit( - id: string, - variantKey: string, - body: Record, - key = `edt-${id}-${variantKey}`, - ): Promise { - return request("PATCH", `/products/${id}/variants/${variantKey}`, body, { - "Idempotency-Key": key, - }); - } - - function listVariants(id: string, headers: Record = {}): Promise { - return request("GET", `/products/${id}/variants`, undefined, headers); - } - - /** The operator's projection of the same read — orphans included and flagged. */ - function listVariantsAsOperator(id: string): Promise { - return listVariants(id, { "X-Internal-Token": server.internalToken ?? "" }); - } - - /** A parent product, priced in USD, so the currency guards have an anchor. */ - async function parent(id: string, skuValue: string, amount = 1000): Promise { - const res = await put( - id, - { sku: skuValue, price: { amount, currency: "USD" }, title: id }, - { "Idempotency-Key": `parent-${id}` }, - ); - expect(res.status).toBe(200); - } - - test("GET variants of a product that has declared none is an empty list, never a 404", async () => { - expect(await listVariants("prod-v-unknown")).toEqual({ - status: 200, - body: { variants: [] }, - }); - }); - - test("PUT declares a variant: the name is written, sku and price are NOT (declare then price)", async () => { - await parent("prod-v1", "SKU-V1"); - const res = await declare("prod-v1", "large", { title: "Large", contentUpdatedAt: VWM }); - expect(res.status).toBe(200); - expect(res.body).toMatchObject({ - productId: "prod-v1", - variantKey: "large", - title: "Large", - sku: null, - // ABSENT IS ABSENT: a declared-but-unpriced size is null, never 0 and - // never a zero-amount money object. - price: null, - orphanedAt: null, - }); - // Write-path bookkeeping never crosses this wire. - expect(res.body).not.toHaveProperty("idempotencyKey"); - expect(res.body).not.toHaveProperty("contentUpdatedAt"); - }); - - test("the declare channel REJECTS commercial fields rather than silently dropping them", async () => { - await parent("prod-v2", "SKU-V2"); - await declare("prod-v2", "small"); - for (const bad of [{ sku: "SKU-SNEAK" }, { price: { amount: 100, currency: "USD" } }]) { - const res = await declare("prod-v2", "small", { title: "Small", ...bad }, "dcl-sneak"); - expect(res.status, JSON.stringify(bad)).toBe(400); - expect(res.body?.error).toBe("invalid request body"); - } - // And nothing leaked through on the way past. - const list = await listVariants("prod-v2"); - const rows = list.body?.variants as Array>; - expect(rows[0]).toMatchObject({ sku: null, price: null }); - }); - - test("the admin edit REJECTS a title rather than silently dropping it — and the stored name is unchanged", async () => { - await parent("prod-v3", "SKU-V3"); - const declared = await declare("prod-v3", "medium", { title: "Medium", contentUpdatedAt: VWM }); - const res = await edit("prod-v3", "medium", { - title: "Renamed by the wrong writer", - expectedUpdatedAt: declared.body?.updatedAt as string, - }); - expect(res.status).toBe(400); - expect(res.body?.error).toBe("invalid request body"); - const rows = (await listVariants("prod-v3")).body?.variants as Array>; - expect(rows[0]?.title).toBe("Medium"); - }); - - test("PATCH prices a variant — integer minor units + ISO-4217 — and the list reflects it with a coarse stock signal", async () => { - await parent("prod-v4", "SKU-V4"); - const declared = await declare("prod-v4", "large", { title: "Large", contentUpdatedAt: VWM }); - const res = await edit("prod-v4", "large", { - sku: "SKU-V4-L", - price: { amount: 2599, currency: "USD" }, - expectedUpdatedAt: declared.body?.updatedAt as string, - }); - expect(res.status).toBe(200); - expect(res.body).toMatchObject({ - variantKey: "large", - sku: "SKU-V4-L", - price: { amount: 2599, currency: "USD" }, - // The name survives a commerce edit byte-identical: the two writers - // cannot reach each other's column. - title: "Large", - }); - - const list = await listVariants("prod-v4"); - const rows = list.body?.variants as Array>; - expect(rows).toHaveLength(1); - // The edit seeded the sku's inventory row at zero — a KNOWN sku that is out - // of stock, which reads as not purchasable. - expect(rows[0]?.inStock).toBe(false); - // The exact count is NOT published on this storefront-reachable read. - expect(rows[0]).not.toHaveProperty("onHand"); - }); - - // This read is UNAUTHENTICATED (the write gate covers non-GET verbs only) and - // exists for the storefront picker, so it carries live sizes and nothing else. - // A discontinued size's name and its last price are not public data; surfacing - // orphans is the internal-token console's job, where unit cost and the exact - // on-hand count already live. - test("the list is ordered by variant key and EXCLUDES orphans — the public read is live rows only", async () => { - await parent("prod-v5", "SKU-V5"); - for (const k of ["small", "large", "medium"]) await declare("prod-v5", k); - const dropped = await request( - "POST", - "/products/prod-v5/variants/medium/deactivate", - { contentUpdatedAt: "2026-08-09T00:00:00.000Z" }, - { "Idempotency-Key": "drop-v5-medium" }, - ); - expect(dropped.status).toBe(200); - - const rows = (await listVariants("prod-v5")).body?.variants as Array>; - expect(rows.map((r) => r.variantKey)).toEqual(["large", "small"]); - // Every row the PUBLIC read emits is live, so the flag is present and - // always null — an anonymous caller never has to branch on it. - expect(rows.every((r) => r.orphanedAt === null)).toBe(true); - - // The operator sees all three, with the tombstone flagged — the same route, - // a second projection, exactly as `GET /orders/:orderId` already works. - const all = (await listVariantsAsOperator("prod-v5")).body?.variants as Array< - Record - >; - expect(all.map((r) => r.variantKey)).toEqual(["large", "medium", "small"]); - expect(all.find((r) => r.variantKey === "medium")?.orphanedAt).toEqual(expect.any(String)); - expect(all.find((r) => r.variantKey === "large")?.orphanedAt).toBeNull(); - - // A WRONG token does not unlock, and does not announce that it was wrong: - // the caller gets the public projection, so this is no oracle for whether - // a token is configured. - const wrong = (await listVariants("prod-v5", { "X-Internal-Token": "not-the-token" })).body - ?.variants as Array>; - expect(wrong.map((r) => r.variantKey)).toEqual(["large", "small"]); - }); - - test("a product whose every size is orphaned reads as an empty list, not as tombstones", async () => { - await parent("prod-v5b", "SKU-V5B"); - const declared = await declare("prod-v5b", "large"); - const priced = await edit("prod-v5b", "large", { - sku: "SKU-V5B-L", - price: { amount: 7700, currency: "USD" }, - expectedUpdatedAt: declared.body?.updatedAt as string, - }); - expect(priced.status).toBe(200); - await request( - "POST", - "/products/prod-v5b/variants/large/deactivate", - { contentUpdatedAt: "2026-08-09T00:00:00.000Z" }, - { "Idempotency-Key": "drop-v5b-large" }, - ); - expect((await listVariants("prod-v5b")).body).toEqual({ variants: [] }); - }); - - test("editing an unknown key, and editing an ORPHANED one, are both VARIANT_NOT_FOUND — an edit is neither a create nor a resurrection", async () => { - await parent("prod-v6", "SKU-V6"); - const unknown = await edit("prod-v6", "nope", { - price: { amount: 100, currency: "USD" }, - expectedUpdatedAt: VWM, - }); - expect(unknown.status).toBe(404); - expect(unknown.body).toEqual({ ok: false, error: "VARIANT_NOT_FOUND" }); - - const declared = await declare("prod-v6", "large"); - await request( - "POST", - "/products/prod-v6/variants/large/deactivate", - { contentUpdatedAt: "2026-08-09T00:00:00.000Z" }, - { "Idempotency-Key": "drop-v6-large" }, - ); - const orphaned = await edit( - "prod-v6", - "large", - { - price: { amount: 100, currency: "USD" }, - expectedUpdatedAt: declared.body?.updatedAt as string, - }, - "edt-v6-orphan", - ); - expect(orphaned.status).toBe(404); - expect(orphaned.body).toEqual({ ok: false, error: "VARIANT_NOT_FOUND" }); - // No row was minted by either refusal — and the orphan the second one - // addressed is absent from the public read rather than resurrected by it. - expect((await listVariants("prod-v6")).body).toEqual({ variants: [] }); - }); - - test("a lost update is a 409 STALE_EDIT carrying the watermark to reload from", async () => { - await parent("prod-v7", "SKU-V7"); - const declared = await declare("prod-v7", "large"); - const stale = await edit("prod-v7", "large", { - price: { amount: 100, currency: "USD" }, - expectedUpdatedAt: "2020-01-01T00:00:00.000Z", - }); - expect(stale.status).toBe(409); - expect(stale.body).toMatchObject({ ok: false, error: "STALE_EDIT" }); - expect(stale.body?.currentUpdatedAt).toBe(declared.body?.updatedAt); - }); - - test("a price in a currency the product cannot honour is a 409 CURRENCY_MISMATCH, not a mixed-currency row", async () => { - await parent("prod-v8", "SKU-V8"); - const declared = await declare("prod-v8", "large"); - const res = await edit("prod-v8", "large", { - sku: "SKU-V8-L", - price: { amount: 100, currency: "EUR" }, - expectedUpdatedAt: declared.body?.updatedAt as string, - }); - expect(res.status).toBe(409); - expect(res.body).toMatchObject({ ok: false, error: "CURRENCY_MISMATCH" }); - expect(res.body).toHaveProperty("currency"); - }); - - test("a sku another LIVE sellable unit holds is a 409 SKU_TAKEN — uniqueness spans both tables", async () => { - await parent("prod-v9", "SKU-V9"); - await parent("prod-v9-other", "SKU-V9-TAKEN"); - const declared = await declare("prod-v9", "large"); - const res = await edit("prod-v9", "large", { - sku: "SKU-V9-TAKEN", - price: { amount: 100, currency: "USD" }, - expectedUpdatedAt: declared.body?.updatedAt as string, - }); - expect(res.status).toBe(409); - expect(res.body).toEqual({ ok: false, error: "SKU_TAKEN", sku: "SKU-V9-TAKEN" }); - }); - - test("a rename onto a sku that already has an inventory row is a 409 SKU_STOCK_CONFLICT naming both skus", async () => { - await parent("prod-v10", "SKU-V10"); - await server.seed("SKU-V10-OCCUPIED", 4); - const declared = await declare("prod-v10", "large"); - const first = await edit("prod-v10", "large", { - sku: "SKU-V10-L", - price: { amount: 100, currency: "USD" }, - expectedUpdatedAt: declared.body?.updatedAt as string, - }); - expect(first.status).toBe(200); - const rename = await edit( - "prod-v10", - "large", - { sku: "SKU-V10-OCCUPIED", expectedUpdatedAt: first.body?.updatedAt as string }, - "edt-v10-rename", - ); - expect(rename.status).toBe(409); - expect(rename.body).toEqual({ - ok: false, - error: "SKU_STOCK_CONFLICT", - fromSku: "SKU-V10-L", - toSku: "SKU-V10-OCCUPIED", - }); - // Stock is never merged and never moved by a refusal. - expect(await server.onHand("SKU-V10-OCCUPIED")).toBe(4); - }); - - test("a rename away from a sku with LIVE HOLDS is a 409 SKU_HELD_STOCK naming the sku and the hold count", async () => { - await parent("prod-v11", "SKU-V11"); - const declared = await declare("prod-v11", "large"); - const priced = await edit("prod-v11", "large", { - sku: "SKU-V11-L", - price: { amount: 100, currency: "USD" }, - expectedUpdatedAt: declared.body?.updatedAt as string, - }); - expect(priced.status).toBe(200); - await server.seed("SKU-V11-L", 5); - - // Two units of that size are held. Taken through the raw inventory - // primitive rather than a cart line, because a variant sku is not addable - // to a cart yet — and because THE SKU-RENAME RULE is a property of the sku - // column, not of one caller: any live reservation naming it blocks the - // rename, whatever took it. - const held = await request( - "POST", - "/inventory/reserve", - { sku: "SKU-V11-L", qty: 2 }, - { "Idempotency-Key": "hold-v11" }, - ); - expect(held.status).toBe(200); - expect(held.body?.ok).toBe(true); - - const rename = await edit( - "prod-v11", - "large", - { sku: "SKU-V11-RENAMED", expectedUpdatedAt: priced.body?.updatedAt as string }, - "edt-v11-rename", - ); - expect(rename.status).toBe(409); - expect(rename.body).toMatchObject({ - ok: false, - error: "SKU_HELD_STOCK", - sku: "SKU-V11-L", - liveHolds: 1, - }); - }); - - test("a zero or negative price is a 400 at the boundary — an absent price is expressed by omitting the field", async () => { - await parent("prod-v12", "SKU-V12"); - const declared = await declare("prod-v12", "large"); - for (const amount of [0, -100]) { - const res = await edit( - "prod-v12", - "large", - { - price: { amount, currency: "USD" }, - expectedUpdatedAt: declared.body?.updatedAt as string, - }, - `edt-v12-${String(amount)}`, - ); - expect(res.status, `amount=${String(amount)}`).toBe(400); - expect(res.body?.error).toBe("invalid request body"); - } - }); - - test("an identity-less variant key is a 400 MISSING_VARIANT_KEY on every writer, never a 500", async () => { - await parent("prod-v13", "SKU-V13"); - const blank = encodeURIComponent(" "); - const calls: Array<[string, string, unknown]> = [ - ["PUT", `/products/prod-v13/variants/${blank}`, { title: "x" }], - ["PATCH", `/products/prod-v13/variants/${blank}`, { expectedUpdatedAt: VWM }], - ["POST", `/products/prod-v13/variants/${blank}/deactivate`, { contentUpdatedAt: VWM }], - ]; - for (const [method, path, body] of calls) { - const res = await request(method, path, body, { "Idempotency-Key": `blank-${method}` }); - expect(res.status, `${method} ${path}`).toBe(400); - expect(res.body).toEqual({ error: "MISSING_VARIANT_KEY" }); - } - }); - - test("every variant writer requires an Idempotency-Key", async () => { - const calls: Array<[string, string, unknown]> = [ - ["PUT", "/products/prod-v14/variants/large", { title: "Large" }], - ["PATCH", "/products/prod-v14/variants/large", { expectedUpdatedAt: VWM }], - ["POST", "/products/prod-v14/variants/large/deactivate", { contentUpdatedAt: VWM }], - ]; - for (const [method, path, body] of calls) { - const res = await request(method, path, body); - expect(res.status, `${method} ${path}`).toBe(400); - expect(res.body?.error).toBe("missing Idempotency-Key header"); - } - }); - - test("deactivate is retained-not-deleted, replays cleanly, and a stale watermark is a no-op", async () => { - await parent("prod-v15", "SKU-V15"); - const declared = await declare("prod-v15", "large"); - const priced = await edit("prod-v15", "large", { - sku: "SKU-V15-L", - price: { amount: 4200, currency: "USD" }, - expectedUpdatedAt: declared.body?.updatedAt as string, - }); - expect(priced.status).toBe(200); - await server.seed("SKU-V15-L", 11); - - // An unknown key is a no-op, never a 404 and never a minted row. - const unknown = await request( - "POST", - "/products/prod-v15/variants/never-declared/deactivate", - { contentUpdatedAt: "2026-08-09T00:00:00.000Z" }, - { "Idempotency-Key": "drop-v15-unknown" }, - ); - expect(unknown).toEqual({ status: 200, body: { ok: true } }); - - const drop = await request( - "POST", - "/products/prod-v15/variants/large/deactivate", - { contentUpdatedAt: "2026-08-09T00:00:00.000Z" }, - { "Idempotency-Key": "drop-v15-large" }, - ); - expect(drop.status).toBe(200); - - // Gone from the public read — and RETAINED, which the operator's projection - // shows directly: this is the transition's only HTTP-observable effect, and - // without the token-gated mode it could be driven and never seen. - expect((await listVariants("prod-v15")).body).toEqual({ variants: [] }); - const tombstone = (await listVariantsAsOperator("prod-v15")).body?.variants as Array< - Record - >; - expect(tombstone).toHaveLength(1); - expect(tombstone[0]).toMatchObject({ - variantKey: "large", - // Retained in full: the row keeps its sku and its price. - sku: "SKU-V15-L", - price: { amount: 4200, currency: "USD" }, - }); - expect(tombstone[0]?.orphanedAt).toEqual(expect.any(String)); - expect(await server.onHand("SKU-V15-L")).toBe(11); - - const back = await declare( - "prod-v15", - "large", - { title: "Large", contentUpdatedAt: "2026-08-10T00:00:00.000Z" }, - "dcl-v15-resurrect", - ); - expect(back.status).toBe(200); - expect(back.body).toMatchObject({ - variantKey: "large", - sku: "SKU-V15-L", - price: { amount: 4200, currency: "USD" }, - orphanedAt: null, - }); - const rows = (await listVariants("prod-v15")).body?.variants as Array>; - expect(rows).toHaveLength(1); - expect(rows[0]).toMatchObject({ variantKey: "large", sku: "SKU-V15-L" }); - expect(await server.onHand("SKU-V15-L")).toBe(11); - }); - - test("the deactivate body is strict too — an unknown key is a 400, never a silent strip", async () => { - await parent("prod-v16", "SKU-V16"); - await declare("prod-v16", "large"); - const res = await request( - "POST", - "/products/prod-v16/variants/large/deactivate", - { contentUpdatedAt: VWM, title: "not this writer's field" }, - { "Idempotency-Key": "drop-v16-strict" }, - ); - expect(res.status).toBe(400); - expect(res.body?.error).toBe("invalid request body"); - // Refused whole: the size is still live and still listed. - const rows = (await listVariants("prod-v16")).body?.variants as Array>; - expect(rows).toHaveLength(1); - expect(rows[0]?.orphanedAt).toBeNull(); - }); -}); diff --git a/packages/service/test/products-list-low-stock-schema.test.ts b/packages/service/test/products-list-low-stock-schema.test.ts deleted file mode 100644 index 452e52e5..00000000 --- a/packages/service/test/products-list-low-stock-schema.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { isValidLowStockThreshold, MAX_LOW_STOCK_THRESHOLD } from "@otta-sh/domain"; -import { describe, expect, test } from "vitest"; -import { - lowStockQuery, - productListFilterSchema, - productsListQuery, - settingsBody, -} from "../src/schemas.js"; - -// The FAST LOOP half of the low-stock query-parameter guard (port doc, the -// admin Products list filter). The HTTP half — that a bad value 400s rather -// than 500s against a live server, and that a valid one actually filters — -// lives in `admin-products-http.test.ts`, which is `describe.skipIf(PG === -// undefined)`. These cases need no server and no database, so they fire on -// every local run: if `lowStockThreshold` ever loses its constraint again (it -// was once absent from `productListFilterSchema` while -// `lowStockQuery`/`settingsBody` already had it), this file goes red -// immediately. - -describe("productsListQuery reads `lowStockThreshold` off the raw query string as DIGITS, not by coercion", () => { - test("a valid non-negative integer string parses to a number", () => { - const res = productsListQuery.safeParse({ lowStockThreshold: "5" }); - expect(res.success).toBe(true); - if (!res.success) throw new Error("unreachable"); - expect(res.data.lowStockThreshold).toBe(5); - }); - - test("zero is its own valid boundary, not falsy-and-dropped", () => { - const res = productsListQuery.safeParse({ lowStockThreshold: "0" }); - expect(res.success).toBe(true); - if (!res.success) throw new Error("unreachable"); - expect(res.data.lowStockThreshold).toBe(0); - }); - - test("omitted stays omitted — no default, no coercion to 0", () => { - const res = productsListQuery.safeParse({}); - expect(res.success).toBe(true); - if (!res.success) throw new Error("unreachable"); - expect(res.data.lowStockThreshold).toBeUndefined(); - }); - - test("REJECTS negative, fractional, and non-numeric strings — a 400, never silently clamped", () => { - for (const bad of ["-1", "2.5", "not-a-number", "NaN", "Infinity"]) { - const res = productsListQuery.safeParse({ lowStockThreshold: bad }); - expect(res.success, bad).toBe(false); - } - }); - - test("REJECTS `?lowStockThreshold=` — an EMPTY value is not a threshold of zero", () => { - // THE ONE `Number()` WOULD HAVE WAVED THROUGH, and the reason this field - // gates on digits instead of coercing. `Number("")` is 0, a perfectly - // valid threshold, so an empty parameter would have narrowed the list to - // out-of-stock rows — silently, and to the one answer an operator who - // typed nothing cannot have meant. Absent and empty must not diverge. - const res = productsListQuery.safeParse({ lowStockThreshold: "" }); - expect(res.success).toBe(false); - }); - - test("REJECTS the other shapes `Number()` accepts — hex, exponent, and padded digits", () => { - // `Number` reads "0x10" as 16, "1e2" as 100 and " 7 " as 7. None of those - // is a threshold a query string should be allowed to express: the value - // an operator sees in the URL would not be the value the predicate uses. - for (const bad of ["0x10", "1e2", " 7 ", "+7", "7 "]) { - const res = productsListQuery.safeParse({ lowStockThreshold: bad }); - expect(res.success, bad).toBe(false); - } - }); - - test("REJECTS a threshold above int4 — digits alone are not the whole domain", () => { - // `inventory.on_hand` is a Postgres `integer` and the predicate binds the - // threshold straight into `on_hand <= $1`. Above `int4`'s maximum Postgres - // throws on the bind while better-sqlite3 and the fake accept it and - // answer — the three-way dialect disagreement the port's guard exists to - // make unreachable, arriving as a 500 through the very catch that turns a - // bad threshold into a 400. A shape gate does not stop it; the bound does. - expect( - productsListQuery.safeParse({ lowStockThreshold: String(MAX_LOW_STOCK_THRESHOLD) }).success, - ).toBe(true); - expect( - productsListQuery.safeParse({ lowStockThreshold: String(MAX_LOW_STOCK_THRESHOLD + 1) }) - .success, - ).toBe(false); - expect(productsListQuery.safeParse({ lowStockThreshold: "99999999999999" }).success).toBe( - false, - ); - }); -}); - -describe("the ceiling is the SAME number everywhere it is enforced", () => { - test("the domain guard agrees with the wire, at the boundary and one past it", () => { - // ONE DEFINITION, THREE LAYERS. The query string, the cursor-embedded - // filter and the port's own guard all bound on `MAX_LOW_STOCK_THRESHOLD`; - // a value the wire lets through and the guard refuses (or the reverse) is - // the drift `isValidLowStockThreshold` was extracted to prevent. - expect(isValidLowStockThreshold(MAX_LOW_STOCK_THRESHOLD)).toBe(true); - expect(isValidLowStockThreshold(MAX_LOW_STOCK_THRESHOLD + 1)).toBe(false); - expect(isValidLowStockThreshold(Number.MAX_SAFE_INTEGER)).toBe(false); - // ...and the three schemas draw the line in the same place. - expect( - productListFilterSchema.safeParse({ lowStockThreshold: MAX_LOW_STOCK_THRESHOLD }).success, - ).toBe(true); - expect( - productListFilterSchema.safeParse({ lowStockThreshold: MAX_LOW_STOCK_THRESHOLD + 1 }).success, - ).toBe(false); - expect(lowStockQuery.safeParse({ threshold: String(MAX_LOW_STOCK_THRESHOLD) }).success).toBe( - true, - ); - expect( - lowStockQuery.safeParse({ threshold: String(MAX_LOW_STOCK_THRESHOLD + 1) }).success, - ).toBe(false); - }); - - test("the SETTINGS WRITE is bounded too — the saved value is what every later read binds", () => { - // THE PATH THAT NEVER APPEARS IN A URL. An operator saves the threshold - // once; every subsequent list read then binds that stored number into the - // predicate. An unbounded write is therefore the same int4 overflow with a - // longer fuse, and the one the query-string gate cannot see. - expect(settingsBody.safeParse({ lowStockThreshold: MAX_LOW_STOCK_THRESHOLD }).success).toBe( - true, - ); - expect(settingsBody.safeParse({ lowStockThreshold: MAX_LOW_STOCK_THRESHOLD + 1 }).success).toBe( - false, - ); - expect(settingsBody.safeParse({ lowStockThreshold: Number.MAX_SAFE_INTEGER }).success).toBe( - false, - ); - }); -}); - -describe("productListFilterSchema validates `lowStockThreshold` the same domain, one layer in (the cursor-embedded filter)", () => { - test("a valid non-negative integer (already a real number, not a query string) passes", () => { - const res = productListFilterSchema.safeParse({ lowStockThreshold: 5 }); - expect(res.success).toBe(true); - if (!res.success) throw new Error("unreachable"); - expect(res.data.lowStockThreshold).toBe(5); - }); - - test("REJECTS a negative, fractional, or non-finite number — MOD-1's re-validation of a decoded cursor", () => { - for (const bad of [-1, 2.5, Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY]) { - const res = productListFilterSchema.safeParse({ lowStockThreshold: bad }); - expect(res.success, String(bad)).toBe(false); - } - }); - - test("omitted stays omitted, same as every other filter axis here", () => { - const res = productListFilterSchema.safeParse({}); - expect(res.success).toBe(true); - if (!res.success) throw new Error("unreachable"); - expect(res.data.lowStockThreshold).toBeUndefined(); - }); - - test("REJECTS a string — the cursor's own field is a real number, unlike the query string it started from", () => { - const res = productListFilterSchema.safeParse({ lowStockThreshold: "5" }); - expect(res.success).toBe(false); - }); -}); diff --git a/packages/service/test/public-order-redaction.test.ts b/packages/service/test/public-order-redaction.test.ts deleted file mode 100644 index a3500a8d..00000000 --- a/packages/service/test/public-order-redaction.test.ts +++ /dev/null @@ -1,370 +0,0 @@ -import { - cents, - currency as toCurrency, - idempotencyKey, - money, - productId as toProductId, - sku as toSku, -} from "@otta-sh/domain"; -import { - CountingIdGen, - FakeEmailSender, - FixedClock, - InMemoryAddressStore, - InMemoryCartStore, - InMemoryCouponStore, - InMemoryCredentialVerifier, - InMemoryCustomerStore, - InMemoryEntitlementStore, - InMemoryInventoryStore, - InMemoryOrderNotesStore, - InMemoryOrderStore, - InMemoryPaymentEventStore, - InMemoryProductCommerceStore, - InMemoryReportingStore, - InMemorySessionStore, - InMemorySettingsStore, - InMemoryShippingRulesStore, - InMemoryTaxRulesStore, -} from "@otta-sh/domain/testing"; -import { StripePaymentGateway } from "@otta-sh/payments-stripe"; -import type { Hono } from "hono"; -import { beforeEach, describe, expect, test } from "vitest"; -import { createApp } from "../src/app.js"; - -// PR D / ADR-0010 §2: `GET /orders/:orderId` is an unauthenticated, -// capability-URL-only read (guess/leak the order UUID ⇒ a read). Unauthenticated -// callers must get the redacted, guest-confirmation shape; a valid -// X-Internal-Token unlocks the full admin-equivalent view. IO-free, in-memory -// stores, `app.request()` — mirrors `test/service-token.test.ts`'s `makeApp`. - -const INTERNAL_TOKEN = "internal-secret"; -const CLOCK_START = new Date("2026-07-10T00:00:00.000Z"); - -interface TestApp { - app: Hono; - emailSender: FakeEmailSender; - inventory: InMemoryInventoryStore; - productCommerce: InMemoryProductCommerceStore; -} - -function makeApp( - options: { internalToken?: string | undefined } = { internalToken: INTERNAL_TOKEN }, -): TestApp { - const clock = new FixedClock(CLOCK_START); - const inventory = new InMemoryInventoryStore({ idGen: new CountingIdGen("res"), clock }); - const cartStore = new InMemoryCartStore({ - idGen: new CountingIdGen("cart"), - reservationState: (id) => { - try { - return inventory.reservationState(id); - } catch { - return undefined; - } - }, - releaseHold: (id) => { - void inventory.release(id); - }, - }); - const productCommerce = new InMemoryProductCommerceStore({ - clock, - // NOTE: `InMemoryInventoryStore.onHand` returns 0 for an unseeded sku, so - // this wiring COLLAPSES null -> 0. Fine for the coarse `inStock` boolean - // these suites exercise; do NOT assert the products-list `onHand` - // projection through it (the list must distinguish "no inventory row" - // from "out of stock" — see the divergence note in - // `packages/domain/src/ports/inventory-store.ts`'s `getOnHand` doc). - inventoryOnHand: (s) => inventory.onHand(s), - }); - const idGen = new CountingIdGen("id"); - const customerStore = new InMemoryCustomerStore({ idGen, clock }); - const emailSender = new FakeEmailSender(); - const app = createApp({ - store: inventory, - productCommerce, - cartStore, - orderStore: new InMemoryOrderStore({ idGen, clock }), - orderNotesStore: new InMemoryOrderNotesStore({ idGen, clock }), - entitlementStore: new InMemoryEntitlementStore({ idGen, clock }), - paymentEventStore: new InMemoryPaymentEventStore(), - shippingRules: new InMemoryShippingRulesStore(), - taxRules: new InMemoryTaxRulesStore(), - couponStore: new InMemoryCouponStore({ idGen, clock }), - reportingStore: new InMemoryReportingStore(), - settingsStore: new InMemorySettingsStore(), - customerStore, - addressStore: new InMemoryAddressStore({ idGen, clock }), - sessionStore: new InMemorySessionStore({ idGen, clock }), - credentialVerifier: new InMemoryCredentialVerifier({ customerStore, idGen, clock }), - emailSender, - idGen, - gateways: { stripe: new StripePaymentGateway({ webhookSecret: "whsec_gate_test", clock }) }, - clock, - internalToken: options.internalToken, - }); - return { app, emailSender, inventory, productCommerce }; -} - -async function json(res: Response): Promise> { - return (await res.json()) as Record; -} - -const jsonHeaders = { "content-type": "application/json" }; - -/** Seed one physical product with stock (directly on the stores, mirroring - * `helpers/start-test-server.ts`'s `seedProduct`), checkout a one-line cart for - * it, and return the created order id + its full (internal-view) body. */ -async function checkoutOneLine( - testApp: TestApp, - opts: { sku?: string; productId?: string; buyerRef?: string } = {}, -): Promise<{ orderId: string; order: Record }> { - const { app, inventory, productCommerce } = testApp; - const sku = opts.sku ?? "SKU-A"; - const productId = opts.productId ?? "pa"; - const buyerRef = opts.buyerRef ?? "buyer@example.com"; - await productCommerce.upsert( - { - productId: toProductId(productId), - sku: toSku(sku), - price: money(cents(1200), toCurrency("USD")), - title: "Widget A", - productKind: "physical", - }, - idempotencyKey(`seed-${productId}`), - ); - await inventory.seedOnHand(sku, 5); - const cartRes = await app.request("/carts", { - method: "POST", - headers: jsonHeaders, - body: JSON.stringify({ currency: "USD" }), - }); - const cart = await json(cartRes); - const cartId = cart["cartId"] as string; - const addRes = await app.request(`/carts/${cartId}/lines`, { - method: "POST", - headers: { ...jsonHeaders, "Idempotency-Key": `add-${cartId}` }, - body: JSON.stringify({ sku, qty: 1, productId }), - }); - expect(addRes.status).toBe(200); - const coRes = await app.request("/checkout/orders", { - method: "POST", - headers: { ...jsonHeaders, "Idempotency-Key": `co-${cartId}` }, - body: JSON.stringify({ - cartId, - paymentMethod: "stripe", - buyerRef, - shippingAddress: { - name: "Ada Lovelace", - line1: "12 Analytical Way", - city: "London", - postalCode: "EC1A 1BB", - country: "GB", - email: buyerRef, - }, - }), - }); - expect(coRes.status).toBe(201); - const body = await json(coRes); - const order = body["order"] as Record; - return { orderId: order["id"] as string, order }; -} - -async function getOrder( - app: Hono, - orderId: string, - opts: { token?: string } = {}, -): Promise> { - const headers: Record = {}; - if (opts.token !== undefined) headers["X-Internal-Token"] = opts.token; - const res = await app.request(`/orders/${orderId}`, { headers }); - expect(res.status).toBe(200); - const body = await json(res); - return body["order"] as Record; -} - -describe("public GET /orders/:orderId redaction (PR D / ADR-0010 §2)", () => { - let testApp: TestApp; - let app: Hono; - let emailSender: FakeEmailSender; - - beforeEach(() => { - testApp = makeApp(); - ({ app, emailSender } = testApp); - }); - - test("omits buyerRef/customerId/shippingAddress/reconciliation* keys entirely (not null)", async () => { - const { orderId } = await checkoutOneLine(testApp); - const publicOrder = await getOrder(app, orderId); - expect(publicOrder).not.toHaveProperty("buyerRef"); - expect(publicOrder).not.toHaveProperty("customerId"); - expect(publicOrder).not.toHaveProperty("shippingAddress"); - expect(publicOrder).not.toHaveProperty("reconciliationFlag"); - expect(publicOrder).not.toHaveProperty("reconciliationResolution"); - }); - - test("keeps id/state/currency/paymentMethod/holdExpiresAt/createdAt/totals/lines", async () => { - const { orderId, order: fullOrder } = await checkoutOneLine(testApp); - const publicOrder = await getOrder(app, orderId); - expect(publicOrder["id"]).toBe(fullOrder["id"]); - expect(publicOrder["state"]).toBe(fullOrder["state"]); - expect(publicOrder["currency"]).toBe(fullOrder["currency"]); - expect(publicOrder["paymentMethod"]).toBe(fullOrder["paymentMethod"]); - expect(publicOrder["holdExpiresAt"]).toEqual(fullOrder["holdExpiresAt"]); - expect(publicOrder["createdAt"]).toBe(fullOrder["createdAt"]); - expect(publicOrder["totals"]).toEqual(fullOrder["totals"]); - expect(publicOrder["lines"]).toEqual(fullOrder["lines"]); - }); - - test("trims fulfillment: carrier/trackingNumber/trackingUrl/shippedAt present, recordedBy/recordedAt absent", async () => { - const { orderId } = await checkoutOneLine(testApp); - await app.request(`/admin/orders/${orderId}/transition`, { - method: "POST", - headers: { ...jsonHeaders, "X-Internal-Token": INTERNAL_TOKEN }, - body: JSON.stringify({ toState: "paid" }), - }); - await app.request(`/admin/orders/${orderId}/transition`, { - method: "POST", - headers: { ...jsonHeaders, "X-Internal-Token": INTERNAL_TOKEN }, - body: JSON.stringify({ toState: "processing" }), - }); - const fulfillRes = await app.request(`/admin/orders/${orderId}/fulfillment`, { - method: "POST", - headers: { ...jsonHeaders, "X-Internal-Token": INTERNAL_TOKEN }, - body: JSON.stringify({ - carrier: "UPS", - trackingNumber: "1Z999", - trackingUrl: "https://ups.example/track/1Z999", - shippedAt: "2026-07-11T00:00:00.000Z", - recordedBy: "ops-alice", - }), - }); - expect(fulfillRes.status).toBe(200); - const publicOrder = await getOrder(app, orderId); - const fulfillment = publicOrder["fulfillment"] as Record; - expect(fulfillment).toMatchObject({ - carrier: "UPS", - trackingNumber: "1Z999", - trackingUrl: "https://ups.example/track/1Z999", - shippedAt: "2026-07-11T00:00:00.000Z", - }); - expect(fulfillment).not.toHaveProperty("recordedBy"); - expect(fulfillment).not.toHaveProperty("recordedAt"); - }); - - test("trims cancellation: reason/cancelledAt present, detail/cancelledBy absent", async () => { - const { orderId } = await checkoutOneLine(testApp); - const cancelRes = await app.request(`/admin/orders/${orderId}/cancel`, { - method: "POST", - headers: { ...jsonHeaders, "X-Internal-Token": INTERNAL_TOKEN }, - body: JSON.stringify({ - reason: "customer_request", - detail: "buyer called to cancel", - cancelledBy: "ops-bob", - }), - }); - expect(cancelRes.status).toBe(200); - const publicOrder = await getOrder(app, orderId); - const cancellation = publicOrder["cancellation"] as Record; - expect(cancellation).toMatchObject({ reason: "customer_request" }); - expect(typeof cancellation["cancelledAt"]).toBe("string"); - expect(cancellation).not.toHaveProperty("detail"); - expect(cancellation).not.toHaveProperty("cancelledBy"); - }); - - test("a valid X-Internal-Token returns the full serializeOrder view", async () => { - const { orderId, order: fullOrder } = await checkoutOneLine(testApp); - const authorized = await getOrder(app, orderId, { token: INTERNAL_TOKEN }); - expect(authorized).toEqual(fullOrder); - expect(authorized).toHaveProperty("buyerRef"); - expect(authorized).toHaveProperty("shippingAddress"); - }); - - test("a WRONG X-Internal-Token degrades to the redacted view, status 200 (never 401/503)", async () => { - const { orderId } = await checkoutOneLine(testApp); - const res = await app.request(`/orders/${orderId}`, { - headers: { "X-Internal-Token": "not-the-token" }, - }); - expect(res.status).toBe(200); - const body = await json(res); - const order = body["order"] as Record; - expect(order).not.toHaveProperty("buyerRef"); - expect(order).not.toHaveProperty("shippingAddress"); - }); - - test("server internalToken UNSET degrades to the redacted view, status 200 (guest read must not break)", async () => { - const unset = makeApp({ internalToken: undefined }); - const { orderId } = await checkoutOneLine(unset); - // Even presenting a (necessarily wrong, since none is configured) token - // must not throw or 401/503 — tokenMatches(header, "") would be a bug. - const res = await unset.app.request(`/orders/${orderId}`, { - headers: { "X-Internal-Token": "anything" }, - }); - expect(res.status).toBe(200); - const body = await json(res); - const order = body["order"] as Record; - expect(order).not.toHaveProperty("buyerRef"); - expect(order).not.toHaveProperty("shippingAddress"); - }); - - test("server internalToken is the EMPTY STRING: behaves as unset ⇒ redacted view, 200 (mirrors auth.ts:52)", async () => { - const empty = makeApp({ internalToken: "" }); - const { orderId } = await checkoutOneLine(empty); - const res = await empty.app.request(`/orders/${orderId}`, { - headers: { "X-Internal-Token": "" }, - }); - expect(res.status).toBe(200); - const body = await json(res); - const order = body["order"] as Record; - expect(order).not.toHaveProperty("buyerRef"); - expect(order).not.toHaveProperty("shippingAddress"); - }); - - test("regressions stay full: GET /me/orders/:id (session), GET /admin/orders/:id (internal token), POST /checkout/orders response", async () => { - const buyerRef = "regression@example.com"; - const { orderId, order: checkoutOrder } = await checkoutOneLine(testApp, { - sku: "SKU-REG", - productId: "preg", - buyerRef, - }); - // POST /checkout/orders response is already full — assert directly. - expect(checkoutOrder).toHaveProperty("buyerRef"); - expect(checkoutOrder).toHaveProperty("shippingAddress"); - - // GET /admin/orders/:id (internal token) stays full. - const adminRes = await app.request(`/admin/orders/${orderId}`, { - headers: { "X-Internal-Token": INTERNAL_TOKEN }, - }); - expect(adminRes.status).toBe(200); - const adminBody = await json(adminRes); - const adminOrder = adminBody["order"] as Record; - expect(adminOrder).toHaveProperty("buyerRef"); - expect(adminOrder).toHaveProperty("shippingAddress"); - - // GET /me/orders/:id (session) stays full: log the buyer in (this links - // the guest order, matched by buyerRef === email, to the new customer). - const reqRes = await app.request("/auth/login/request", { - method: "POST", - headers: jsonHeaders, - body: JSON.stringify({ email: buyerRef }), - }); - expect(reqRes.status).toBe(200); - const send = emailSender.sends.find((s) => s.template === "customer-login-link"); - expect(send).toBeDefined(); - const { challengeId, token } = send!.data as { challengeId: string; token: string }; - const verifyRes = await app.request("/auth/login/verify", { - method: "POST", - headers: jsonHeaders, - body: JSON.stringify({ challengeId, token }), - }); - expect(verifyRes.status).toBe(200); - const { sessionToken } = await json(verifyRes); - const meRes = await app.request(`/me/orders/${orderId}`, { - headers: { Authorization: `Bearer ${sessionToken as string}` }, - }); - expect(meRes.status).toBe(200); - const meBody = await json(meRes); - const meOrder = meBody["order"] as Record; - expect(meOrder).toHaveProperty("buyerRef"); - expect(meOrder).toHaveProperty("shippingAddress"); - }); -}); diff --git a/packages/service/test/qty-bounds.test.ts b/packages/service/test/qty-bounds.test.ts deleted file mode 100644 index 46cd421e..00000000 --- a/packages/service/test/qty-bounds.test.ts +++ /dev/null @@ -1,236 +0,0 @@ -import { - CountingIdGen, - FakeEmailSender, - FixedClock, - InMemoryAddressStore, - InMemoryCartStore, - InMemoryCouponStore, - InMemoryCredentialVerifier, - InMemoryCustomerStore, - InMemoryEntitlementStore, - InMemoryInventoryStore, - InMemoryOrderNotesStore, - InMemoryOrderStore, - InMemoryPaymentEventStore, - InMemoryProductCommerceStore, - InMemoryReportingStore, - InMemorySessionStore, - InMemorySettingsStore, - InMemoryShippingRulesStore, - InMemoryTaxRulesStore, -} from "@otta-sh/domain/testing"; -import { StripePaymentGateway } from "@otta-sh/payments-stripe"; -import type { Hono } from "hono"; -import { describe, expect, test, vi } from "vitest"; -import { createApp } from "../src/app.js"; -import { CART_LINE_MAX_QTY, RESERVE_MAX_QTY } from "../src/schemas.js"; - -// PR C — wire-level qty caps (service-hardening plan §4). Zod-only bounds on -// the three qty sites that previously accepted any positive safe integer -// (including 1e9): `addLineBody`/`patchLineBody` get a shopper-facing -// CART_LINE_MAX_QTY (10,000); `reserveBody` gets the same 1,000,000,000 cap as -// the admin `stockMovementBody` precedent (the raw inventory primitive, a -// machine caller). The cap is a wire bound only — an over-cap request never -// reaches the store, so no reservation row (successful or failed) is minted. -// This does NOT fix junk-row/request-count amplification (see the linked -// follow-up issue); it only closes the "qty: 1e9 is a valid wire request" gap. -interface TestApp { - app: Hono; - inventory: InMemoryInventoryStore; -} - -function makeApp(): TestApp { - const clock = new FixedClock(new Date("2026-07-26T00:00:00.000Z")); - const inventory = new InMemoryInventoryStore({ - idGen: new CountingIdGen("res"), - clock, - seed: [{ sku: "SKU-1", onHand: 20_000 }], - }); - const cartStore = new InMemoryCartStore({ - idGen: new CountingIdGen("cart"), - reservationState: (id) => { - try { - return inventory.reservationState(id); - } catch { - return undefined; - } - }, - releaseHold: (id) => { - void inventory.release(id); - }, - }); - const productCommerce = new InMemoryProductCommerceStore({ - clock, - // NOTE: `InMemoryInventoryStore.onHand` returns 0 for an unseeded sku, so - // this wiring COLLAPSES null -> 0. Fine for the coarse `inStock` boolean - // these suites exercise; do NOT assert the products-list `onHand` - // projection through it (the list must distinguish "no inventory row" - // from "out of stock" — see the divergence note in - // `packages/domain/src/ports/inventory-store.ts`'s `getOnHand` doc). - inventoryOnHand: (s) => inventory.onHand(s), - }); - const idGen = new CountingIdGen("id"); - const customerStore = new InMemoryCustomerStore({ idGen, clock }); - const app = createApp({ - store: inventory, - productCommerce, - cartStore, - orderStore: new InMemoryOrderStore({ idGen, clock }), - orderNotesStore: new InMemoryOrderNotesStore({ idGen, clock }), - entitlementStore: new InMemoryEntitlementStore({ idGen, clock }), - paymentEventStore: new InMemoryPaymentEventStore(), - shippingRules: new InMemoryShippingRulesStore(), - taxRules: new InMemoryTaxRulesStore(), - couponStore: new InMemoryCouponStore({ idGen, clock }), - reportingStore: new InMemoryReportingStore(), - settingsStore: new InMemorySettingsStore(), - customerStore, - addressStore: new InMemoryAddressStore({ idGen, clock }), - sessionStore: new InMemorySessionStore({ idGen, clock }), - credentialVerifier: new InMemoryCredentialVerifier({ customerStore, idGen, clock }), - emailSender: new FakeEmailSender(), - idGen, - gateways: { stripe: new StripePaymentGateway({ webhookSecret: "whsec_gate_test", clock }) }, - clock, - }); - return { app, inventory }; -} - -const json = { "content-type": "application/json" }; - -async function newCart(app: Hono): Promise { - const res = await app.request("/carts", { method: "POST", headers: json, body: "{}" }); - expect(res.status).toBe(201); - const body = (await res.json()) as { cartId: string }; - return body.cartId; -} - -describe("PR C — cart line qty cap (CART_LINE_MAX_QTY)", () => { - test("POST /carts/:id/lines over cap is 400 with a structured error body", async () => { - const { app } = makeApp(); - const cartId = await newCart(app); - - const res = await app.request(`/carts/${cartId}/lines`, { - method: "POST", - headers: { ...json, "Idempotency-Key": "k1" }, - body: JSON.stringify({ sku: "SKU-1", qty: CART_LINE_MAX_QTY + 1 }), - }); - - expect(res.status).toBe(400); - const body = (await res.json()) as { error: string; issues: unknown }; - expect(body.error).toBe("invalid request body"); - expect(body.issues).toBeDefined(); - }); - - test("...and the store is never touched: reserve() not called, onHand unchanged", async () => { - const { app, inventory } = makeApp(); - const cartId = await newCart(app); - const reserveSpy = vi.spyOn(inventory, "reserve"); - const before = inventory.onHand("SKU-1"); - - const res = await app.request(`/carts/${cartId}/lines`, { - method: "POST", - headers: { ...json, "Idempotency-Key": "k1b" }, - body: JSON.stringify({ sku: "SKU-1", qty: CART_LINE_MAX_QTY + 1 }), - }); - - expect(res.status).toBe(400); - expect(reserveSpy).not.toHaveBeenCalled(); - expect(inventory.onHand("SKU-1")).toBe(before); - }); - - test("POST /carts/:id/lines at the CART_LINE_MAX_QTY boundary succeeds (200)", async () => { - const { app } = makeApp(); - const cartId = await newCart(app); - - const res = await app.request(`/carts/${cartId}/lines`, { - method: "POST", - headers: { ...json, "Idempotency-Key": "k2" }, - body: JSON.stringify({ sku: "SKU-1", qty: CART_LINE_MAX_QTY }), - }); - - expect(res.status).toBe(200); - const body = (await res.json()) as { ok: boolean }; - expect(body.ok).toBe(true); - }); - - async function existingLineId(app: Hono, cartId: string, key: string): Promise { - const addRes = await app.request(`/carts/${cartId}/lines`, { - method: "POST", - headers: { ...json, "Idempotency-Key": key }, - body: JSON.stringify({ sku: "SKU-1", qty: 1 }), - }); - const addBody = (await addRes.json()) as { line: { lineId: string } }; - return addBody.line.lineId; - } - - test("PATCH /carts/:id/lines/:lineId over cap is 400", async () => { - const { app } = makeApp(); - const cartId = await newCart(app); - const lineId = await existingLineId(app, cartId, "k3"); - - const overCap = await app.request(`/carts/${cartId}/lines/${lineId}`, { - method: "PATCH", - headers: { ...json, "Idempotency-Key": "k4" }, - body: JSON.stringify({ qty: CART_LINE_MAX_QTY + 1 }), - }); - expect(overCap.status).toBe(400); - }); - - test("PATCH /carts/:id/lines/:lineId at the cap is 200", async () => { - const { app } = makeApp(); - const cartId = await newCart(app); - const lineId = await existingLineId(app, cartId, "k3b"); - - const atCap = await app.request(`/carts/${cartId}/lines/${lineId}`, { - method: "PATCH", - headers: { ...json, "Idempotency-Key": "k5" }, - body: JSON.stringify({ qty: CART_LINE_MAX_QTY }), - }); - expect(atCap.status).toBe(200); - }); - - test("the exact QA repro — qty: 1e9 on a cart line — is now 400", async () => { - const { app } = makeApp(); - const cartId = await newCart(app); - - const res = await app.request(`/carts/${cartId}/lines`, { - method: "POST", - headers: { ...json, "Idempotency-Key": "k6" }, - body: JSON.stringify({ sku: "SKU-1", qty: 1e9 }), - }); - - expect(res.status).toBe(400); - }); -}); - -describe("PR C — POST /inventory/reserve qty cap (RESERVE_MAX_QTY, aligned with stockMovementBody)", () => { - test("qty: 1_000_000_001 is 400, reserve never called", async () => { - const { app, inventory } = makeApp(); - const reserveSpy = vi.spyOn(inventory, "reserve"); - - const res = await app.request("/inventory/reserve", { - method: "POST", - headers: { ...json, "Idempotency-Key": "k7" }, - body: JSON.stringify({ sku: "SKU-1", qty: RESERVE_MAX_QTY + 1 }), - }); - - expect(res.status).toBe(400); - expect(reserveSpy).not.toHaveBeenCalled(); - }); - - test("qty: 1_000_000_000 reaches the store (200, OUT_OF_STOCK since it exceeds seeded on-hand)", async () => { - const { app } = makeApp(); - - const res = await app.request("/inventory/reserve", { - method: "POST", - headers: { ...json, "Idempotency-Key": "k8" }, - body: JSON.stringify({ sku: "SKU-1", qty: RESERVE_MAX_QTY }), - }); - - expect(res.status).toBe(200); - const body = (await res.json()) as { ok: boolean; reason?: string }; - expect(body.ok).toBe(false); - expect(body.reason).toBe("OUT_OF_STOCK"); - }); -}); diff --git a/packages/service/test/reports-http.test.ts b/packages/service/test/reports-http.test.ts deleted file mode 100644 index 2474ce78..00000000 --- a/packages/service/test/reports-http.test.ts +++ /dev/null @@ -1,252 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test } from "vitest"; -import { startTestServer, type TestServer } from "./helpers/start-test-server.js"; - -// Phase 7 §7 Step 5: wire ⇄ port fidelity for /reports/*, against a LIVE server -// backed by Postgres, seeded with the shared reporting fixture. - -const PG = process.env.PG_CONNECTION_STRING; -const FROM = "2026-07-10T00:00:00.000Z"; -const TO = "2026-07-12T23:59:59.999Z"; - -async function json(res: Response): Promise> { - return (await res.json()) as Record; -} - -describe.skipIf(PG === undefined)("reports HTTP contract", () => { - let server: TestServer; - let token: string; - beforeEach(async () => { - server = await startTestServer(); - token = server.internalToken as string; - await server.seedReportingFixture(); - }); - afterEach(async () => { - await server.stop(); - }); - - function get(path: string): Promise { - return fetch(`${server.baseUrl}/reports${path}`, { headers: { "X-Internal-Token": token } }); - } - - test("GET /reports/revenue returns per-day buckets grouped by currency, integer cents", async () => { - const body = await json(await get(`/revenue?from=${FROM}&to=${TO}&interval=day`)); - expect(body.ok).toBe(true); - // Every bucket carries BOTH figures, `refundedCents` alongside — never - // netted into — `revenueCents` (INC-23). 07-10 USD shows a partial refund - // on an order whose full 1000 still counts as revenue; 07-12 USD shows a - // fully refunded order's 6666, money the revenue allow-list excludes and - // which no endpoint reported at all before this. - expect(body.buckets).toEqual([ - { - bucketStart: "2026-07-10T00:00:00.000Z", - currency: "EUR", - revenueCents: 3000, - refundedCents: 300, - }, - { - bucketStart: "2026-07-10T00:00:00.000Z", - currency: "USD", - revenueCents: 3000, - refundedCents: 250, - }, - { - bucketStart: "2026-07-11T00:00:00.000Z", - currency: "EUR", - revenueCents: 2500, - refundedCents: 0, - }, - { - bucketStart: "2026-07-11T00:00:00.000Z", - currency: "USD", - revenueCents: 5500, - refundedCents: 0, - }, - { - bucketStart: "2026-07-12T00:00:00.000Z", - currency: "EUR", - revenueCents: 3500, - refundedCents: 0, - }, - { - bucketStart: "2026-07-12T00:00:00.000Z", - currency: "USD", - revenueCents: 3000, - refundedCents: 6666, - }, - ]); - }); - - test("GET /reports/revenue emits refundedCents as a KEY even at zero — absence is what means 'not reported'", async () => { - const body = await json(await get(`/revenue?from=${FROM}&to=${TO}&interval=day`)); - const buckets = body.buckets as Array>; - const zeroBucket = buckets.find( - (b) => b.bucketStart === "2026-07-11T00:00:00.000Z" && b.currency === "EUR", - ); - // `in`, not a truthiness/`?? 0` read: a client distinguishes "no refunds" - // from "this service predates the field" by the key, never by the value. - expect(zeroBucket !== undefined && "refundedCents" in zeroBucket).toBe(true); - expect(zeroBucket?.refundedCents).toBe(0); - }); - - test("GET /reports/orders-by-status counts every state including expired", async () => { - const body = await json(await get(`/orders-by-status?from=${FROM}&to=${TO}`)); - expect(body.counts).toEqual([ - { status: "cancelled", orderCount: 1 }, - { status: "completed", orderCount: 1 }, - { status: "delivered", orderCount: 1 }, - { status: "expired", orderCount: 1 }, - { status: "failed", orderCount: 1 }, - { status: "paid", orderCount: 3 }, - { status: "pending", orderCount: 1 }, - { status: "processing", orderCount: 2 }, - { status: "refunded", orderCount: 1 }, - { status: "shipped", orderCount: 2 }, - ]); - }); - - test("GET /reports/top-products respects metric and limit query params", async () => { - const byRevenue = await json( - await get(`/top-products?from=${FROM}&to=${TO}&metric=revenue&limit=2`), - ); - expect((byRevenue.products as Array>).map((p) => p.productId)).toEqual([ - "p2", - "p4", - ]); - const byQty = await json( - await get(`/top-products?from=${FROM}&to=${TO}&metric=quantity&limit=2`), - ); - expect((byQty.products as Array>).map((p) => p.productId)).toEqual([ - "p1", - "p3", - ]); - // Snapshot title travels on the wire. - expect((byQty.products as Array>)[0]?.titleSnapshot).toBe("Widget"); - }); - - test("GET /reports/low-stock defaults the threshold from settings and honors an override", async () => { - // Default settings.lowStockThreshold = 5. - const dflt = await json(await get("/low-stock")); - expect((dflt.rows as Array>).map((r) => r.sku)).toEqual([ - "SKU-A", - "SKU-B", - "SKU-C", - "SKU-E", - ]); - const override = await json(await get("/low-stock?threshold=0")); - expect((override.rows as Array>).map((r) => r.sku)).toEqual(["SKU-A"]); - }); - - test("GET /reports/low-stock carries the LIVE product title; null when unknown and NEVER the sku", async () => { - // The shared fixture seeds inventory but no products, so titles start null. - const before = await json(await get("/low-stock")); - const rowsBefore = before.rows as Array>; - for (const r of rowsBefore) { - expect(Object.hasOwn(r, "title")).toBe(true); - expect(r.title).toBeNull(); - // The one fallback that must never happen: the sku standing in as a name. - expect(r.title).not.toBe(r.sku); - } - - await server.seedProductRow({ - id: "p-live-a", - sku: "SKU-A", - title: "Alpha Widget", - priceCents: 100, - active: true, - createdAt: "2026-07-10T00:00:00.000Z", - }); - // A product whose own title is null stays null — not the sku. - await server.seedProductRow({ - id: "p-live-b", - sku: "SKU-B", - title: null, - priceCents: 100, - active: true, - createdAt: "2026-07-10T00:00:00.000Z", - }); - - const after = await json(await get("/low-stock")); - const rows = after.rows as Array>; - const bySku = new Map(rows.map((r) => [r.sku, r])); - expect(bySku.get("SKU-A")?.title).toBe("Alpha Widget"); - expect(bySku.get("SKU-B")?.title).toBeNull(); - expect(bySku.get("SKU-B")?.title).not.toBe("SKU-B"); - }); - - test("GET /reports/low-stock: a soft-deleted product sharing a live sku neither duplicates the row nor titles it", async () => { - // Legal state: sku uniqueness on product_commerce is a PARTIAL index over - // live rows, so a tombstone may hold a sku a live row also holds. The join - // must see only the live row. - await server.seedProductRow({ - id: "p-dead-c", - sku: "SKU-C", - title: "Gamma Sprocket (old)", - priceCents: 100, - active: true, - createdAt: "2026-07-09T00:00:00.000Z", - deletedAt: "2026-07-09T12:00:00.000Z", - }); - await server.seedProductRow({ - id: "p-live-c", - sku: "SKU-C", - title: "Gamma Sprocket", - priceCents: 100, - active: true, - createdAt: "2026-07-10T00:00:00.000Z", - }); - // SKU-E gets ONLY a tombstone: the low-stock row still lists (inventory is - // the driving table) but a dead product cannot supply its title. - await server.seedProductRow({ - id: "p-dead-e", - sku: "SKU-E", - title: "Epsilon Ghost", - priceCents: 100, - active: true, - createdAt: "2026-07-09T00:00:00.000Z", - deletedAt: "2026-07-09T12:00:00.000Z", - }); - - const rows = (await json(await get("/low-stock"))).rows as Array>; - expect(rows.filter((r) => r.sku === "SKU-C")).toHaveLength(1); - expect(rows.find((r) => r.sku === "SKU-C")?.title).toBe("Gamma Sprocket"); - expect(rows.filter((r) => r.sku === "SKU-E")).toHaveLength(1); - expect(rows.find((r) => r.sku === "SKU-E")?.title).toBeNull(); - // And the page as a whole did not grow: still one row per low-stock sku. - expect(rows.map((r) => r.sku)).toEqual(["SKU-A", "SKU-B", "SKU-C", "SKU-E"]); - }); - - test("GET /reports/revenue with a from/to range over 400 days returns 400 with a structured validation error", async () => { - const res = await get(`/revenue?from=2024-01-01T00:00:00.000Z&to=2026-01-01T00:00:00.000Z`); - expect(res.status).toBe(400); - const body = await json(res); - expect(body.ok).toBe(false); - expect(body.error).toBe("range_too_wide"); - expect(body.maxDays).toBe(400); - }); - - test("GET /reports/orders-by-status and /top-products also reject a >400-day range", async () => { - const wide = `from=2024-01-01T00:00:00.000Z&to=2026-01-01T00:00:00.000Z`; - expect((await get(`/orders-by-status?${wide}`)).status).toBe(400); - expect((await get(`/top-products?${wide}`)).status).toBe(400); - }); - - test("GET /reports/revenue with a malformed date returns 400", async () => { - const res = await get(`/revenue?from=not-a-date&to=${TO}`); - expect(res.status).toBe(400); - }); - - test("SECURITY: /reports/* without the internal token is rejected (merchant data is not public)", async () => { - // No token header — every report endpoint must refuse (401), not leak data. - for (const path of [ - `/revenue?from=${FROM}&to=${TO}`, - `/orders-by-status?from=${FROM}&to=${TO}`, - `/top-products?from=${FROM}&to=${TO}`, - "/low-stock", - ]) { - const res = await fetch(`${server.baseUrl}/reports${path}`); - expect(res.status).toBe(401); - } - // With the token, the same read succeeds. - expect((await get(`/revenue?from=${FROM}&to=${TO}`)).status).toBe(200); - }); -}); diff --git a/packages/service/test/rules-admin.http.contract.pg.test.ts b/packages/service/test/rules-admin.http.contract.pg.test.ts deleted file mode 100644 index 3d6729bb..00000000 --- a/packages/service/test/rules-admin.http.contract.pg.test.ts +++ /dev/null @@ -1,380 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test } from "vitest"; -import { startTestServer, type TestServer } from "./helpers/start-test-server.js"; - -// Phase 6: thin pass/fail contract for the shipping/tax/coupon admin CRUD — -// 1:1 store reflections, so a create-then-read per resource plus an auth guard. - -const PG = process.env.PG_CONNECTION_STRING; - -async function json(res: Response): Promise> { - return (await res.json()) as Record; -} - -describe.skipIf(PG === undefined)("rules admin CRUD HTTP contract", () => { - let server: TestServer; - let token: string; - beforeEach(async () => { - server = await startTestServer(); - token = server.internalToken as string; - }); - afterEach(async () => { - await server.stop(); - }); - - function post(path: string, body: unknown, withToken = true): Promise { - return fetch(`${server.baseUrl}/admin${path}`, { - method: "POST", - headers: { - "content-type": "application/json", - ...(withToken ? { "X-Internal-Token": token } : {}), - }, - body: JSON.stringify(body), - }); - } - function get(path: string): Promise { - return fetch(`${server.baseUrl}/admin${path}`, { headers: { "X-Internal-Token": token } }); - } - function send(method: string, path: string, body?: unknown, withToken = true): Promise { - return fetch(`${server.baseUrl}/admin${path}`, { - method, - headers: { - ...(body !== undefined ? { "content-type": "application/json" } : {}), - ...(withToken ? { "X-Internal-Token": token } : {}), - }, - ...(body !== undefined ? { body: JSON.stringify(body) } : {}), - }); - } - - test("shipping: create zone → method → rate, then read back", async () => { - expect((await post("/shipping/zones", { id: "z-us", name: "US" })).status).toBe(201); - expect( - (await post("/shipping/zones/z-us/methods", { id: "m", name: "Flat", type: "flat_rate" })) - .status, - ).toBe(201); - expect( - (await post("/shipping/methods/m/rates", { currency: "USD", amountCents: 599 })).status, - ).toBe(201); - - const zones = await json(await get("/shipping/zones")); - expect((zones.zones as unknown[]).length).toBe(1); - const methods = await json(await get("/shipping/zones/z-us/methods")); - expect((methods.methods as unknown[]).length).toBe(1); - const rate = await json(await get("/shipping/methods/m/rates?currency=USD")); - expect((rate.rate as Record).amountCents).toBe(599); - }); - - test("tax: create class + rate, then read by zone", async () => { - expect((await post("/tax/classes", { id: "standard", name: "Standard" })).status).toBe(201); - expect( - (await post("/tax/rates", { id: "t1", taxClassId: "standard", zoneId: "z-us", rateBps: 725 })) - .status, - ).toBe(201); - const classes = await json(await get("/tax/classes")); - expect((classes.classes as unknown[]).length).toBe(1); - const rates = await json(await get("/tax/rates?zoneId=z-us")); - expect((rates.rates as Array>)[0]?.rateBps).toBe(725); - }); - - test("coupons: create + read by code round-trips money fields", async () => { - expect( - ( - await post("/coupons", { - id: "cpn", - code: "SAVE5", - type: "fixed_amount", - amountCents: 500, - currency: "USD", - maxUses: 10, - }) - ).status, - ).toBe(201); - const coupon = await json(await get("/coupons/SAVE5")); - const c = coupon.coupon as Record; - expect(c.amountCents).toBe(500); - expect(c.usesCount).toBe(0); - expect((await get("/coupons/NOPE")).status).toBe(404); - }); - - test("writes without the internal token are rejected 401", async () => { - const res = await post("/shipping/zones", { id: "z", name: "Z" }, false); - expect(res.status).toBe(401); - }); - - // -- UPDATE/DELETE (admin-UX Increment 3) ---------------------------------- - - test("shipping: update zone (LWW), CAS-update rate, and referential delete guards", async () => { - await post("/shipping/zones", { id: "z-us", name: "US" }); - await post("/shipping/zones/z-us/methods", { id: "m", name: "Flat", type: "flat_rate" }); - await post("/shipping/methods/m/rates", { currency: "USD", amountCents: 599 }); - - // Zone LWW edit — `regions` is a REQUIRED full-replace field. - const zoneUpd = await send("PUT", "/shipping/zones/z-us", { - name: "United States", - regions: ["US", "PR"], - }); - expect(zoneUpd.status).toBe(200); - expect(((await json(zoneUpd)).zone as Record).name).toBe("United States"); - - // Deleting a zone with a method is refused (409 IN_USE_BY_METHODS). - const zoneDel = await send("DELETE", "/shipping/zones/z-us"); - expect(zoneDel.status).toBe(409); - expect((await json(zoneDel)).reason).toBe("IN_USE_BY_METHODS"); - - // Rate CAS: correct expected wins (200); a stale expected is 409 STALE. - const okUpd = await send("PUT", "/shipping/methods/m/rates/USD", { - amountCents: 699, - minSubtotalCents: null, - expectedAmountCents: 599, - }); - expect(okUpd.status).toBe(200); - expect((await json(okUpd)).ok).toBe(true); - const stale = await send("PUT", "/shipping/methods/m/rates/USD", { - amountCents: 799, - minSubtotalCents: null, - expectedAmountCents: 599, // still the old value - }); - expect(stale.status).toBe(409); - const staleBody = await json(stale); - expect(staleBody.reason).toBe("STALE"); - expect((staleBody.current as Record).amountCents).toBe(699); - - // Leaf rate delete, then a method delete now succeeds; deletes are idempotent. - expect((await send("DELETE", "/shipping/methods/m/rates/USD")).status).toBe(200); - expect((await send("DELETE", "/shipping/methods/m/rates/USD")).status).toBe(404); - expect((await send("DELETE", "/shipping/methods/m")).status).toBe(200); - expect((await send("DELETE", "/shipping/zones/z-us")).status).toBe(200); - }); - - test("tax: CAS-update rate (stale ⇒ 409) and leaf delete (idempotent 404)", async () => { - await post("/tax/classes", { id: "standard", name: "Standard" }); - await post("/tax/rates", { id: "t1", taxClassId: "standard", zoneId: "z-us", rateBps: 725 }); - - const ok = await send("PUT", "/tax/rates/t1", { - rateBps: 825, - appliesToShipping: false, - expectedRateBps: 725, - }); - expect(ok.status).toBe(200); - expect(((await json(ok)).rate as Record).rateBps).toBe(825); - const stale = await send("PUT", "/tax/rates/t1", { - rateBps: 900, - appliesToShipping: false, - expectedRateBps: 725, - }); - expect(stale.status).toBe(409); - expect((await json(stale)).reason).toBe("STALE"); - - expect((await send("DELETE", "/tax/rates/t1")).status).toBe(200); - expect((await send("DELETE", "/tax/rates/t1")).status).toBe(404); - }); - - test("coupons: update (LWW) and forbid-if-... delete semantics", async () => { - await post("/coupons", { - id: "cpn", - code: "SAVE5", - type: "fixed_amount", - amountCents: 500, - currency: "USD", - maxUses: 10, - }); - const upd = await send("PUT", "/coupons/cpn", { amountCents: 750, maxUses: 20 }); - expect(upd.status).toBe(200); - const c = (await json(upd)).coupon as Record; - expect(c.amountCents).toBe(750); - expect(c.code).toBe("SAVE5"); // identity preserved - - expect((await send("PUT", "/coupons/missing", { amountCents: 1 })).status).toBe(404); - // Unredeemed coupon deletes; replay is an idempotent 404. - expect((await send("DELETE", "/coupons/cpn")).status).toBe(200); - expect((await send("DELETE", "/coupons/cpn")).status).toBe(404); - }); - - test("full-replace updates 400 on an OMITTED required field and write nothing (reviewer B finding 1)", async () => { - await post("/shipping/zones", { id: "z-req", name: "US", regions: ["US"] }); - await post("/shipping/zones/z-req/methods", { id: "m-req", name: "Flat", type: "flat_rate" }); - await post("/shipping/methods/m-req/rates", { - currency: "USD", - amountCents: 599, - minSubtotalCents: 5000, - }); - await post("/tax/classes", { id: "std-req", name: "Standard" }); - await post("/tax/rates", { - id: "t-req", - taxClassId: "std-req", - zoneId: "z-req", - rateBps: 725, - appliesToShipping: true, - }); - - // Omitted `regions` must be a 400 — never a silent wipe-to-null. - expect((await send("PUT", "/shipping/zones/z-req", { name: "Renamed" })).status).toBe(400); - const zone = (await json(await get("/shipping/zones"))).zones as Array>; - const zreq = zone.find((z) => z.id === "z-req"); - expect(zreq?.name).toBe("US"); // nothing written - expect(zreq?.regions).toEqual(["US"]); // regions NOT wiped - - // Omitted `minSubtotalCents` must be a 400 — never a silent threshold clear. - expect( - ( - await send("PUT", "/shipping/methods/m-req/rates/USD", { - amountCents: 699, - expectedAmountCents: 599, - }) - ).status, - ).toBe(400); - const rate = (await json(await get("/shipping/methods/m-req/rates?currency=USD"))) - .rate as Record; - expect(rate.amountCents).toBe(599); // nothing written - expect(rate.minSubtotalCents).toBe(5000); // threshold NOT cleared - - // Omitted `appliesToShipping` must be a 400 — never a silent flip to false. - expect( - (await send("PUT", "/tax/rates/t-req", { rateBps: 825, expectedRateBps: 725 })).status, - ).toBe(400); - const rates = (await json(await get("/tax/rates?zoneId=z-req"))).rates as Array< - Record - >; - expect(rates[0]?.rateBps).toBe(725); // nothing written - expect(rates[0]?.appliesToShipping).toBe(true); // flag NOT flipped - }); - - test("UPDATE/DELETE without the internal token are rejected 401", async () => { - await post("/shipping/zones", { id: "z-guard", name: "Z" }); - expect((await send("PUT", "/shipping/zones/z-guard", { name: "X" }, false)).status).toBe(401); - expect((await send("DELETE", "/shipping/zones/z-guard", undefined, false)).status).toBe(401); - }); - - // -- Increment 3 closeout: tax-class rename/delete wiring ----------------- - - test("tax class: rename (LWW) round-trips; unknown id is 404", async () => { - await post("/tax/classes", { id: "reduced", name: "Reduced" }); - const upd = await send("PUT", "/tax/classes/reduced", { name: "Reduced rate" }); - expect(upd.status).toBe(200); - const cls = (await json(upd)).taxClass as Record; - expect(cls).toEqual({ id: "reduced", name: "Reduced rate" }); - const classes = (await json(await get("/tax/classes"))).classes as Array< - Record - >; - expect(classes.find((c) => c.id === "reduced")?.name).toBe("Reduced rate"); - - expect((await send("PUT", "/tax/classes/missing", { name: "X" })).status).toBe(404); - }); - - test("tax class: a rename never orphans an existing rate (still resolves by id)", async () => { - await post("/tax/classes", { id: "std-rn", name: "Standard" }); - await post("/tax/rates", { id: "t-rn", taxClassId: "std-rn", zoneId: "z-rn", rateBps: 725 }); - expect((await send("PUT", "/tax/classes/std-rn", { name: "Standard renamed" })).status).toBe( - 200, - ); - const rates = (await json(await get("/tax/rates?zoneId=z-rn"))).rates as Array< - Record - >; - expect(rates[0]?.taxClassId).toBe("std-rn"); - expect(rates[0]?.rateBps).toBe(725); - }); - - test("tax class: delete succeeds once unreferenced; idempotent 404 after; unknown id is 404", async () => { - await post("/tax/classes", { id: "temp-del", name: "Temp" }); - expect((await send("DELETE", "/tax/classes/temp-del")).status).toBe(200); - expect((await send("DELETE", "/tax/classes/temp-del")).status).toBe(404); - expect((await send("DELETE", "/tax/classes/never-existed")).status).toBe(404); - }); - - test("tax class: delete is refused 409 with an honest count while a PRODUCT references it", async () => { - await post("/tax/classes", { id: "prod-ref", name: "Product referenced" }); - await server.seedProductRow({ - id: "p-tax-ref", - sku: "SKU-TAXREF", - priceCents: 1000, - createdAt: "2026-07-10T00:00:00.000Z", - taxClass: "prod-ref", - }); - const del = await send("DELETE", "/tax/classes/prod-ref"); - expect(del.status).toBe(409); - const body = await json(del); - expect(body.reason).toBe("IN_USE_BY_PRODUCTS"); - expect(body.count).toBe(1); - }); - - test("tax class: delete is refused 409 with an honest count while a RATE references it", async () => { - await post("/tax/classes", { id: "rate-ref", name: "Rate referenced" }); - await post("/tax/rates", { - id: "t-ref-1", - taxClassId: "rate-ref", - zoneId: "z-a", - rateBps: 500, - }); - await post("/tax/rates", { - id: "t-ref-2", - taxClassId: "rate-ref", - zoneId: "z-b", - rateBps: 700, - }); - const del = await send("DELETE", "/tax/classes/rate-ref"); - expect(del.status).toBe(409); - const body = await json(del); - expect(body.reason).toBe("IN_USE_BY_RATES"); - expect(body.count).toBe(2); - // The class survives the refused delete. - const classes = (await json(await get("/tax/classes"))).classes as Array< - Record - >; - expect(classes.some((c) => c.id === "rate-ref")).toBe(true); - }); - - test("tax class UPDATE/DELETE without the internal token are rejected 401", async () => { - await post("/tax/classes", { id: "tc-guard", name: "Guard" }); - expect((await send("PUT", "/tax/classes/tc-guard", { name: "X" }, false)).status).toBe(401); - expect((await send("DELETE", "/tax/classes/tc-guard", undefined, false)).status).toBe(401); - }); - - // -- Increment 3 closeout: coupon blank-economics server-side guard ------- - - test("coupon update: a fixed_amount coupon cannot be updated with a null amountCents (400, nothing written)", async () => { - await post("/coupons", { - id: "cpn-fixed", - code: "FIXED10", - type: "fixed_amount", - amountCents: 1000, - currency: "USD", - }); - const res = await send("PUT", "/coupons/cpn-fixed", { amountCents: null, maxUses: 5 }); - expect(res.status).toBe(400); - const coupon = (await json(await get("/coupons/FIXED10"))).coupon as Record; - expect(coupon.amountCents).toBe(1000); // nothing written - expect(coupon.maxUses).toBeNull(); // nothing written - }); - - test("coupon update: a percentage coupon cannot be updated with a null rateBps (400, nothing written)", async () => { - await post("/coupons", { - id: "cpn-pct", - code: "PCT10", - type: "percentage", - rateBps: 1000, - }); - const res = await send("PUT", "/coupons/cpn-pct", { rateBps: null, capCents: 500 }); - expect(res.status).toBe(400); - const coupon = (await json(await get("/coupons/PCT10"))).coupon as Record; - expect(coupon.rateBps).toBe(1000); // nothing written - expect(coupon.capCents).toBeNull(); // nothing written - }); - - test("coupon update: omitting the OTHER type's field is fine (only the owning type's blank is guarded)", async () => { - await post("/coupons", { - id: "cpn-fixed-2", - code: "FIXED20", - type: "fixed_amount", - amountCents: 2000, - currency: "USD", - }); - // amountCents present and non-null; rateBps absent (irrelevant to fixed_amount). - const res = await send("PUT", "/coupons/cpn-fixed-2", { amountCents: 2500 }); - expect(res.status).toBe(200); - const coupon = (await json(await get("/coupons/FIXED20"))).coupon as Record; - expect(coupon.amountCents).toBe(2500); - }); - - test("coupon update: an unknown couponId is still 404 (fetch-then-validate doesn't change the not_found case)", async () => { - expect((await send("PUT", "/coupons/does-not-exist", { amountCents: 100 })).status).toBe(404); - }); -}); diff --git a/packages/service/test/service-token.test.ts b/packages/service/test/service-token.test.ts deleted file mode 100644 index a68cd0e3..00000000 --- a/packages/service/test/service-token.test.ts +++ /dev/null @@ -1,388 +0,0 @@ -import { - CountingIdGen, - FakeEmailSender, - FixedClock, - InMemoryAddressStore, - InMemoryCartStore, - InMemoryCouponStore, - InMemoryCredentialVerifier, - InMemoryCustomerStore, - InMemoryEntitlementStore, - InMemoryInventoryStore, - InMemoryOrderNotesStore, - InMemoryOrderStore, - InMemoryPaymentEventStore, - InMemoryProductCommerceStore, - InMemoryReportingStore, - InMemorySessionStore, - InMemorySettingsStore, - InMemoryShippingRulesStore, - InMemoryTaxRulesStore, -} from "@otta-sh/domain/testing"; -import { StripePaymentGateway } from "@otta-sh/payments-stripe"; -import type { Hono } from "hono"; -import { describe, expect, test } from "vitest"; -import { createApp } from "../src/app.js"; - -// D9 / ADR-0007 — the SERVICE_API_TOKEN write gate at the app level, over the -// IO-free in-memory stores via `app.request()` (no server, no PG). Token set ⇒ -// every non-GET/HEAD method on every path needs `X-Service-Token: `; -// GET/HEAD (and /health) stay open; token unset ⇒ exactly today's behavior. The -// gate reads ONLY `X-Service-Token`; `Authorization: Bearer` is owned by -// customer session auth and the gate ignores it (headline regression below). -interface TestApp { - app: Hono; - inventory: InMemoryInventoryStore; - internalToken: string | undefined; -} - -function makeApp(options: { serviceToken?: string; internalToken?: string } = {}): TestApp { - const clock = new FixedClock(new Date("2026-07-10T00:00:00.000Z")); - const inventory = new InMemoryInventoryStore({ idGen: new CountingIdGen("res"), clock }); - const cartStore = new InMemoryCartStore({ - idGen: new CountingIdGen("cart"), - reservationState: (id) => { - try { - return inventory.reservationState(id); - } catch { - return undefined; - } - }, - releaseHold: (id) => { - void inventory.release(id); - }, - }); - const productCommerce = new InMemoryProductCommerceStore({ - clock, - // NOTE: `InMemoryInventoryStore.onHand` returns 0 for an unseeded sku, so - // this wiring COLLAPSES null -> 0. Fine for the coarse `inStock` boolean - // these suites exercise; do NOT assert the products-list `onHand` - // projection through it (the list must distinguish "no inventory row" - // from "out of stock" — see the divergence note in - // `packages/domain/src/ports/inventory-store.ts`'s `getOnHand` doc). - inventoryOnHand: (s) => inventory.onHand(s), - }); - const idGen = new CountingIdGen("id"); - const customerStore = new InMemoryCustomerStore({ idGen, clock }); - const app = createApp({ - store: inventory, - productCommerce, - cartStore, - orderStore: new InMemoryOrderStore({ idGen, clock }), - orderNotesStore: new InMemoryOrderNotesStore({ idGen, clock }), - entitlementStore: new InMemoryEntitlementStore({ idGen, clock }), - paymentEventStore: new InMemoryPaymentEventStore(), - shippingRules: new InMemoryShippingRulesStore(), - taxRules: new InMemoryTaxRulesStore(), - couponStore: new InMemoryCouponStore({ idGen, clock }), - reportingStore: new InMemoryReportingStore(), - settingsStore: new InMemorySettingsStore(), - customerStore, - addressStore: new InMemoryAddressStore({ idGen, clock }), - sessionStore: new InMemorySessionStore({ idGen, clock }), - credentialVerifier: new InMemoryCredentialVerifier({ customerStore, idGen, clock }), - emailSender: new FakeEmailSender(), - idGen, - // A REAL Stripe gateway with a test secret so the webhook route's OWN - // auth (Stripe-Signature HMAC over raw bytes) is live in these tests. - gateways: { stripe: new StripePaymentGateway({ webhookSecret: "whsec_gate_test", clock }) }, - clock, - serviceToken: options.serviceToken, - internalToken: options.internalToken, - }); - return { app, inventory, internalToken: options.internalToken }; -} - -const TOKEN = "svc-secret"; -const serviceHeader = { "X-Service-Token": TOKEN }; -const json = { "content-type": "application/json" }; - -describe("SERVICE_API_TOKEN write gate (token set)", () => { - test.each([ - ["POST", "/inventory/reserve", { sku: "S", qty: 1 }], - ["POST", "/carts", {}], - ["PUT", "/products/p1/commerce", { sku: "S" }], - ["POST", "/catalog/commerce/batch", { productIds: ["p1"] }], - ["POST", "/internal/expire-holds", undefined], - // Phase 4 mutating routes are gated too: checkout and the internal order - // sweep are CMS-/first-party-server-called (they can carry the Bearer); - // /entitlements/grant is service-authenticated and server-called likewise. - ["POST", "/checkout/orders", { cartId: "c1", paymentMethod: "stripe", buyerRef: "b@x.io" }], - ["POST", "/internal/expire-orders", undefined], - ["POST", "/entitlements/grant", {}], - ] as const)("%s %s without X-Service-Token is 401", async (method, path, body) => { - const { app } = makeApp({ serviceToken: TOKEN }); - const res = await app.request(path, { - method, - headers: json, - body: body === undefined ? undefined : JSON.stringify(body), - }); - expect(res.status).toBe(401); - // No challenge header — the machine token is not a Bearer scheme (ADR-0007). - expect(res.headers.get("WWW-Authenticate")).toBeNull(); - expect(await res.json()).toEqual({ ok: false, error: "unauthorized" }); - }); - - test("a wrong X-Service-Token is 401", async () => { - const { app } = makeApp({ serviceToken: TOKEN }); - const res = await app.request("/carts", { - method: "POST", - headers: { ...json, "X-Service-Token": "wrong" }, - body: "{}", - }); - expect(res.status).toBe(401); - }); - - test("a matching Authorization: Bearer does NOT open the gate (Authorization is session-only)", async () => { - const { app } = makeApp({ serviceToken: TOKEN }); - const res = await app.request("/carts", { - method: "POST", - headers: { ...json, Authorization: `Bearer ${TOKEN}` }, - body: "{}", - }); - expect(res.status).toBe(401); - }); - - test("the correct X-Service-Token reaches the routes (full cart write path)", async () => { - const { app, inventory } = makeApp({ serviceToken: TOKEN }); - inventory.seed("SKU-1", 5); - - const created = await app.request("/carts", { - method: "POST", - headers: { ...json, ...serviceHeader }, - body: "{}", - }); - expect(created.status).toBe(201); - const { cartId } = (await created.json()) as { cartId: string }; - - const added = await app.request(`/carts/${cartId}/lines`, { - method: "POST", - headers: { ...json, ...serviceHeader, "Idempotency-Key": "k1" }, - body: JSON.stringify({ sku: "SKU-1", qty: 2 }), - }); - expect(added.status).toBe(200); - expect(inventory.onHand("SKU-1")).toBe(3); - }); - - test("GET/HEAD and /health stay open as the storefront read surface", async () => { - const { app } = makeApp({ serviceToken: TOKEN }); - expect((await app.request("/health")).status).toBe(200); - expect((await app.request("/health", { method: "HEAD" })).status).toBe(200); - // GET /carts/:id (unknown id) reaches the route: 404, not 401. - expect((await app.request("/carts/nope")).status).toBe(404); - // GET /products/:id/commerce reaches the route (200 view), not 401. - expect((await app.request("/products/nope/commerce")).status).toBe(200); - }); - - test("POST /webhooks/stripe is EXEMPT from the service-token gate — its own Stripe-Signature auth still applies", async () => { - const { app } = makeApp({ serviceToken: TOKEN }); - // No X-Service-Token header, garbage signature: the request REACHES the - // webhook route (never 401 from the gate) and is rejected by the route's - // own HMAC verification (400 INVALID_SIGNATURE). Stripe cannot carry our - // service token — signature auth is the exemption's justification. - const res = await app.request("/webhooks/stripe", { - method: "POST", - headers: { ...json, "Stripe-Signature": "t=1,v1=deadbeef" }, - body: JSON.stringify({ id: "evt_1", type: "payment_intent.succeeded" }), - }); - expect(res.status).toBe(400); - expect(await res.json()).toEqual({ ok: false, reason: "INVALID_SIGNATURE" }); - }); - - test("the webhook exemption is exact method+path: other paths AND other verbs stay gated", async () => { - const { app } = makeApp({ serviceToken: TOKEN }); - const otherPath = await app.request("/webhooks/other", { method: "POST", headers: json }); - expect(otherPath.status).toBe(401); - // Same path, different verb: only POST carries Stripe's signature auth. - const put = await app.request("/webhooks/stripe", { method: "PUT", headers: json }); - expect(put.status).toBe(401); - const del = await app.request("/webhooks/stripe", { method: "DELETE" }); - expect(del.status).toBe(401); - }); - - test("/internal/expire-holds with both secrets set needs X-Service-Token AND X-Internal-Token", async () => { - const { app } = makeApp({ serviceToken: TOKEN, internalToken: "int-secret" }); - // Only the internal token: blocked at the service-token gate. - const onlyInternal = await app.request("/internal/expire-holds", { - method: "POST", - headers: { "X-Internal-Token": "int-secret" }, - }); - expect(onlyInternal.status).toBe(401); - // Only the service token: passes the gate, 401s at the internal check. - const onlyService = await app.request("/internal/expire-holds", { - method: "POST", - headers: serviceHeader, - }); - expect(onlyService.status).toBe(401); - // Both: 200. - const both = await app.request("/internal/expire-holds", { - method: "POST", - headers: { ...serviceHeader, "X-Internal-Token": "int-secret" }, - }); - expect(both.status).toBe(200); - expect(await both.json()).toEqual({ ok: true, reclaimed: 0 }); - }); - - test("/internal/expire-orders with both secrets set needs X-Service-Token AND X-Internal-Token", async () => { - const { app } = makeApp({ serviceToken: TOKEN, internalToken: "int-secret" }); - const onlyInternal = await app.request("/internal/expire-orders", { - method: "POST", - headers: { "X-Internal-Token": "int-secret" }, - }); - expect(onlyInternal.status).toBe(401); // blocked at the service-token gate - const onlyService = await app.request("/internal/expire-orders", { - method: "POST", - headers: serviceHeader, - }); - expect(onlyService.status).toBe(401); // passes the gate, 401s at the internal check - const both = await app.request("/internal/expire-orders", { - method: "POST", - headers: { ...serviceHeader, "X-Internal-Token": "int-secret" }, - }); - expect(both.status).toBe(200); - expect(await both.json()).toEqual({ ok: true, expired: 0 }); - }); - - test("/entitlements/grant with both secrets set needs X-Service-Token AND X-Internal-Token", async () => { - const { app } = makeApp({ serviceToken: TOKEN, internalToken: "int-secret" }); - const onlyInternal = await app.request("/entitlements/grant", { - method: "POST", - headers: { ...json, "X-Internal-Token": "int-secret" }, - body: "{}", - }); - expect(onlyInternal.status).toBe(401); // blocked at the service-token gate - const onlyService = await app.request("/entitlements/grant", { - method: "POST", - headers: { ...json, ...serviceHeader }, - body: "{}", - }); - expect(onlyService.status).toBe(401); // passes the gate, 401s at the internal check - // Both headers clear BOTH auth layers: the route's next check is the x402 - // gateway (unwired in this stub app → 503), proving auth was passed. - const both = await app.request("/entitlements/grant", { - method: "POST", - headers: { ...json, ...serviceHeader, "X-Internal-Token": "int-secret" }, - body: "{}", - }); - expect(both.status).toBe(503); - expect(await both.json()).toEqual({ ok: false, error: "x402 not configured" }); - }); - - // ── #25: routes that ALSO carry X-Internal-Token now require BOTH the gate's - // X-Service-Token AND the route's own X-Internal-Token when both secrets are - // set. Pure new coverage — emergent from middleware order, no route change. - - test("PUT /settings with both secrets set needs X-Service-Token AND X-Internal-Token", async () => { - const { app } = makeApp({ serviceToken: TOKEN, internalToken: "int-secret" }); - const put = (headers: Record) => - app.request("/settings", { - method: "PUT", - headers: { ...json, "Idempotency-Key": "settings-1", ...headers }, - body: JSON.stringify({ holdTtlMinutes: 30, lowStockThreshold: 5 }), - }); - expect((await put({ "X-Internal-Token": "int-secret" })).status).toBe(401); // gate - expect((await put(serviceHeader)).status).toBe(401); // internal check - const both = await put({ ...serviceHeader, "X-Internal-Token": "int-secret" }); - expect(both.status).toBe(200); - expect(await both.json()).toMatchObject({ ok: true }); - }); - - test("POST /admin/orders/:id/transition with both secrets set needs X-Service-Token AND X-Internal-Token", async () => { - const { app } = makeApp({ serviceToken: TOKEN, internalToken: "int-secret" }); - const transition = (headers: Record) => - app.request("/admin/orders/order-1/transition", { - method: "POST", - headers: { ...json, ...headers }, - body: JSON.stringify({ toState: "paid" }), - }); - expect((await transition({ "X-Internal-Token": "int-secret" })).status).toBe(401); // gate - expect((await transition(serviceHeader)).status).toBe(401); // internal check - // Both clear auth; the (absent) order then resolves to 404, NOT 401 — proof - // the request passed BOTH auth layers and reached the route body. - const both = await transition({ ...serviceHeader, "X-Internal-Token": "int-secret" }); - expect(both.status).toBe(404); - expect(await both.json()).toEqual({ ok: false, reason: "ORDER_NOT_FOUND" }); - }); - - test("a rules-admin POST (/admin/shipping/zones) with both secrets set needs X-Service-Token AND X-Internal-Token", async () => { - const { app } = makeApp({ serviceToken: TOKEN, internalToken: "int-secret" }); - const create = (headers: Record) => - app.request("/admin/shipping/zones", { - method: "POST", - headers: { ...json, ...headers }, - body: JSON.stringify({ id: "zone-1", name: "Zone 1" }), - }); - expect((await create({ "X-Internal-Token": "int-secret" })).status).toBe(401); // gate - expect((await create(serviceHeader)).status).toBe(401); // internal check - const both = await create({ ...serviceHeader, "X-Internal-Token": "int-secret" }); - expect(both.status).toBe(201); - expect(await both.json()).toMatchObject({ ok: true }); - }); - - // ── #25 HEADLINE regression: the session route `POST /auth/logout` must NOT be - // 401'd at the write gate. Before ADR-0007 the gate consumed Authorization: - // Bearer, so enabling SERVICE_API_TOKEN would 401 every session route (whose - // Bearer carries a customer SESSION token, not the service token) before - // session auth ran. Now the gate reads only X-Service-Token. - test("POST /auth/logout: session Bearer alone is 401 at the gate; X-Service-Token + session Bearer passes", async () => { - const { app } = makeApp({ serviceToken: TOKEN }); - // A customer's session token (any value — logout is idempotent) in - // Authorization, but no X-Service-Token: blocked at the gate. - const gated = await app.request("/auth/logout", { - method: "POST", - headers: { Authorization: "Bearer customer-session-xyz" }, - }); - expect(gated.status).toBe(401); - expect(await gated.json()).toEqual({ ok: false, error: "unauthorized" }); - - // BOTH headers: the gate passes on X-Service-Token, and the session route - // runs (revoke is idempotent) → 200. The two headers do not collide. - const ok = await app.request("/auth/logout", { - method: "POST", - headers: { ...serviceHeader, Authorization: "Bearer customer-session-xyz" }, - }); - expect(ok.status).toBe(200); - expect(await ok.json()).toEqual({ ok: true }); - }); -}); - -describe("SERVICE_API_TOKEN unset (regression pin: exactly today's behavior)", () => { - test("writes need no Authorization header", async () => { - const { app, inventory } = makeApp(); - inventory.seed("SKU-2", 4); - - const created = await app.request("/carts", { method: "POST", headers: json, body: "{}" }); - expect(created.status).toBe(201); - - const reserve = await app.request("/inventory/reserve", { - method: "POST", - headers: { ...json, "Idempotency-Key": "r1" }, - body: JSON.stringify({ sku: "SKU-2", qty: 1 }), - }); - expect(reserve.status).toBe(200); - - const batch = await app.request("/catalog/commerce/batch", { - method: "POST", - headers: json, - body: JSON.stringify({ productIds: ["p1"] }), - }); - expect(batch.status).toBe(200); - }); - - test("/internal/expire-holds keeps its own gate: 503 disabled, 401 mismatch", async () => { - const disabled = makeApp(); - expect((await disabled.app.request("/internal/expire-holds", { method: "POST" })).status).toBe( - 503, - ); - - const enabled = makeApp({ internalToken: "int-secret" }); - expect( - ( - await enabled.app.request("/internal/expire-holds", { - method: "POST", - headers: { "X-Internal-Token": "wrong" }, - }) - ).status, - ).toBe(401); - }); -}); diff --git a/packages/service/test/settings-http.test.ts b/packages/service/test/settings-http.test.ts deleted file mode 100644 index c2f79cea..00000000 --- a/packages/service/test/settings-http.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test } from "vitest"; -import { - STRIPE_WEBHOOK_SECRET, - startTestServer, - type TestServer, -} from "./helpers/start-test-server.js"; - -// Phase 7 §7 Step 5: wire ⇄ port fidelity for /settings, plus the settings-tiering -// SECURITY test — no secret-shaped field is ever in a /settings response body. - -const PG = process.env.PG_CONNECTION_STRING; - -async function json(res: Response): Promise> { - return (await res.json()) as Record; -} - -describe.skipIf(PG === undefined)("settings HTTP contract", () => { - let server: TestServer; - let token: string; - beforeEach(async () => { - server = await startTestServer(); - token = server.internalToken as string; - }); - afterEach(async () => { - await server.stop(); - }); - - /** `GET /settings` is admin surface (ADR-0010), so the read carries the - * internal token exactly like the PUT — the write gate's GET/HEAD exemption - * is not authorization. The unauthenticated cases live in the IO-free - * `admin-read-gate.test.ts`. */ - function get(): Promise { - return fetch(`${server.baseUrl}/settings`, { headers: { "X-Internal-Token": token } }); - } - - function put(body: unknown, opts: { token?: string; key?: string } = {}): Promise { - const headers: Record = { "content-type": "application/json" }; - if (opts.token !== undefined) headers["X-Internal-Token"] = opts.token; - if (opts.key !== undefined) headers["Idempotency-Key"] = opts.key; - return fetch(`${server.baseUrl}/settings`, { - method: "PUT", - headers, - body: JSON.stringify(body), - }); - } - - test("GET /settings returns the operational defaults before any write", async () => { - const body = await json(await get()); - expect(body).toEqual({ ok: true, settings: { holdTtlMinutes: 15, lowStockThreshold: 5 } }); - }); - - test("PUT /settings persists and GET reflects it", async () => { - const res = await put({ holdTtlMinutes: 45, lowStockThreshold: 20 }, { token, key: "k-1" }); - expect(res.status).toBe(200); - expect((await json(res)).settings).toEqual({ holdTtlMinutes: 45, lowStockThreshold: 20 }); - const read = await json(await get()); - expect(read.settings).toEqual({ holdTtlMinutes: 45, lowStockThreshold: 20 }); - }); - - test("PUT /settings replayed with the same Idempotency-Key does not double-apply", async () => { - const first = await json(await put({ holdTtlMinutes: 30 }, { token, key: "k-rep" })); - await put({ holdTtlMinutes: 99 }, { token, key: "k-other" }); - const replay = await json(await put({ holdTtlMinutes: 30 }, { token, key: "k-rep" })); - expect(replay.settings).toEqual(first.settings); - const read = await json(await get()); - expect((read.settings as Record).holdTtlMinutes).toBe(99); - }); - - test("PUT /settings with holdTtlMinutes=0 returns 400 with a structured validation error", async () => { - const res = await put({ holdTtlMinutes: 0 }, { token, key: "k-bad" }); - expect(res.status).toBe(400); - const body = await json(res); - expect(body.ok).toBe(false); - expect(body.error).toBe("validation_error"); - }); - - test("PUT /settings with a non-integer lowStockThreshold returns 400", async () => { - const res = await put({ lowStockThreshold: 2.5 }, { token, key: "k-frac" }); - expect(res.status).toBe(400); - }); - - test("PUT /settings without the Idempotency-Key header returns 400", async () => { - const res = await put({ holdTtlMinutes: 20 }, { token }); - expect(res.status).toBe(400); - }); - - test("PUT /settings without the internal token is rejected (not silently open)", async () => { - const res = await put({ holdTtlMinutes: 20 }, { key: "k-noauth" }); - expect(res.status).toBe(401); - }); - - test("SECURITY: no secret-shaped field is ever returned from GET /settings", async () => { - // Change settings so the row exists, then read it back. - await put({ holdTtlMinutes: 42, lowStockThreshold: 7 }, { token, key: "k-sec" }); - const res = await get(); - const raw = await res.text(); - const body = JSON.parse(raw) as { settings: Record }; - - // The response shape is EXACTLY the two operational fields — nothing else. - expect(Object.keys(body.settings).toSorted()).toEqual(["holdTtlMinutes", "lowStockThreshold"]); - - // No secret-shaped key, at any depth of the serialized body. - expect(raw).not.toMatch(/secret|password|stripe|webhook|x402|dbUrl|connectionString|apiKey/i); - // And the actual known secret value never appears. - expect(raw).not.toContain(STRIPE_WEBHOOK_SECRET); - }); -}); diff --git a/packages/service/test/stripe-wiring.test.ts b/packages/service/test/stripe-wiring.test.ts deleted file mode 100644 index 68dbe858..00000000 --- a/packages/service/test/stripe-wiring.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { cents, currency, idempotencyKey, orderId } from "@otta-sh/domain"; -import { afterEach, describe, expect, test, vi } from "vitest"; -import { wireStripeGateway } from "../src/stripe-wiring.js"; - -// The inverse hazard of the live-createIntent change: a deployment with a -// STRIPE_WEBHOOK_SECRET but NO STRIPE_SECRET_KEY hands buyers OFFLINE (fake, -// unpayable) client secrets. That must be a loud boot warning — and NEVER a -// throw: staging / e2e run without a secret key and must keep working. - -describe("wireStripeGateway (boot signal, never fail-closed)", () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - test("no STRIPE_WEBHOOK_SECRET ⇒ undefined (Stripe simply not configured), no warning", () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - expect(wireStripeGateway({})).toBeUndefined(); - expect(wireStripeGateway({ STRIPE_WEBHOOK_SECRET: "" })).toBeUndefined(); - expect(wireStripeGateway({ STRIPE_SECRET_KEY: "sk_test" })).toBeUndefined(); - expect(warn).not.toHaveBeenCalled(); - }); - - test("webhook secret ONLY ⇒ a gateway with refundable:false + OFFLINE intents, and a loud warning about unpayable client secrets", async () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const gateway = wireStripeGateway({ STRIPE_WEBHOOK_SECRET: "whsec_x" }); - expect(gateway).toBeDefined(); - expect(gateway?.refundable).toBe(false); - expect(warn).toHaveBeenCalledOnce(); - expect(String(warn.mock.calls[0])).toMatch(/STRIPE_SECRET_KEY/); - expect(String(warn.mock.calls[0])).toMatch(/offline|unpayable/i); - // And it really is the offline deterministic handle. - const intent = await gateway!.createIntent({ - orderId: orderId("ord-9"), - amount: cents(1000), - currency: currency("USD"), - idempotencyKey: idempotencyKey("k-9"), - lines: [{ title: "Widget", quantity: 1 }], - }); - expect(intent.intentId).toBe("pi_ord-9"); - }); - - test("webhook secret + STRIPE_SECRET_KEY ⇒ refundable:true (live intents), no warning", () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const gateway = wireStripeGateway({ - STRIPE_WEBHOOK_SECRET: "whsec_x", - STRIPE_SECRET_KEY: "sk_test_1", - }); - expect(gateway?.refundable).toBe(true); - expect(warn).not.toHaveBeenCalled(); - }); - - test("an EMPTY-STRING STRIPE_SECRET_KEY is treated as absent (offline + the warning)", () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const gateway = wireStripeGateway({ STRIPE_WEBHOOK_SECRET: "whsec_x", STRIPE_SECRET_KEY: "" }); - expect(gateway?.refundable).toBe(false); - expect(warn).toHaveBeenCalledOnce(); - }); -}); diff --git a/packages/service/test/worker-entry.pg.test.ts b/packages/service/test/worker-entry.pg.test.ts deleted file mode 100644 index 01d5c962..00000000 --- a/packages/service/test/worker-entry.pg.test.ts +++ /dev/null @@ -1,240 +0,0 @@ -import { makePostgresDb, makePostgresPool, migrateToLatest } from "@otta-sh/store-postgres/pg"; -import { afterAll, beforeAll, describe, expect, test, vi } from "vitest"; -import { createWorker, type WorkerEnv } from "../src/worker.js"; - -const PG = process.env.PG_CONNECTION_STRING; - -function makeCtx(): { - ctx: { waitUntil(promise: Promise): void }; - settle(): Promise; -} { - const waits: Promise[] = []; - return { - ctx: { - waitUntil(promise: Promise): void { - waits.push(promise); - }, - }, - async settle(): Promise { - await Promise.allSettled(waits); - }, - }; -} - -// PG-gated Worker integration (tests 11–13): the full env → pool → migration → -// stores → app wiring through `worker.fetch`/`worker.scheduled` against real -// Postgres. Isolation: a dedicated schema, reached BOTH by the worker (a -// search_path-scoped connection string in the fake HYPERDRIVE binding) and by -// its lazy migration (`overrides.migrate` pins `migrationTableSchema` — an -// unqualified migrateToLatest matches migration tables by name across ALL -// schemas; the production single-schema default path is covered by the manual -// wrangler-dev verification). -describe.skipIf(PG === undefined)("Worker entry [Postgres]", () => { - const connectionString = PG as string; - const schema = `test_worker_${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`; - const scopedConnectionString = `${connectionString}?options=${encodeURIComponent(`-c search_path=${schema}`)}`; - - const admin = makePostgresPool({ connectionString, max: 1 }); - const probePool = makePostgresPool({ - connectionString, - max: 2, - options: `-c search_path=${schema}`, - }); - const probeDb = makePostgresDb(probePool); - - const migrate = (db: ReturnType): Promise => - migrateToLatest(db, { migrationTableSchema: schema }); - - async function onHand(sku: string): Promise { - const row = await probeDb - .selectFrom("inventory") - .select("on_hand") - .where("sku", "=", sku) - .executeTakeFirst(); - return row?.on_hand ?? 0; - } - - async function seed(sku: string, qty: number): Promise { - await probeDb - .insertInto("inventory") - .values({ sku, on_hand: qty }) - .onConflict((oc) => oc.column("sku").doUpdateSet({ on_hand: qty })) - .execute(); - } - - beforeAll(async () => { - await admin.query(`CREATE SCHEMA "${schema}"`); - vi.spyOn(console, "warn").mockImplementation(() => {}); - vi.spyOn(console, "log").mockImplementation(() => {}); - }); - - afterAll(async () => { - vi.restoreAllMocks(); - await probeDb.destroy(); - await admin.query(`DROP SCHEMA "${schema}" CASCADE`); - await admin.end(); - }); - - test("test 11: e2e fetch round-trip — lazy migration, gated writes, on_hand decremented in the DB", async () => { - const worker = createWorker({ migrate }); - const env: WorkerEnv = { - HYPERDRIVE: { connectionString: scopedConnectionString }, - SERVICE_API_TOKEN: "worker-pg-secret", - }; - const serviceHeader = { "X-Service-Token": "worker-pg-secret" }; - const { ctx, settle } = makeCtx(); - - // First event: the schema is empty — /health both proves the wiring and - // triggers the lazy migration. - const health = await worker.fetch(new Request("http://worker.test/health"), env, ctx); - expect(health.status).toBe(200); - expect(await health.json()).toEqual({ ok: true }); - const applied = await admin.query(`SELECT name FROM "${schema}".kysely_migration`); - expect(applied.rows.length).toBeGreaterThanOrEqual(4); // all forward-only migrations ran - - await seed("SKU-WORKER", 5); - - // A tokenless write is rejected by the env-carried gate. - const tokenless = await worker.fetch( - new Request("http://worker.test/carts", { - method: "POST", - headers: { "content-type": "application/json" }, - body: "{}", - }), - env, - ctx, - ); - expect(tokenless.status).toBe(401); - - // The full cart round-trip with the X-Service-Token write gate (ADR-0007). - const created = await worker.fetch( - new Request("http://worker.test/carts", { - method: "POST", - headers: { "content-type": "application/json", ...serviceHeader }, - body: "{}", - }), - env, - ctx, - ); - expect(created.status).toBe(201); - const { cartId } = (await created.json()) as { cartId: string }; - - const added = await worker.fetch( - new Request(`http://worker.test/carts/${cartId}/lines`, { - method: "POST", - headers: { - "content-type": "application/json", - "Idempotency-Key": "wk-1", - ...serviceHeader, - }, - body: JSON.stringify({ sku: "SKU-WORKER", qty: 2 }), - }), - env, - ctx, - ); - expect(added.status).toBe(200); - expect((await added.json()) as { ok: boolean }).toMatchObject({ ok: true }); - expect(await onHand("SKU-WORKER")).toBe(3); - - // Reads stay open: GET /carts/:id without any token. - const read = await worker.fetch(new Request(`http://worker.test/carts/${cartId}`), env, ctx); - expect(read.status).toBe(200); - - await settle(); - }); - - test("test 12: scheduled reclaims expired holds against real PG with NO tokens in env", async () => { - const worker = createWorker({ migrate }); - const env: WorkerEnv = { - HYPERDRIVE: { connectionString: scopedConnectionString }, - CART_HOLD_TTL_MS: "1", - }; - const { ctx, settle } = makeCtx(); - - await seed("SKU-SWEEP-W", 5); - const created = await worker.fetch( - new Request("http://worker.test/carts", { - method: "POST", - headers: { "content-type": "application/json" }, - body: "{}", - }), - env, - ctx, - ); - expect(created.status).toBe(201); - const { cartId } = (await created.json()) as { cartId: string }; - const added = await worker.fetch( - new Request(`http://worker.test/carts/${cartId}/lines`, { - method: "POST", - headers: { "content-type": "application/json", "Idempotency-Key": "wk-sweep-1" }, - body: JSON.stringify({ sku: "SKU-SWEEP-W", qty: 3 }), - }), - env, - ctx, - ); - expect(added.status).toBe(200); - expect(await onHand("SKU-SWEEP-W")).toBe(2); - - // Let the 1ms TTL lapse, then run the cron handler. - await new Promise((resolve) => setTimeout(resolve, 25)); - await worker.scheduled({ scheduledTime: Date.now(), cron: "*/15 * * * *" }, env, ctx); - await settle(); - - expect(await onHand("SKU-SWEEP-W")).toBe(5); // the hold's stock is back - }); - - test("test 13: /internal/expire-holds parity through worker.fetch (config-flow proof for the secrets)", async () => { - // Both secrets set: the endpoint needs X-Service-Token AND X-Internal-Token. - const worker = createWorker({ migrate }); - const env: WorkerEnv = { - HYPERDRIVE: { connectionString: scopedConnectionString }, - SERVICE_API_TOKEN: "svc-w", - INTERNAL_API_TOKEN: "int-w", - }; - const { ctx, settle } = makeCtx(); - const url = "http://worker.test/internal/expire-holds"; - - const both = await worker.fetch( - new Request(url, { - method: "POST", - headers: { "X-Service-Token": "svc-w", "X-Internal-Token": "int-w" }, - }), - env, - ctx, - ); - expect(both.status).toBe(200); - expect((await both.json()) as { ok: boolean }).toMatchObject({ ok: true }); - - const wrongInternal = await worker.fetch( - new Request(url, { - method: "POST", - headers: { "X-Service-Token": "svc-w", "X-Internal-Token": "wrong" }, - }), - env, - ctx, - ); - expect(wrongInternal.status).toBe(401); - - const missingService = await worker.fetch( - new Request(url, { method: "POST", headers: { "X-Internal-Token": "int-w" } }), - env, - ctx, - ); - expect(missingService.status).toBe(401); - - // INTERNAL_API_TOKEN absent from env: 503 (disabled), never silently open. - const workerNoInternal = createWorker({ migrate }); - const envNoInternal: WorkerEnv = { - HYPERDRIVE: { connectionString: scopedConnectionString }, - SERVICE_API_TOKEN: "svc-w", - }; - const disabled = await workerNoInternal.fetch( - new Request(url, { method: "POST", headers: { "X-Service-Token": "svc-w" } }), - envNoInternal, - ctx, - ); - expect(disabled.status).toBe(503); - - await settle(); - }); -}); diff --git a/packages/service/test/worker-entry.test.ts b/packages/service/test/worker-entry.test.ts deleted file mode 100644 index 406540d5..00000000 --- a/packages/service/test/worker-entry.test.ts +++ /dev/null @@ -1,392 +0,0 @@ -import type { makePostgresPool } from "@otta-sh/store-postgres/pg"; -import { afterEach, describe, expect, test, vi } from "vitest"; -import { createWorker, type WorkerEnv } from "../src/worker.js"; - -// Worker-entry unit tests over injected fakes (no PG): the factory closure -// owns the config + migration memos (D2), pools are per-request with -// try/finally teardown via ctx.waitUntil (D1), and pre-app failures surface as -// the standard 500 envelope — never an uncaught workerd exception. - -type PgPool = ReturnType; - -interface RecordedPool { - ended: boolean; - queries: string[]; -} - -/** A fake pg Pool factory satisfying exactly what kysely's PostgresDriver - * touches: `connect()` → client with `query`/`release`, `end()`, `ending`. - * `failFirstEnd` makes the FIRST `end()` call reject without marking the - * pool ended (simulating a rejecting `db.destroy()`); retries succeed. */ -function fakePools(options: { failQueries?: boolean; failFirstEnd?: boolean } = {}): { - pools: RecordedPool[]; - makePool: typeof makePostgresPool; -} { - const pools: RecordedPool[] = []; - const makePool = ((): PgPool => { - const record: RecordedPool = { ended: false, queries: [] }; - pools.push(record); - let endCalls = 0; - const client = { - query: (sql: string): Promise<{ command: string; rowCount: number; rows: never[] }> => { - record.queries.push(sql); - if (options.failQueries) return Promise.reject(new Error("fake query failure")); - // Kysely only exposes numAffectedRows for mutation commands, so the - // fake must echo the real command tag (e.g. the challenge prune reads - // DeleteResult.numDeletedRows - a "SELECT" tag would make it NaN). - const command = /^\s*(delete|insert|update)/i.exec(sql)?.[1]?.toUpperCase() ?? "SELECT"; - return Promise.resolve({ command, rowCount: 0, rows: [] }); - }, - release: (): void => {}, - }; - const pool = { - ending: false, - connect: () => Promise.resolve(client), - end: (): Promise => { - endCalls++; - if (options.failFirstEnd === true && endCalls === 1) { - return Promise.reject(new Error("fake destroy failure")); - } - pool.ending = true; - record.ended = true; - return Promise.resolve(); - }, - }; - return pool as unknown as PgPool; - }) as typeof makePostgresPool; - return { pools, makePool }; -} - -function makeCtx(): { - ctx: { waitUntil(promise: Promise): void }; - settle(): Promise; -} { - const waits: Promise[] = []; - return { - ctx: { - waitUntil(promise: Promise): void { - waits.push(promise); - }, - }, - async settle(): Promise { - await Promise.allSettled(waits); - }, - }; -} - -const ENV: WorkerEnv = { HYPERDRIVE: { connectionString: "postgres://fake:fake@fake:5432/fake" } }; -const CONTROLLER = { scheduledTime: 0, cron: "*/15 * * * *" }; - -function silence() { - return { - error: vi.spyOn(console, "error").mockImplementation(() => {}), - log: vi.spyOn(console, "log").mockImplementation(() => {}), - }; -} - -afterEach(() => { - vi.restoreAllMocks(); -}); - -describe("createWorker fetch", () => { - test("test 5: a missing/empty HYPERDRIVE binding is a logged descriptive error and a wire 500 envelope", async () => { - vi.spyOn(console, "warn").mockImplementation(() => {}); - for (const env of [{}, { HYPERDRIVE: { connectionString: "" } }] satisfies WorkerEnv[]) { - const { pools, makePool } = fakePools(); - const worker = createWorker({ makePool, migrate: () => Promise.resolve() }); - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - const { ctx, settle } = makeCtx(); - - const res = await worker.fetch(new Request("http://worker.test/health"), env, ctx); - await settle(); - - expect(res.status).toBe(500); - expect(await res.json()).toEqual({ ok: false, error: "internal_error" }); - expect(pools.length).toBe(0); // failed before any pool existed - const logged = errorSpy.mock.calls.map((call) => call.map(String).join(" ")).join("\n"); - expect(logged).toContain("HYPERDRIVE"); - expect(logged).toContain("nodejs_compat"); - errorSpy.mockRestore(); - } - }); - - test("test 6: migration runs once per factory instance across many fetches", async () => { - vi.spyOn(console, "warn").mockImplementation(() => {}); - const migrate = vi.fn(() => Promise.resolve()); - const { makePool } = fakePools(); - const worker = createWorker({ makePool, migrate }); - const { ctx, settle } = makeCtx(); - - for (let i = 0; i < 3; i++) { - const res = await worker.fetch(new Request("http://worker.test/health"), ENV, ctx); - expect(res.status).toBe(200); - } - await settle(); - expect(migrate).toHaveBeenCalledTimes(1); - - // A second instance shares nothing: it migrates independently. - const worker2 = createWorker({ makePool, migrate }); - const second = makeCtx(); - await worker2.fetch(new Request("http://worker.test/health"), ENV, second.ctx); - await second.settle(); - expect(migrate).toHaveBeenCalledTimes(2); - }); - - test("test 7: a rejected migration is a 500, destroys its own pool, and clears the memo for a retry", async () => { - const { error } = silence(); - vi.spyOn(console, "warn").mockImplementation(() => {}); - const migrate = vi - .fn<() => Promise>() - .mockRejectedValueOnce(new Error("migration boom")) - .mockResolvedValue(undefined); - const { pools, makePool } = fakePools(); - const worker = createWorker({ makePool, migrate }); - - const first = makeCtx(); - const failed = await worker.fetch(new Request("http://worker.test/health"), ENV, first.ctx); - await first.settle(); - expect(failed.status).toBe(500); - expect(await failed.json()).toEqual({ ok: false, error: "internal_error" }); - expect(pools.length).toBe(1); - expect(pools[0]?.ended).toBe(true); // the failing event's pool is NOT abandoned - expect(error).toHaveBeenCalled(); - - const second = makeCtx(); - const retried = await worker.fetch(new Request("http://worker.test/health"), ENV, second.ctx); - await second.settle(); - expect(retried.status).toBe(200); - expect(migrate).toHaveBeenCalledTimes(2); // memo cleared on rejection - expect(pools.length).toBe(2); // a fresh pool per event - expect(pools[1]?.ended).toBe(true); - }); - - test("test 8: a new pool per request, every pool ended once waitUntil settles", async () => { - vi.spyOn(console, "warn").mockImplementation(() => {}); - const { pools, makePool } = fakePools(); - const worker = createWorker({ makePool, migrate: () => Promise.resolve() }); - const { ctx, settle } = makeCtx(); - - await worker.fetch(new Request("http://worker.test/health"), ENV, ctx); - await worker.fetch(new Request("http://worker.test/health"), ENV, ctx); - await settle(); - - expect(pools.length).toBe(2); // never reused across requests - expect(pools.every((pool) => pool.ended)).toBe(true); - }); - - test("test 8b: a config-parse failure is a 500 with ZERO pools, and the error is memoized", async () => { - const { error } = silence(); - vi.spyOn(console, "warn").mockImplementation(() => {}); - const { pools, makePool } = fakePools(); - const worker = createWorker({ makePool, migrate: () => Promise.resolve() }); - const env: WorkerEnv = { ...ENV, CART_HOLD_TTL_MS: "abc" }; - const { ctx, settle } = makeCtx(); - - const first = await worker.fetch(new Request("http://worker.test/health"), env, ctx); - expect(first.status).toBe(500); - expect(await first.json()).toEqual({ ok: false, error: "internal_error" }); - expect(pools.length).toBe(0); // config resolves before any pool exists - - const second = await worker.fetch(new Request("http://worker.test/health"), env, ctx); - expect(second.status).toBe(500); - await settle(); - expect(pools.length).toBe(0); // parse error memoized — makePool never called - const logged = error.mock.calls.map((call) => call.map(String).join(" ")).join("\n"); - expect(logged).toContain("CART_HOLD_TTL_MS"); - }); - - test("test 9: config flows from the env binding, not process.env (SERVICE_API_TOKEN gates writes)", async () => { - vi.spyOn(console, "warn").mockImplementation(() => {}); - expect(process.env.SERVICE_API_TOKEN).toBeUndefined(); - const { makePool } = fakePools(); - const worker = createWorker({ makePool, migrate: () => Promise.resolve() }); - const env: WorkerEnv = { ...ENV, SERVICE_API_TOKEN: "worker-secret" }; - const { ctx, settle } = makeCtx(); - - const health = await worker.fetch(new Request("http://worker.test/health"), env, ctx); - expect(health.status).toBe(200); - - const tokenless = await worker.fetch( - new Request("http://worker.test/carts", { - method: "POST", - headers: { "content-type": "application/json" }, - body: "{}", - }), - env, - ctx, - ); - expect(tokenless.status).toBe(401); - expect(await tokenless.json()).toEqual({ ok: false, error: "unauthorized" }); - - // The env-carried gate reads X-Service-Token (ADR-0007): the same POST with - // the matching header passes the write gate. It lands on /internal (whose - // own INTERNAL_API_TOKEN is unset here → 503 disabled), so a NON-401 status - // proves the gate honored the header — no DB round-trip needed. - const withServiceToken = await worker.fetch( - new Request("http://worker.test/internal/expire-holds", { - method: "POST", - headers: { "X-Service-Token": "worker-secret" }, - }), - env, - ctx, - ); - expect(withServiceToken.status).toBe(503); - await settle(); - }); - - test("the one-time open-gate warning fires for an EMPTY SERVICE_API_TOKEN too (empty opens the gate)", async () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const { makePool } = fakePools(); - const worker = createWorker({ makePool, migrate: () => Promise.resolve() }); - const env: WorkerEnv = { ...ENV, SERVICE_API_TOKEN: "" }; - const { ctx, settle } = makeCtx(); - - await worker.fetch(new Request("http://worker.test/health"), env, ctx); - await worker.fetch(new Request("http://worker.test/health"), env, ctx); - await settle(); - - const warnings = warn.mock.calls - .map((call) => call.map(String).join(" ")) - .filter((line) => line.includes("SERVICE_API_TOKEN")); - expect(warnings).toHaveLength(1); // fired, and only once per isolate - - // A set token never warns. - const warned = createWorker({ makePool, migrate: () => Promise.resolve() }); - warn.mockClear(); - await warned.fetch( - new Request("http://worker.test/health"), - { ...ENV, SERVICE_API_TOKEN: "tok" }, - ctx, - ); - await settle(); - expect(warn.mock.calls.flat().map(String).join("\n")).not.toContain("SERVICE_API_TOKEN"); - }); - - test("Phase 4 gateways wire from the env binding; the webhook stays service-token-exempt", async () => { - vi.spyOn(console, "warn").mockImplementation(() => {}); - const { makePool } = fakePools(); - const worker = createWorker({ makePool, migrate: () => Promise.resolve() }); - const { ctx, settle } = makeCtx(); - const env: WorkerEnv = { - ...ENV, - SERVICE_API_TOKEN: "worker-secret", - STRIPE_WEBHOOK_SECRET: "whsec_worker_unit", - }; - const webhook = () => - worker.fetch( - new Request("http://worker.test/webhooks/stripe", { - method: "POST", - headers: { "content-type": "application/json", "Stripe-Signature": "t=1,v1=bad" }, - body: JSON.stringify({ id: "evt_1" }), - }), - env, - ctx, - ); - // No X-Service-Token, gateway wired from env: the request reaches the route - // and is rejected by its OWN Stripe-Signature verification (400), never the gate. - const res = await webhook(); - expect(res.status).toBe(400); - expect(await res.json()).toEqual({ ok: false, reason: "INVALID_SIGNATURE" }); - - // Without the secret, a separate instance answers 503 (gateway unwired). - const bare = createWorker({ makePool, migrate: () => Promise.resolve() }); - const noGateway = await bare.fetch( - new Request("http://worker.test/webhooks/stripe", { method: "POST", body: "{}" }), - ENV, - ctx, - ); - expect(noGateway.status).toBe(503); - await settle(); - }); - - test("misconfigured x402 env (no test-facilitator opt-in) is the 500 envelope, memoized before any pool", async () => { - const { error } = silence(); - vi.spyOn(console, "warn").mockImplementation(() => {}); - const { pools, makePool } = fakePools(); - const worker = createWorker({ makePool, migrate: () => Promise.resolve() }); - const env: WorkerEnv = { - ...ENV, - X402_PAYTO: "0xTEST", - X402_FACILITATOR_SECRET: "s3cret", - // X402_ALLOW_TEST_FACILITATOR deliberately absent — fail closed (G4). - }; - const { ctx, settle } = makeCtx(); - - const first = await worker.fetch(new Request("http://worker.test/health"), env, ctx); - expect(first.status).toBe(500); - expect(await first.json()).toEqual({ ok: false, error: "internal_error" }); - const second = await worker.fetch(new Request("http://worker.test/health"), env, ctx); - expect(second.status).toBe(500); - await settle(); - expect(pools.length).toBe(0); // wiring failed before any pool existed; error memoized - const logged = error.mock.calls.map((call) => call.map(String).join(" ")).join("\n"); - expect(logged).toContain("X402_ALLOW_TEST_FACILITATOR"); - }); -}); - -describe("createWorker scheduled", () => { - test("test 10: the sweep runs once per event with NO tokens in env, and tears its pool down", async () => { - const { log } = silence(); - vi.spyOn(console, "warn").mockImplementation(() => {}); - const migrate = vi.fn(() => Promise.resolve()); - const { pools, makePool } = fakePools(); - const worker = createWorker({ makePool, migrate }); - const { ctx, settle } = makeCtx(); - - await worker.scheduled(CONTROLLER, ENV, ctx); // ENV carries no tokens at all - await settle(); - - expect(migrate).toHaveBeenCalledTimes(1); - // All four janitors run once per event: the hold sweep, the Phase-4 - // order-expiry sweep (clock-driven, no lazy-on-read fallback), and the - // Phase-5 email-outbox drain + login-challenge prune. - const sweepLogs = log.mock.calls - .map((call) => call.map(String).join(" ")) - .filter((line) => line.includes("cron sweep")); - expect(sweepLogs).toEqual([ - "[service] cron sweep reclaimed 0", - "[service] cron sweep expired 0 orders", - "[service] cron sweep sent 0 emails", - "[service] cron sweep pruned 0 login challenges", - ]); - expect(pools.length).toBe(1); - expect(pools[0]?.ended).toBe(true); - }); - - test("test 10 (failure): each sweep has its own catch — a failing hold sweep does not starve order expiry, labels are distinct, the pool is still destroyed", async () => { - const { error } = silence(); - vi.spyOn(console, "warn").mockImplementation(() => {}); - const { pools, makePool } = fakePools({ failQueries: true }); - const worker = createWorker({ makePool, migrate: () => Promise.resolve() }); - const { ctx, settle } = makeCtx(); - - await expect(worker.scheduled(CONTROLLER, ENV, ctx)).resolves.toBeUndefined(); - await settle(); - - const logged = error.mock.calls.map((call) => call.map(String).join(" ")).join("\n"); - // The hold sweep rejected AND the order sweep still ran (its own, - // distinctly-labeled rejection proves it was reached). - expect(logged).toContain("hold sweep failed"); - expect(logged).toContain("order sweep failed"); - expect(pools.length).toBe(1); - expect(pools[0]?.ended).toBe(true); - }); - - test("teardown: a rejecting db.destroy() cannot skip pool.end()", async () => { - const { error } = silence(); - vi.spyOn(console, "warn").mockImplementation(() => {}); - // scheduled runs real queries, so kysely's driver initializes and - // db.destroy() reaches pool.end() — whose first call rejects here. - const { pools, makePool } = fakePools({ failFirstEnd: true }); - const worker = createWorker({ makePool, migrate: () => Promise.resolve() }); - const { ctx, settle } = makeCtx(); - - await worker.scheduled(CONTROLLER, ENV, ctx); - await settle(); - - expect(pools.length).toBe(1); - expect(pools[0]?.ended).toBe(true); // the finally-side end() still ran - const logged = error.mock.calls.map((call) => call.map(String).join(" ")).join("\n"); - expect(logged).toContain("pool teardown failed"); // rejection surfaced, not swallowed silently - }); -}); diff --git a/packages/service/test/x402-wiring.test.ts b/packages/service/test/x402-wiring.test.ts deleted file mode 100644 index cd2ea431..00000000 --- a/packages/service/test/x402-wiring.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { afterEach, describe, expect, test, vi } from "vitest"; -import { wireX402Gateway } from "../src/x402-wiring.js"; - -// Review G4: enabling X402_PAYTO + X402_FACILITATOR_SECRET used to silently -// wire createTestFacilitator — an OFFLINE shared-secret HMAC "verifier" — into -// the production bin, so a forged proof could settle any same-priced order. -// The bin must FAIL CLOSED: the test facilitator is wired only under an -// explicit, alarmingly-named opt-in. - -describe("wireX402Gateway (fail-closed env wiring)", () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - test("returns undefined (x402 not configured) when payTo or the facilitator secret is missing", () => { - expect(wireX402Gateway({})).toBeUndefined(); - expect(wireX402Gateway({ X402_PAYTO: "0xABC" })).toBeUndefined(); - expect(wireX402Gateway({ X402_FACILITATOR_SECRET: "s" })).toBeUndefined(); - expect(wireX402Gateway({ X402_PAYTO: "0xABC", X402_FACILITATOR_SECRET: "" })).toBeUndefined(); - }); - - test("FAILS CLOSED: payTo + secret WITHOUT the explicit opt-in throws at startup, naming the opt-in", () => { - expect(() => - wireX402Gateway({ X402_PAYTO: "0xABC", X402_FACILITATOR_SECRET: "s3cret" }), - ).toThrowError(/X402_ALLOW_TEST_FACILITATOR/); - // Any value other than the literal "true" is still fail-closed. - expect(() => - wireX402Gateway({ - X402_PAYTO: "0xABC", - X402_FACILITATOR_SECRET: "s3cret", - X402_ALLOW_TEST_FACILITATOR: "1", - }), - ).toThrowError(/X402_ALLOW_TEST_FACILITATOR/); - }); - - test("with X402_ALLOW_TEST_FACILITATOR=true it wires the gateway and warns loudly that this is NOT production-safe", () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const gateway = wireX402Gateway({ - X402_PAYTO: "0xABC", - X402_FACILITATOR_SECRET: "s3cret", - X402_ALLOW_TEST_FACILITATOR: "true", - X402_ACCEPTS: "eip155:8453,eip155:1", - }); - expect(gateway).toBeDefined(); - expect(gateway?.id).toBe("x402"); - expect(warn).toHaveBeenCalledOnce(); - expect(String(warn.mock.calls[0])).toMatch(/not.*production/i); - }); -}); diff --git a/packages/service/tsconfig.json b/packages/service/tsconfig.json deleted file mode 100644 index a291290b..00000000 --- a/packages/service/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist/tsc", - "emitDeclarationOnly": true, - "rootDir": "." - }, - "include": ["src", "test", "tsdown.config.ts", "vitest.config.ts"], - "references": [ - { "path": "../domain" }, - { "path": "../store-postgres" }, - { "path": "../payments-stripe" }, - { "path": "../payments-x402" } - ] -} diff --git a/packages/service/tsdown.config.ts b/packages/service/tsdown.config.ts deleted file mode 100644 index 459a205a..00000000 --- a/packages/service/tsdown.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { defineConfig } from "tsdown"; - -export default defineConfig({ - entry: ["src/index.ts", "src/app.ts", "src/worker.ts"], - format: ["esm"], - dts: true, -}); diff --git a/packages/service/vitest.config.ts b/packages/service/vitest.config.ts deleted file mode 100644 index de3c43e0..00000000 --- a/packages/service/vitest.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - name: "service", - include: ["test/**/*.test.ts"], - }, -}); diff --git a/packages/store-postgres/package.json b/packages/store-postgres/package.json deleted file mode 100644 index 8f533e31..00000000 --- a/packages/store-postgres/package.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "name": "@otta-sh/store-postgres", - "version": "0.0.1", - "description": "Kysely-backed store adapters for Otta — dialect-parameterized over better-sqlite3 (local) and pg (CI/prod).", - "homepage": "https://github.com/UrumiAI/otta.sh#readme", - "bugs": { - "url": "https://github.com/UrumiAI/otta.sh/issues" - }, - "license": "MIT", - "repository": { - "type": "git", - "url": "git+https://github.com/UrumiAI/otta.sh.git", - "directory": "packages/store-postgres" - }, - "files": [ - "dist" - ], - "type": "module", - "exports": { - ".": "./src/index.ts", - "./pg": "./src/pg.ts", - "./testing": "./src/testing.ts" - }, - "publishConfig": { - "exports": { - ".": { - "types": "./dist/index.d.mts", - "default": "./dist/index.mjs" - }, - "./pg": { - "types": "./dist/pg.d.mts", - "default": "./dist/pg.mjs" - }, - "./testing": { - "types": "./dist/testing.d.mts", - "default": "./dist/testing.mjs" - } - } - }, - "scripts": { - "build": "tsdown" - }, - "dependencies": { - "@otta-sh/domain": "workspace:*", - "better-sqlite3": "catalog:", - "kysely": "catalog:", - "pg": "catalog:" - }, - "devDependencies": { - "@types/better-sqlite3": "catalog:", - "@types/node": "catalog:", - "@types/pg": "catalog:", - "tsdown": "catalog:", - "typescript": "catalog:", - "vitest": "catalog:" - } -} diff --git a/packages/store-postgres/src/dialects-pg.ts b/packages/store-postgres/src/dialects-pg.ts deleted file mode 100644 index 1d88b6b5..00000000 --- a/packages/store-postgres/src/dialects-pg.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Kysely, PostgresDialect } from "kysely"; -import { Pool, type PoolConfig } from "pg"; -import type { Database } from "./schema.js"; - -/** - * Postgres dialect factories (§0.4) — split from the sqlite factory so a - * bundler-targeted entry (`@otta-sh/store-postgres/pg`, used by the Cloudflare - * Worker) can reach pg/Kysely without dragging in the `better-sqlite3` native - * addon, which workerd/esbuild cannot bundle. - */ - -/** Build a pg Pool. `max` must be ≥ N for the no-oversell test's N racing reserves. */ -export function makePostgresPool(config: PoolConfig): Pool { - return new Pool(config); -} - -export function makePostgresDb(pool: Pool): Kysely { - return new Kysely({ dialect: new PostgresDialect({ pool }) }); -} diff --git a/packages/store-postgres/src/dialects-sqlite.ts b/packages/store-postgres/src/dialects-sqlite.ts deleted file mode 100644 index 868bcffa..00000000 --- a/packages/store-postgres/src/dialects-sqlite.ts +++ /dev/null @@ -1,17 +0,0 @@ -import BetterSqlite3 from "better-sqlite3"; -import { Kysely, SqliteDialect } from "kysely"; -import type { Database } from "./schema.js"; - -/** - * better-sqlite3 dialect factory (§0.4) — the fast/local default. Kept in its - * own module so sqlite-free entries (`./pg`) never import the native addon. - */ -export function makeSqliteDb(path = ":memory:"): Kysely { - const database = new BetterSqlite3(path); - // Postgres enforces FKs natively; better-sqlite3 does NOT unless this pragma - // is set per connection (it uses a single connection, so this covers all). - database.pragma("foreign_keys = ON"); - return new Kysely({ - dialect: new SqliteDialect({ database }), - }); -} diff --git a/packages/store-postgres/src/dialects.ts b/packages/store-postgres/src/dialects.ts deleted file mode 100644 index cbad59ed..00000000 --- a/packages/store-postgres/src/dialects.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Dialect factories (§0.4). One Kysely store runs over both — better-sqlite3 - * (fast/local default) and pg (CI/prod) — so the same code and the same - * contract suite exercise both. - * - * Re-export shim: the implementations live in `dialects-pg.ts` / - * `dialects-sqlite.ts` so the sqlite-free `./pg` entry (Cloudflare Worker) - * never touches the better-sqlite3 native addon. This module keeps the - * original combined API for Node consumers and tests. - */ -export { makePostgresDb, makePostgresPool } from "./dialects-pg.js"; -export { makeSqliteDb } from "./dialects-sqlite.js"; diff --git a/packages/store-postgres/src/id-gen.ts b/packages/store-postgres/src/id-gen.ts deleted file mode 100644 index ab975cf3..00000000 --- a/packages/store-postgres/src/id-gen.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { IdGen } from "@otta-sh/domain"; - -/** Zero-dep collision-free id source for production adapters (risk R5). */ -export const uuidIdGen: IdGen = { - newId(): string { - return crypto.randomUUID(); - }, -}; diff --git a/packages/store-postgres/src/index.ts b/packages/store-postgres/src/index.ts deleted file mode 100644 index 0749dd71..00000000 --- a/packages/store-postgres/src/index.ts +++ /dev/null @@ -1,84 +0,0 @@ -// Barrel of @otta-sh/store-postgres — the Kysely inventory store, dialect -// factories, and the forward-only migrations (§0.4/§0.5). -export { makePostgresDb, makePostgresPool, makeSqliteDb } from "./dialects.js"; -export { uuidIdGen } from "./id-gen.js"; -export { - KyselyInventoryStore, - type KyselyInventoryStoreOptions, -} from "./kysely-inventory-store.js"; -export { - KyselyProductCommerceStore, - type KyselyProductCommerceStoreOptions, -} from "./kysely-product-commerce-store.js"; -export { KyselyCartStore, type KyselyCartStoreOptions } from "./kysely-cart-store.js"; -export { KyselyOrderStore, type KyselyOrderStoreOptions } from "./kysely-order-store.js"; -export { - KyselyOrderNotesStore, - type KyselyOrderNotesStoreOptions, -} from "./kysely-order-notes-store.js"; -export { - KyselyEntitlementStore, - type KyselyEntitlementStoreOptions, -} from "./kysely-entitlement-store.js"; -export { - KyselyPaymentEventStore, - type KyselyPaymentEventStoreOptions, -} from "./kysely-payment-event-store.js"; -export { KyselyCustomerStore, type KyselyCustomerStoreOptions } from "./kysely-customer-store.js"; -export { KyselyAddressStore, type KyselyAddressStoreOptions } from "./kysely-address-store.js"; -export { - KyselySessionStore, - DEFAULT_SESSION_TTL_MS, - hashToken, - type KyselySessionStoreOptions, -} from "./kysely-session-store.js"; -export { - KyselyCredentialVerifier, - DEFAULT_CHALLENGE_TTL_MS, - DEFAULT_MAX_ACTIVE_CHALLENGES, - type KyselyCredentialVerifierOptions, -} from "./kysely-credential-verifier.js"; -export { KyselyShippingRulesStore } from "./kysely-shipping-rules-store.js"; -export { KyselyTaxRulesStore } from "./kysely-tax-rules-store.js"; -export { KyselyCouponStore, type KyselyCouponStoreOptions } from "./kysely-coupon-store.js"; -export { - KyselyReportingStore, - type KyselyReportingStoreOptions, - type ReportingDialect, -} from "./kysely-reporting-store.js"; -export { KyselySettingsStore, type KyselySettingsStoreOptions } from "./kysely-settings-store.js"; -export { migrateToLatest, migrationProvider } from "./migrations/index.js"; -export type { - AddressesTable, - CouponRedemptionsTable, - CouponsTable, - ShippingMethodsTable, - ShippingRatesTable, - ShippingZonesTable, - TaxClassesTable, - TaxRatesTable, - CartLinesTable, - CartMutationKind, - CartMutationsTable, - CartState, - CartsTable, - CustomerSessionsTable, - CustomersTable, - Database, - EntitlementsTable, - InventoryTable, - LoginChallengesTable, - OrderEmailsOutboxTable, - OrderItemsTable, - OrderNotesTable, - OrdersTable, - OrderStateColumn, - OrderTotalsTable, - PaymentEventsTable, - PaymentsTable, - ProductCommerceTable, - ReservationsTable, - ReservationState, - SettingsTable, - SettingsMutationsTable, -} from "./schema.js"; diff --git a/packages/store-postgres/src/kysely-address-store.ts b/packages/store-postgres/src/kysely-address-store.ts deleted file mode 100644 index dc7613df..00000000 --- a/packages/store-postgres/src/kysely-address-store.ts +++ /dev/null @@ -1,137 +0,0 @@ -import type { - Address, - AddressKind, - AddressStore, - Clock, - CreateAddressInput, - CustomerId, - IdGen, - UpdateAddressInput, -} from "@otta-sh/domain"; -import type { Kysely, Selectable } from "kysely"; -import type { AddressesTable, Database } from "./schema.js"; - -export interface KyselyAddressStoreOptions { - db: Kysely; - idGen: IdGen; - clock: Clock; -} - -/** - * `AddressStore` over Kysely (§4/§7), dialect-agnostic. **Customer-scoped**: - * every read/write filters by `customer_id`, so `update`/`delete` on a foreign - * address id are a miss (null / false), never a cross-customer leak (headline - * case 3). `is_default` is portable 0/1 (better-sqlite3 can't bind a JS boolean). - */ -export class KyselyAddressStore implements AddressStore { - readonly #db: Kysely; - readonly #idGen: IdGen; - readonly #clock: Clock; - - constructor(options: KyselyAddressStoreOptions) { - this.#db = options.db; - this.#idGen = options.idGen; - this.#clock = options.clock; - } - - async list(customerId: CustomerId): Promise { - const rows = await this.#db - .selectFrom("addresses") - .selectAll() - .where("customer_id", "=", customerId) - .orderBy("created_at") - .orderBy("id") - .execute(); - return rows.map(toAddress); - } - - async create(customerId: CustomerId, input: CreateAddressInput): Promise
{ - const id = this.#idGen.newId(); - const now = this.#clock.now().toISOString(); - await this.#db - .insertInto("addresses") - .values({ - id, - customer_id: customerId, - kind: input.kind, - name: input.name, - line1: input.line1, - line2: input.line2 ?? null, - city: input.city, - region: input.region ?? null, - postal_code: input.postalCode, - country: input.country, - is_default: input.isDefault === true ? 1 : 0, - created_at: now, - }) - .execute(); - const created = await this.#getScoped(customerId, id); - if (created === null) throw new Error("address vanished immediately after create"); - return created; - } - - async update( - customerId: CustomerId, - addressId: string, - patch: UpdateAddressInput, - ): Promise
{ - const set: Partial = {}; - if (patch.kind !== undefined) set.kind = patch.kind; - if (patch.name !== undefined) set.name = patch.name; - if (patch.line1 !== undefined) set.line1 = patch.line1; - if (patch.line2 !== undefined) set.line2 = patch.line2; - if (patch.city !== undefined) set.city = patch.city; - if (patch.region !== undefined) set.region = patch.region; - if (patch.postalCode !== undefined) set.postal_code = patch.postalCode; - if (patch.country !== undefined) set.country = patch.country; - if (patch.isDefault !== undefined) set.is_default = patch.isDefault ? 1 : 0; - if (Object.keys(set).length > 0) { - const updated = await this.#db - .updateTable("addresses") - .set(set) - .where("id", "=", addressId) - .where("customer_id", "=", customerId) // scoped - .returning("id") - .executeTakeFirst(); - if (updated === undefined) return null; - } - return this.#getScoped(customerId, addressId); - } - - async delete(customerId: CustomerId, addressId: string): Promise { - const deleted = await this.#db - .deleteFrom("addresses") - .where("id", "=", addressId) - .where("customer_id", "=", customerId) // scoped - .returning("id") - .executeTakeFirst(); - return deleted !== undefined; - } - - async #getScoped(customerId: CustomerId, addressId: string): Promise
{ - const row = await this.#db - .selectFrom("addresses") - .selectAll() - .where("id", "=", addressId) - .where("customer_id", "=", customerId) - .executeTakeFirst(); - return row === undefined ? null : toAddress(row); - } -} - -function toAddress(row: Selectable): Address { - return { - id: row.id, - customerId: row.customer_id as CustomerId, - kind: row.kind as AddressKind, - name: row.name, - line1: row.line1, - line2: row.line2, - city: row.city, - region: row.region, - postalCode: row.postal_code, - country: row.country, - isDefault: row.is_default === 1, - createdAt: row.created_at, - }; -} diff --git a/packages/store-postgres/src/kysely-cart-store.ts b/packages/store-postgres/src/kysely-cart-store.ts deleted file mode 100644 index 7912003c..00000000 --- a/packages/store-postgres/src/kysely-cart-store.ts +++ /dev/null @@ -1,455 +0,0 @@ -import { - type AdjustLineInput, - type Cart, - type CartLine, - type CartStore, - type ClaimMutationInput, - type ClaimMutationResult, - type Clock, - type Currency, - type ExpiredHold, - HoldExpiredError, - type IdempotencyKey, - type IdGen, - type OrderId, - type RecordedCartMutation, - type ReservationLifecycle, - type UpsertLineInput, -} from "@otta-sh/domain"; -import { type Kysely, sql, type Transaction } from "kysely"; -import type { CartMutationsTable, Database } from "./schema.js"; - -export interface KyselyCartStoreOptions { - db: Kysely; - idGen: IdGen; - clock: Clock; -} - -/** - * `CartStore` over Kysely (§4/§6), dialect-agnostic across better-sqlite3 and pg. - * - * The `cart_mutations` ledger is claim/complete: the use-cases claim a key - * (`INSERT … ON CONFLICT DO NOTHING`, `completed=0`) BEFORE any inventory - * movement, and each mutation write here marks it `completed=1` in the same - * short transaction as the cart-line write and the reservation-deadline stamp — - * the adapter-level co-location §6 permits (the domain never assumes it). - * Cross-store atomicity with `reserve`/`adjust` is healed by resuming an - * incomplete claim + the TTL sweep. Expiry is the guarded `expireHold` flip: - * the `held → released` transition re-checks the deadline in the same - * conditional statement, and only the winner returns stock and drops lines. - * - * LOCK ORDER (deadlock freedom): every multi-table transaction here and in - * `KyselyInventoryStore` acquires row locks in the fixed order - * `reservations → inventory → cart_lines` — `upsertLine`/`adjustLine` stamp the - * reservation BEFORE touching the line, matching `expireHold` - * (flip → return → drop) and `adjust` (CAS → movement), so a shopper's - * mutation racing the sweep on one hold cannot AB-BA deadlock. - */ -export class KyselyCartStore implements CartStore { - readonly #db: Kysely; - readonly #idGen: IdGen; - readonly #clock: Clock; - - constructor(options: KyselyCartStoreOptions) { - this.#db = options.db; - this.#idGen = options.idGen; - this.#clock = options.clock; - } - - async create(currency: Currency): Promise { - const id = this.#idGen.newId(); - const now = this.#clock.now().toISOString(); - await this.#db - .insertInto("carts") - .values({ - id, - customer_id: null, - state: "active", - currency, - created_at: now, - updated_at: now, - }) - .execute(); - return id; - } - - async get(cartId: string): Promise { - const cart = await this.#db - .selectFrom("carts") - .select(["id", "state", "order_id", "currency"]) - .where("id", "=", cartId) - .executeTakeFirst(); - if (cart === undefined) return null; - - const rows = await this.#db - .selectFrom("cart_lines") - .leftJoin("reservations", "reservations.id", "cart_lines.reservation_id") - .select([ - "cart_lines.id as line_id", - "cart_lines.cart_id as cart_id", - "cart_lines.sku as sku", - "cart_lines.product_id as product_id", - "cart_lines.qty as qty", - "cart_lines.reservation_id as reservation_id", - "cart_lines.expires_at as expires_at", - "reservations.state as reservation_state", - ]) - .where("cart_lines.cart_id", "=", cartId) - .orderBy("cart_lines.id") - .execute(); - - return { - cartId: cart.id, - state: cart.state, - orderId: cart.order_id, - currency: cart.currency as Currency, - lines: rows.map((r) => this.#toLine(r)), - }; - } - - async recordedMutation(key: IdempotencyKey): Promise { - const row = await this.#db - .selectFrom("cart_mutations") - .selectAll() - .where("idempotency_key", "=", key) - .executeTakeFirst(); - return row === undefined ? null : toRecorded(row); - } - - async claimMutation(input: ClaimMutationInput): Promise { - const claimed = await this.#db - .insertInto("cart_mutations") - .values({ - idempotency_key: input.key, - cart_id: input.cartId, - line_id: input.lineId ?? null, - kind: input.kind, - resulting_qty: null, - completed: 0, - created_at: this.#clock.now().toISOString(), - }) - .onConflict((oc) => oc.column("idempotency_key").doNothing()) - .returning("idempotency_key") - .executeTakeFirst(); - if (claimed !== undefined) return { claimed: true }; - - const existing = await this.#db - .selectFrom("cart_mutations") - .selectAll() - .where("idempotency_key", "=", input.key) - .executeTakeFirstOrThrow(); - return { claimed: false, recorded: toRecorded(existing) }; - } - - async upsertLine(input: UpsertLineInput): Promise { - return this.#db.transaction().execute(async (trx) => { - const recorded = await trx - .selectFrom("cart_mutations") - .select(["line_id", "completed"]) - .where("idempotency_key", "=", input.key) - .executeTakeFirst(); - if (recorded !== undefined && recorded.completed === 1 && recorded.line_id !== null) { - return this.#lineById(trx, recorded.line_id); - } - - const now = this.#clock.now().toISOString(); - - // Reservation FIRST (lock order), and the deadline stamp doubles as the - // attach guard: scoped to `state='held'`, so a reservation the sweep - // already reaped (a crashed hold whose add is replayed late) matches 0 - // rows and the line is NOT resurrected over dead stock. A digital line - // (Phase 4 §6) carries NO reservation — nothing to stamp or guard. - if (input.reservationId !== null) { - const stamped = await trx - .updateTable("reservations") - .set({ expires_at: input.expiresAt }) - .where("id", "=", input.reservationId) - .where("state", "=", "held") - .returning("id") - .executeTakeFirst(); - if (stamped === undefined) throw new HoldExpiredError(input.reservationId); - } - - const upserted = await trx - .insertInto("cart_lines") - .values({ - id: this.#idGen.newId(), - cart_id: input.cartId, - product_id: input.productId, - sku: input.sku, - qty: input.qty, - reservation_id: input.reservationId, - expires_at: input.expiresAt, - created_at: now, - updated_at: now, - }) - .onConflict((oc) => - oc.columns(["cart_id", "sku"]).doUpdateSet({ - product_id: input.productId, - qty: input.qty, - reservation_id: input.reservationId, - expires_at: input.expiresAt, - updated_at: now, - }), - ) - .returning("id") - .executeTakeFirstOrThrow(); - - await this.#complete(trx, input.key, input.cartId, "add", upserted.id, input.qty); - return this.#lineById(trx, upserted.id); - }); - } - - async adjustLine(input: AdjustLineInput): Promise { - return this.#db.transaction().execute(async (trx) => { - const recorded = await trx - .selectFrom("cart_mutations") - .select(["line_id", "completed"]) - .where("idempotency_key", "=", input.key) - .executeTakeFirst(); - if (recorded !== undefined && recorded.completed === 1) { - return this.#lineById(trx, input.lineId); - } - - const now = this.#clock.now().toISOString(); - const line = await trx - .selectFrom("cart_lines") - .select("reservation_id") - .where("id", "=", input.lineId) - .executeTakeFirstOrThrow(); - - // Reservation FIRST (lock order: reservations → cart_lines, matching - // expireHold), then the line — whose qty mirrors the reservation's own - // qty (the inventory authority's serialized truth) when a hold exists, - // so racing different-key adjusts converge instead of last-writer desync. - if (line.reservation_id !== null) { - await trx - .updateTable("reservations") - .set({ expires_at: input.expiresAt }) - .where("id", "=", line.reservation_id) - .execute(); - } - - await trx - .updateTable("cart_lines") - .set({ - qty: - line.reservation_id === null - ? input.newQty - : (eb) => - eb - .selectFrom("reservations") - .select("reservations.qty") - .whereRef("reservations.id", "=", "cart_lines.reservation_id"), - expires_at: input.expiresAt, - updated_at: now, - }) - .where("id", "=", input.lineId) - .execute(); - - await this.#complete(trx, input.key, input.cartId, "adjust", input.lineId, input.newQty); - return this.#lineById(trx, input.lineId); - }); - } - - async removeLine(cartId: string, lineId: string, key: IdempotencyKey): Promise { - await this.#db.transaction().execute(async (trx) => { - const recorded = await trx - .selectFrom("cart_mutations") - .select("completed") - .where("idempotency_key", "=", key) - .executeTakeFirst(); - if (recorded !== undefined && recorded.completed === 1) return; // replay - - await this.#complete(trx, key, cartId, "remove", lineId, null); - await trx - .deleteFrom("cart_lines") - .where("id", "=", lineId) - .where("cart_id", "=", cartId) - .execute(); - }); - } - - async checkout(cartId: string, orderId: OrderId): Promise { - // Secondary cart-state fence (§5): guarded `active → checked_out`, which - // also stamps the order the cart handed off to (issue #132). Idempotent - // — a replay finds the cart already `checked_out` (0 rows) → false (success - // for the same order). `checked_out` is terminal (nothing re-opens it). - // - // ONE statement sets BOTH columns, so state and order id are never - // observable apart — and the UNCHANGED `state = 'active'` predicate IS the - // CAS that makes the stamp write-once: a second checkout matches 0 rows and - // writes neither column. No extra `order_id IS NULL` guard, no constraint. - const flipped = await this.#db - .updateTable("carts") - .set({ - state: "checked_out", - order_id: orderId, - updated_at: this.#clock.now().toISOString(), - }) - .where("id", "=", cartId) - .where("state", "=", "active") - .returning("id") - .executeTakeFirst(); - return flipped !== undefined; - } - - async listExpired(now: string, cutoff: string): Promise { - // Lapsed held holds: those the cart stamped (`expires_at` passed) plus a - // CART-ORIGINATED crashed hold whose line write never landed (`expires_at - // IS NULL`, reaped via `created_at` + TTL, scoped by its claim in the - // `cart_mutations` ledger). A raw Phase-0 reserve — held, unstamped, no - // ledger claim — is deliberately never listed: it awaits an explicit - // commit/release, not the cart sweep. - const rows = await this.#db - .selectFrom("reservations") - .select("id") - .where("state", "=", "held") - .where((eb) => - eb.or([ - eb.and([eb("expires_at", "is not", null), eb("expires_at", "<=", now)]), - eb.and([ - eb("expires_at", "is", null), - eb("created_at", "<=", cutoff), - eb.exists( - eb - .selectFrom("cart_mutations") - .select("cart_mutations.idempotency_key") - .whereRef("cart_mutations.idempotency_key", "=", "reservations.idempotency_key"), - ), - ]), - ]), - ) - .execute(); - return rows.map((r) => ({ reservationId: r.id })); - } - - async expireHold(reservationId: string, now: string, cutoff: string): Promise { - return this.#db.transaction().execute(async (trx) => { - // The guarded flip re-checks the deadline ATOMICALLY with the state - // transition (plan §5): a hold whose TTL was reset between listing and - // this statement no longer matches, and one that left `held` (adopted / - // released) matches nothing either. 0 rows ⇒ quietly lose (never throw): - // a lazy read racing the sweep, a TTL reset, or a checkout is normal. - const flipped = await trx - .updateTable("reservations") - .set({ state: "released" }) - .where("id", "=", reservationId) - .where("state", "=", "held") - .where((eb) => - eb.or([ - eb.and([eb("expires_at", "is not", null), eb("expires_at", "<=", now)]), - eb.and([ - eb("expires_at", "is", null), - eb("created_at", "<=", cutoff), - eb.exists( - eb - .selectFrom("cart_mutations") - .select("cart_mutations.idempotency_key") - .whereRef("cart_mutations.idempotency_key", "=", "reservations.idempotency_key"), - ), - ]), - ]), - ) - .returning(["qty", "sku"]) - .executeTakeFirst(); - if (flipped === undefined) return false; - - // Only the flip winner returns the stock and drops the line(s) — all in - // this same transaction, so the return happens exactly once. - await trx - .updateTable("inventory") - .set({ on_hand: sql`on_hand + ${flipped.qty}` }) - .where("sku", "=", flipped.sku) - .execute(); - await trx.deleteFrom("cart_lines").where("reservation_id", "=", reservationId).execute(); - return true; - }); - } - - // -- internals ------------------------------------------------------------ - - /** Mark the ledger entry completed (insert-or-update: the claim may or may - * not pre-exist, e.g. legacy callers or a peer's rolled-back claim). */ - async #complete( - trx: Transaction, - key: string, - cartId: string, - kind: "add" | "adjust" | "remove", - lineId: string | null, - resultingQty: number | null, - ): Promise { - await trx - .insertInto("cart_mutations") - .values({ - idempotency_key: key, - cart_id: cartId, - line_id: lineId, - kind, - resulting_qty: resultingQty, - completed: 1, - created_at: this.#clock.now().toISOString(), - }) - .onConflict((oc) => - oc.column("idempotency_key").doUpdateSet({ - line_id: lineId, - resulting_qty: resultingQty, - completed: 1, - }), - ) - .execute(); - } - - async #lineById(trx: Transaction, lineId: string): Promise { - const row = await trx - .selectFrom("cart_lines") - .leftJoin("reservations", "reservations.id", "cart_lines.reservation_id") - .select([ - "cart_lines.id as line_id", - "cart_lines.cart_id as cart_id", - "cart_lines.sku as sku", - "cart_lines.product_id as product_id", - "cart_lines.qty as qty", - "cart_lines.reservation_id as reservation_id", - "cart_lines.expires_at as expires_at", - "reservations.state as reservation_state", - ]) - .where("cart_lines.id", "=", lineId) - .executeTakeFirstOrThrow(); - return this.#toLine(row); - } - - #toLine(row: { - line_id: string; - cart_id: string; - sku: string; - product_id: string | null; - qty: number; - reservation_id: string | null; - expires_at: string | null; - reservation_state: string | null; - }): CartLine { - return { - lineId: row.line_id, - cartId: row.cart_id, - sku: row.sku, - productId: row.product_id, - qty: row.qty, - reservationId: row.reservation_id, - reservationState: - row.reservation_state === null ? null : (row.reservation_state as ReservationLifecycle), - expiresAt: row.expires_at, - }; - } -} - -function toRecorded(row: CartMutationsTable): RecordedCartMutation { - return { - key: row.idempotency_key as IdempotencyKey, - cartId: row.cart_id, - kind: row.kind, - lineId: row.line_id, - resultingQty: row.resulting_qty, - completed: row.completed === 1, - }; -} diff --git a/packages/store-postgres/src/kysely-coupon-store.ts b/packages/store-postgres/src/kysely-coupon-store.ts deleted file mode 100644 index af1c47b3..00000000 --- a/packages/store-postgres/src/kysely-coupon-store.ts +++ /dev/null @@ -1,406 +0,0 @@ -import { - cents, - currency as toCurrency, - customerId as toCustomerId, - idempotencyKey as toIdempotencyKey, - orderId as toOrderId, - type Clock, - type CouponListFilter, - type CouponListPage, - type CouponListResult, - type CouponRecord, - type CouponRedemption, - type CouponStore, - type CouponSummary, - type CouponType, - type CreateCouponInput, - type DeleteCouponResult, - type IdGen, - type RedeemCouponInput, - type RedeemResult, - type UpdateCouponInput, - type UpdateCouponResult, -} from "@otta-sh/domain"; -import type { Expression, ExpressionBuilder, Kysely, Selectable, SqlBool } from "kysely"; -import { expressionBuilder, sql } from "kysely"; -import type { CouponsTable, Database } from "./schema.js"; - -/** Guarded max-uses lost: the coupon is at its cap. Rolls the redeem tx back. */ -class CouponExhaustedError extends Error { - constructor() { - super("coupon exhausted"); - this.name = "CouponExhaustedError"; - } -} - -/** Per-customer cap lost: this customer already redeemed maxUsesPerCustomer. */ -class CouponPerCustomerError extends Error { - constructor() { - super("coupon per-customer cap"); - this.name = "CouponPerCustomerError"; - } -} - -export interface KyselyCouponStoreOptions { - db: Kysely; - idGen: IdGen; - /** Stamps `created_at` on `create()` — the admin-list (`listCoupons`) - * keyset ordering column (Increment 3). */ - clock: Clock; -} - -/** - * `CouponStore` over Kysely (§5), dialect-agnostic across better-sqlite3 and pg. - * - * `redeem` mirrors `InventoryStore.reserve` exactly: - * 1. Replay short-circuit — a recorded `(coupon_id, idempotency_key)` redemption - * resolves without a second decrement. - * 2. One short transaction: claim the redemption (`INSERT … ON CONFLICT - * (coupon_id, idempotency_key) DO NOTHING`), then the GUARDED single-statement - * max-uses increment (`UPDATE … WHERE uses_count < max_uses`) — coupled - * all-or-nothing. 0 rows from the guard ⇒ roll the whole tx back (no - * redemption row, no increment) and return `COUPON_EXHAUSTED`. This is the - * oversell-analogue: no over-redeem under concurrency. - * - * `release` is the mirror: delete the redemption + decrement (guarded `> 0`), - * idempotent (releasing a released/absent id is a no-op). - */ -export class KyselyCouponStore implements CouponStore { - readonly #db: Kysely; - readonly #idGen: IdGen; - readonly #clock: Clock; - - constructor(options: KyselyCouponStoreOptions) { - this.#db = options.db; - this.#idGen = options.idGen; - this.#clock = options.clock; - } - - async create(input: CreateCouponInput): Promise { - await this.#db - .insertInto("coupons") - .values({ - id: input.id, - code: input.code, - type: input.type, - amount_cents: input.amountCents, - rate_bps: input.rateBps, - cap_cents: input.capCents, - currency: input.currency, - min_subtotal_cents: input.minSubtotalCents, - starts_at: input.startsAt, - expires_at: input.expiresAt, - max_uses: input.maxUses, - max_uses_per_customer: input.maxUsesPerCustomer, - uses_count: 0, - created_at: this.#clock.now().toISOString(), - }) - .execute(); - return (await this.findById(input.id)) as CouponRecord; - } - - async findByCode(code: string): Promise { - const r = await this.#db - .selectFrom("coupons") - .selectAll() - .where("code", "=", code) - .executeTakeFirst(); - return r === undefined ? null : toRecord(r); - } - - async findById(couponId: string): Promise { - const r = await this.#db - .selectFrom("coupons") - .selectAll() - .where("id", "=", couponId) - .executeTakeFirst(); - return r === undefined ? null : toRecord(r); - } - - /** LWW edit (port doc). `code`/`type`/`currency`/`uses_count` are untouched - * (immutable identity/kind + store-owned counter). Zero rows ⇒ `not_found`. */ - async update(couponId: string, input: UpdateCouponInput): Promise { - const updated = await this.#db - .updateTable("coupons") - .set({ - amount_cents: input.amountCents, - rate_bps: input.rateBps, - cap_cents: input.capCents, - min_subtotal_cents: input.minSubtotalCents, - starts_at: input.startsAt, - expires_at: input.expiresAt, - max_uses: input.maxUses, - max_uses_per_customer: input.maxUsesPerCustomer, - }) - .where("id", "=", couponId) - .returningAll() - .executeTakeFirst(); - if (updated === undefined) return { ok: false, reason: "not_found" }; - return { ok: true, coupon: toRecord(updated) }; - } - - /** - * Forbid-if-redeemed delete (port doc): the DELETE is conditioned on NO - * `coupon_redemption` referencing the coupon, so a concurrent `redeem` can - * never orphan a redemption (the FK stays satisfied) and the reconciliation - * trail is preserved. Zero rows ⇒ classify unknown id vs still-redeemed. - */ - async delete(couponId: string): Promise { - const res = await this.#db - .deleteFrom("coupons") - .where("id", "=", couponId) - .where((eb) => - eb.not( - eb.exists( - eb - .selectFrom("coupon_redemptions") - .select("id") - .whereRef("coupon_redemptions.coupon_id", "=", "coupons.id"), - ), - ), - ) - .executeTakeFirst(); - if (Number(res.numDeletedRows) > 0) return { ok: true }; - const exists = await this.#db - .selectFrom("coupons") - .select("id") - .where("id", "=", couponId) - .executeTakeFirst(); - if (exists === undefined) return { ok: false, reason: "not_found" }; - return { ok: false, reason: "in_use_by_redemptions" }; - } - - async redeem(input: RedeemCouponInput): Promise { - // 1. Replay short-circuit (mirrors reserve's replay-by-state). - const existing = await this.#findRedemption(input.couponId, input.idempotencyKey); - if (existing !== undefined) { - return { ok: true, redemptionId: existing.id, replayed: true }; - } - - const redemptionId = this.#idGen.newId(); - try { - return await this.#db.transaction().execute(async (trx) => { - // 2a. Claim the redemption. A concurrent same-key peer makes this a - // no-op conflict (blocks until the peer commits) — re-read + replay. - const claim = await trx - .insertInto("coupon_redemptions") - .values({ - id: redemptionId, - coupon_id: input.couponId, - order_id: input.orderId, - customer_id: input.customerId ?? null, - idempotency_key: input.idempotencyKey, - created_at: input.createdAt, - }) - .onConflict((oc) => oc.columns(["coupon_id", "idempotency_key"]).doNothing()) - .returning("id") - .executeTakeFirst(); - if (claim === undefined) { - const raced = await trx - .selectFrom("coupon_redemptions") - .select("id") - .where("coupon_id", "=", input.couponId) - .where("idempotency_key", "=", input.idempotencyKey) - .executeTakeFirstOrThrow(); - return { ok: true, redemptionId: raced.id, replayed: true }; - } - - // 2b. Guarded global max-uses increment — the atomic oversell-analogue. - // This UPDATE takes a ROW LOCK on the coupon row, so EVERY concurrent - // redeem for this coupon serializes here (mirrors inventory reserve). - const bumped = await trx - .updateTable("coupons") - .set({ uses_count: sql`uses_count + 1` }) - .where("id", "=", input.couponId) - .where((eb) => - eb.or([eb("max_uses", "is", null), eb("uses_count", "<", eb.ref("max_uses"))]), - ) - .returning(["uses_count", "max_uses_per_customer"]) - .executeTakeFirst(); - if (bumped === undefined) throw new CouponExhaustedError(); - - // 2c. Per-customer cap (review I3): checked AFTER the guarded UPDATE so it - // runs under the coupon-row lock acquired above. That lock serializes - // every same-coupon redeem, making this COUNT race-free under READ - // COMMITTED — a concurrent same-customer peer cannot reach here until we - // commit, and then it sees our committed redemption. The just-inserted - // own row is counted, so `> cap` means over the limit. - if (input.customerId !== undefined && bumped.max_uses_per_customer !== null) { - const { count } = await trx - .selectFrom("coupon_redemptions") - .select((eb) => eb.fn.countAll().as("count")) - .where("coupon_id", "=", input.couponId) - .where("customer_id", "=", input.customerId) - .executeTakeFirstOrThrow(); - if (Number(count) > bumped.max_uses_per_customer) throw new CouponPerCustomerError(); - } - - return { ok: true, redemptionId, replayed: false }; - }); - } catch (err) { - if (err instanceof CouponExhaustedError) return { ok: false, reason: "COUPON_EXHAUSTED" }; - if (err instanceof CouponPerCustomerError) - return { ok: false, reason: "COUPON_MAX_PER_CUSTOMER" }; - throw err; - } - } - - async release(redemptionId: string): Promise { - await this.#db.transaction().execute(async (trx) => { - const deleted = await trx - .deleteFrom("coupon_redemptions") - .where("id", "=", redemptionId) - .returning("coupon_id") - .executeTakeFirst(); - if (deleted === undefined) return; // already released / never redeemed: no-op - await trx - .updateTable("coupons") - .set({ uses_count: sql`uses_count - 1` }) - .where("id", "=", deleted.coupon_id) - .where("uses_count", ">", 0) - .execute(); - }); - } - - async releaseByOrder(orderId: string): Promise { - return this.#db.transaction().execute(async (trx) => { - const deleted = await trx - .deleteFrom("coupon_redemptions") - .where("order_id", "=", orderId) - .returning("coupon_id") - .execute(); - for (const row of deleted) { - await trx - .updateTable("coupons") - .set({ uses_count: sql`uses_count - 1` }) - .where("id", "=", row.coupon_id) - .where("uses_count", ">", 0) - .execute(); - } - return deleted.length; - }); - } - - async listRedemptionsCreatedBefore(cutoff: string): Promise { - const rows = await this.#db - .selectFrom("coupon_redemptions") - .selectAll() - .where("created_at", "<", cutoff) - .orderBy("created_at") - .orderBy("id") - .execute(); - return rows.map((r) => ({ - id: r.id, - couponId: r.coupon_id, - orderId: toOrderId(r.order_id), - customerId: r.customer_id === null ? null : toCustomerId(r.customer_id), - idempotencyKey: toIdempotencyKey(r.idempotency_key), - createdAt: r.created_at, - })); - } - - /** - * Admin Coupons console list (view-only; admin-UX Increment 3 — the missing - * enumerate primitive, mirroring `listProducts`'s proven keyset shape 1:1). - * A single `coupons` SELECT — no join (the redeemed indicator is the - * already-stored `uses_count` column, not a correlated `EXISTS`). Ordered - * `created_at DESC, id DESC`; `search` is a case-insensitive EXACT match on - * `code` (port doc — deliberately NOT a substring, unlike `listProducts`'s - * title half). `limit + 1` next-page detection, exactly like `listProducts`. - */ - async listCoupons(filter: CouponListFilter, page: CouponListPage): Promise { - let q = this.#db.selectFrom("coupons").selectAll(); - - const conds = couponFilterConditions(filter); - if (conds.length > 0) q = q.where((eb) => eb.and(conds)); - if (page.cursor !== undefined && page.cursor !== null) { - const cursor = page.cursor; - // (created_at < :c) OR (created_at = :c AND id < :cid) — everything - // strictly "after" the cursor position under `created_at DESC, id DESC`. - q = q.where((eb) => - eb.or([ - eb("coupons.created_at", "<", cursor.createdAt), - eb.and([ - eb("coupons.created_at", "=", cursor.createdAt), - eb("coupons.id", "<", cursor.couponId), - ]), - ]), - ); - } - - const rows = await q - .orderBy("coupons.created_at", "desc") - .orderBy("coupons.id", "desc") - .limit(page.limit + 1) - .execute(); - - const hasMore = rows.length > page.limit; - const returned = hasMore ? rows.slice(0, page.limit) : rows; - const last = returned.at(-1); - const nextCursor = - hasMore && last !== undefined ? { createdAt: last.created_at, couponId: last.id } : null; - - const coupons: CouponSummary[] = returned.map((r) => ({ - ...toRecord(r), - createdAt: r.created_at, - })); - return { coupons, nextCursor }; - } - - /** Count under the SAME predicate as `listCoupons` (one builder — - * `couponFilterConditions`) over `coupons` alone: no ordering, no cursor, - * one scalar. Mirrors `KyselyOrderStore.countOrders`. */ - async countCoupons(filter: CouponListFilter): Promise { - let q = this.#db.selectFrom("coupons").select(sql`count(*)`.as("n")); - const conds = couponFilterConditions(filter); - if (conds.length > 0) q = q.where((eb) => eb.and(conds)); - const row = await q.executeTakeFirstOrThrow(); - return Number(row.n); - } - - // -- internals ------------------------------------------------------------ - - async #findRedemption(couponId: string, key: string): Promise<{ id: string } | undefined> { - return this.#db - .selectFrom("coupon_redemptions") - .select("id") - .where("coupon_id", "=", couponId) - .where("idempotency_key", "=", key) - .executeTakeFirst(); - } -} - -/** The ONE `CouponListFilter` predicate `listCoupons` builds from (mirrors - * `productFilterConditions` — a single builder so semantics can never drift). - * Returns standalone expressions (a detached `expressionBuilder`) to AND onto - * the query. `search` is a case-insensitive EXACT match on `code` (port doc). - * Known, accepted divergence (PR #74 review, matches the existing search - * precedent in `productFilterConditions`): SQLite's built-in `lower()` folds - * ASCII only, while JS `toLowerCase()` is Unicode-aware — a non-ASCII coupon - * code (e.g. "ÉTÉ10") case-folds differently on sqlite than on pg/the fake. */ -function couponFilterConditions(filter: CouponListFilter): Expression[] { - const eb: ExpressionBuilder = expressionBuilder(); - const conds: Expression[] = []; - if (filter.search !== undefined) { - conds.push(eb(sql`lower(coupons.code)`, "=", filter.search.toLowerCase())); - } - return conds; -} - -function toRecord(r: Selectable): CouponRecord { - return { - id: r.id, - code: r.code, - type: r.type as CouponType, - amountCents: r.amount_cents === null ? null : cents(r.amount_cents), - rateBps: r.rate_bps, - capCents: r.cap_cents === null ? null : cents(r.cap_cents), - currency: r.currency === null ? null : toCurrency(r.currency), - minSubtotalCents: r.min_subtotal_cents === null ? null : cents(r.min_subtotal_cents), - startsAt: r.starts_at, - expiresAt: r.expires_at, - maxUses: r.max_uses, - maxUsesPerCustomer: r.max_uses_per_customer, - usesCount: r.uses_count, - }; -} diff --git a/packages/store-postgres/src/kysely-credential-verifier.ts b/packages/store-postgres/src/kysely-credential-verifier.ts deleted file mode 100644 index eb20a292..00000000 --- a/packages/store-postgres/src/kysely-credential-verifier.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { createHash, timingSafeEqual } from "node:crypto"; -import { - DuplicateCustomerEmailError, - email as toEmail, - type Clock, - type CustomerCredentialVerifier, - type CustomerId, - type CustomerStore, - type Email, - type IdGen, - type IssueChallengeResult, - type VerifyChallengeResult, -} from "@otta-sh/domain"; -import type { Kysely } from "kysely"; -import type { Database } from "./schema.js"; - -/** Default magic-link challenge lifetime. */ -export const DEFAULT_CHALLENGE_TTL_MS = 15 * 60 * 1000; - -/** Default per-email active-challenge cap (review round H1, §9 Risk 4). */ -export const DEFAULT_MAX_ACTIVE_CHALLENGES = 3; - -export interface KyselyCredentialVerifierOptions { - db: Kysely; - /** Used to get-or-create the customer on the first successful verify. */ - customerStore: CustomerStore; - idGen: IdGen; - clock: Clock; - ttlMs?: number; - /** Per-email active-challenge cap (H1). */ - maxActiveChallenges?: number; -} - -/** - * Magic-link `CustomerCredentialVerifier` over Kysely (§4). **Mechanism-specific** - * — the only surface that changes if the auth mechanism does (§4 two-port split). - * The one-time token is stored as a hash (single-use via `consumed_at`, guarded - * so a URL replay is `CONSUMED`), TTL-expired tokens are `EXPIRED`, and the - * customer is get-or-created on the first successful verify. - * - * Rate limiting (review round H1, §9 Risk 4): `issueChallenge` is a DB-backed - * per-email window — when N unconsumed, unexpired challenges already exist for - * the address it no-ops (`THROTTLED`), bounding both the email-bombing rate and - * `login_challenges` growth per address. Per-IP limiting is gateway-layer scope - * (ADR-0004), not this adapter's. `pruneChallenges` deletes consumed/expired - * rows so the table cannot grow unboundedly (driven by the same internal - * maintenance tick as the outbox dispatcher). - */ -export class KyselyCredentialVerifier implements CustomerCredentialVerifier { - readonly #db: Kysely; - readonly #customerStore: CustomerStore; - readonly #idGen: IdGen; - readonly #clock: Clock; - readonly #ttlMs: number; - readonly #maxActive: number; - - constructor(options: KyselyCredentialVerifierOptions) { - this.#db = options.db; - this.#customerStore = options.customerStore; - this.#idGen = options.idGen; - this.#clock = options.clock; - this.#ttlMs = options.ttlMs ?? DEFAULT_CHALLENGE_TTL_MS; - this.#maxActive = options.maxActiveChallenges ?? DEFAULT_MAX_ACTIVE_CHALLENGES; - } - - async issueChallenge(email: Email): Promise { - const now = this.#clock.now(); - const nowIso = now.toISOString(); - - // Per-email window (H1): count active (unconsumed, unexpired) challenges. - const active = await this.#db - .selectFrom("login_challenges") - .select(({ fn }) => fn.countAll().as("n")) - .where("email", "=", email) - .where("consumed_at", "is", null) - .where("expires_at", ">", nowIso) - .executeTakeFirstOrThrow(); - if (Number(active.n) >= this.#maxActive) return { ok: false, reason: "THROTTLED" }; - - const challengeId = this.#idGen.newId(); - const token = this.#idGen.newId(); - await this.#db - .insertInto("login_challenges") - .values({ - id: challengeId, - email, - token_hash: hashToken(token), - created_at: nowIso, - expires_at: new Date(now.getTime() + this.#ttlMs).toISOString(), - consumed_at: null, - }) - .execute(); - return { ok: true, challengeId, token }; - } - - async pruneChallenges(now: string): Promise { - const res = await this.#db - .deleteFrom("login_challenges") - .where((eb) => eb.or([eb("consumed_at", "is not", null), eb("expires_at", "<=", now)])) - .executeTakeFirst(); - return Number(res.numDeletedRows); - } - - async verifyChallenge(challengeId: string, token: string): Promise { - const row = await this.#db - .selectFrom("login_challenges") - .selectAll() - .where("id", "=", challengeId) - .executeTakeFirst(); - if (row === undefined || !tokenMatches(token, row.token_hash)) { - return { ok: false, reason: "INVALID" }; - } - if (row.consumed_at !== null) return { ok: false, reason: "CONSUMED" }; - if (row.expires_at <= this.#clock.now().toISOString()) return { ok: false, reason: "EXPIRED" }; - - // Guarded single-use consume — a concurrent verify that already consumed it - // leaves 0 rows here ⇒ CONSUMED, never a double login. - const consumed = await this.#db - .updateTable("login_challenges") - .set({ consumed_at: this.#clock.now().toISOString() }) - .where("id", "=", challengeId) - .where("consumed_at", "is", null) - .returning("id") - .executeTakeFirst(); - if (consumed === undefined) return { ok: false, reason: "CONSUMED" }; - - const customerId = await this.#resolveCustomer(toEmail(row.email)); - return { ok: true, customerId }; - } - - async #resolveCustomer(email: Email): Promise { - const existing = await this.#customerStore.getByEmail(email); - if (existing !== null) return existing.id; - try { - const created = await this.#customerStore.create({ email }); - return created.id; - } catch (err) { - if (err instanceof DuplicateCustomerEmailError) { - const raced = await this.#customerStore.getByEmail(email); - if (raced !== null) return raced.id; - } - throw err; - } - } -} - -function hashToken(token: string): string { - return createHash("sha256").update(token).digest("hex"); -} - -/** Constant-time compare of the token against the stored hash. */ -function tokenMatches(token: string, storedHash: string): boolean { - const provided = Buffer.from(hashToken(token), "hex"); - const expected = Buffer.from(storedHash, "hex"); - return provided.length === expected.length && timingSafeEqual(provided, expected); -} diff --git a/packages/store-postgres/src/kysely-customer-store.ts b/packages/store-postgres/src/kysely-customer-store.ts deleted file mode 100644 index 641bf69a..00000000 --- a/packages/store-postgres/src/kysely-customer-store.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { - customerId as toCustomerId, - DuplicateCustomerEmailError, - type Clock, - type CreateCustomerInput, - type Customer, - type CustomerId, - type CustomerStore, - type Email, - type IdGen, - type UpdateCustomerInput, -} from "@otta-sh/domain"; -import type { Kysely, Selectable } from "kysely"; -import type { CustomersTable, Database } from "./schema.js"; - -export interface KyselyCustomerStoreOptions { - db: Kysely; - idGen: IdGen; - clock: Clock; -} - -/** - * `CustomerStore` over Kysely (§4/§7), dialect-agnostic across better-sqlite3 - * and pg. `email` is UNIQUE + lower-normalized (the domain `Email` brand), so - * `create` on a duplicate throws `DuplicateCustomerEmailError` (via - * `ON CONFLICT DO NOTHING` returning 0 rows) and `getByEmail` is - * case-insensitive. Zero dependency on EmDash `ctx.users` (headline case 2). - */ -export class KyselyCustomerStore implements CustomerStore { - readonly #db: Kysely; - readonly #idGen: IdGen; - readonly #clock: Clock; - - constructor(options: KyselyCustomerStoreOptions) { - this.#db = options.db; - this.#idGen = options.idGen; - this.#clock = options.clock; - } - - async create(input: CreateCustomerInput): Promise { - const id = this.#idGen.newId(); - const now = this.#clock.now().toISOString(); - const inserted = await this.#db - .insertInto("customers") - .values({ - id, - email: input.email, - display_name: input.displayName ?? null, - email_verified_at: null, - created_at: now, - }) - .onConflict((oc) => oc.column("email").doNothing()) - .returning("id") - .executeTakeFirst(); - if (inserted === undefined) throw new DuplicateCustomerEmailError(input.email); - const created = await this.get(toCustomerId(id)); - if (created === null) throw new Error("customer vanished immediately after create"); - return created; - } - - async get(id: CustomerId): Promise { - const row = await this.#db - .selectFrom("customers") - .selectAll() - .where("id", "=", id) - .executeTakeFirst(); - return row === undefined ? null : toCustomer(row); - } - - async getByEmail(email: Email): Promise { - const row = await this.#db - .selectFrom("customers") - .selectAll() - .where("email", "=", email) - .executeTakeFirst(); - return row === undefined ? null : toCustomer(row); - } - - async update(id: CustomerId, patch: UpdateCustomerInput): Promise { - const set: Partial = {}; - if (patch.displayName !== undefined) set.display_name = patch.displayName; - if (patch.emailVerifiedAt !== undefined) set.email_verified_at = patch.emailVerifiedAt; - if (Object.keys(set).length > 0) { - const updated = await this.#db - .updateTable("customers") - .set(set) - .where("id", "=", id) - .returning("id") - .executeTakeFirst(); - if (updated === undefined) return null; - } - return this.get(id); - } -} - -function toCustomer(row: Selectable): Customer { - return { - id: toCustomerId(row.id), - email: row.email as Email, - displayName: row.display_name, - emailVerifiedAt: row.email_verified_at, - createdAt: row.created_at, - }; -} diff --git a/packages/store-postgres/src/kysely-entitlement-store.ts b/packages/store-postgres/src/kysely-entitlement-store.ts deleted file mode 100644 index d22ea3be..00000000 --- a/packages/store-postgres/src/kysely-entitlement-store.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { - orderId as toOrderId, - productId as toProductId, - sku as toSku, - type Clock, - type Entitlement, - type EntitlementQuery, - type EntitlementSource, - type EntitlementState, - type EntitlementStore, - type GrantEntitlementInput, - type IdGen, -} from "@otta-sh/domain"; -import { type Kysely, sql } from "kysely"; -import type { Database, EntitlementsTable } from "./schema.js"; - -export interface KyselyEntitlementStoreOptions { - db: Kysely; - idGen: IdGen; - clock: Clock; -} - -/** - * `EntitlementStore` over Kysely (§6), dialect-agnostic. Grant is - * `INSERT … ON CONFLICT (grant_idempotency_key) DO NOTHING` — grant-once under - * webhook/proof replay; check authorizes delivery (no active row ⇒ not served). - */ -export class KyselyEntitlementStore implements EntitlementStore { - readonly #db: Kysely; - readonly #idGen: IdGen; - readonly #clock: Clock; - - constructor(options: KyselyEntitlementStoreOptions) { - this.#db = options.db; - this.#idGen = options.idGen; - this.#clock = options.clock; - } - - async grant(input: GrantEntitlementInput): Promise { - const now = this.#clock.now().toISOString(); - await this.#db - .insertInto("entitlements") - .values({ - id: this.#idGen.newId(), - order_id: input.orderId, - product_id: input.productId, - sku: input.sku, - buyer_ref: input.buyerRef, - state: "active", - source: input.source, - granted_at: now, - grant_idempotency_key: input.grantIdempotencyKey, - }) - .onConflict((oc) => oc.column("grant_idempotency_key").doNothing()) - .execute(); - - const row = await this.#db - .selectFrom("entitlements") - .selectAll() - .where("grant_idempotency_key", "=", input.grantIdempotencyKey) - .executeTakeFirstOrThrow(); - return toDomain(row); - } - - async check(query: EntitlementQuery): Promise { - let q = this.#db - .selectFrom("entitlements") - .select("id") - .where("state", "=", "active") - .where("sku", "=", query.sku); - if (query.orderId !== undefined) q = q.where("order_id", "=", query.orderId); - // buyer_ref carries email semantics: fold case (lower(buyer_ref) = lower(?)) - // so the session scope's lower-normalized Email matches a mixed-case - // checkout ref — identical to KyselyOrderStore.linkGuestOrders. `lower()` is - // standard SQL, identical on sqlite and pg. - if (query.buyerRef !== undefined) { - q = q.where(sql`lower(buyer_ref)`, "=", query.buyerRef.toLowerCase()); - } - // A query with neither scope matches nothing (delivery must be scoped). - if (query.orderId === undefined && query.buyerRef === undefined) return false; - const row = await q.executeTakeFirst(); - return row !== undefined; - } -} - -function toDomain(row: EntitlementsTable): Entitlement { - return { - id: row.id, - orderId: toOrderId(row.order_id), - productId: row.product_id === null ? null : toProductId(row.product_id), - sku: toSku(row.sku), - buyerRef: row.buyer_ref, - state: row.state as EntitlementState, - source: row.source as EntitlementSource, - grantedAt: row.granted_at, - }; -} diff --git a/packages/store-postgres/src/kysely-inventory-store.ts b/packages/store-postgres/src/kysely-inventory-store.ts deleted file mode 100644 index 5f199d87..00000000 --- a/packages/store-postgres/src/kysely-inventory-store.ts +++ /dev/null @@ -1,796 +0,0 @@ -import { - type AdoptInput, - type AdoptManyInput, - type AdoptManyResult, - type AdoptResult, - AdjustReservationMismatchError, - type Clock, - type CommitManyResult, - type IdGen, - type IdempotencyKey, - type InventoryStore, - ReservationCommitLostError, - ReservationNotFoundError, - ReservationNotHeldError, - type ReserveResult, - type RestockResult, - StockMovementMismatchError, - type StockRemovalResult, -} from "@otta-sh/domain"; -import { type Kysely, sql } from "kysely"; -import type { Database, ReservationState } from "./schema.js"; - -/** Losing the guarded finalize flip: another caller owns this reservation. */ -const LOST = Symbol("finalize-lost"); -type FinalizeOutcome = ReserveResult | typeof LOST; - -/** Internal: aborts the adjust tx (rolling back its claim) when the guarded CAS - * matches 0 rows — a different-key adjust or a checkout raced the hold. */ -class LostCasError extends Error { - constructor() { - super("adjust lost the guarded reservation CAS"); - } -} - -/** Internal: aborts a restock/removeStock tx (rolling back its just-inserted - * claim) when the target sku has no inventory row — an UNKNOWN_SKU is outside - * the idempotency scope, exactly like `reserve`'s FK-abort on an unknown sku, - * so the key must NOT be consumed. */ -class UnknownSkuError extends Error { - constructor() { - super("stock movement targets a sku with no inventory row"); - } -} - -export interface KyselyInventoryStoreOptions { - db: Kysely; - idGen: IdGen; - clock: Clock; - /** Bounded re-read loop while awaiting a peer's finalize (replay `pending` branch). */ - await?: { maxAttempts?: number; delayMs?: number }; -} - -/** - * `InventoryStore` over Kysely (§0.4/§0.5), dialect-agnostic across - * better-sqlite3 and pg. - * - * `reserve` is the finalize choreography from §0.5: - * 1. idempotency claim — `INSERT … ON CONFLICT (idempotency_key) DO NOTHING - * RETURNING id` (single statement, autocommit). - * 2. finalize — a SHORT transaction on one connection coupling the - * state-guarded `pending → held` flip with the conditional decrement so - * `held ⟺ a durable decrement`. On a 0-row decrement the same tx sets - * `failed` and returns OUT_OF_STOCK; on losing the guarded flip it falls - * to a bounded re-read of `state` until terminal. - * A replay (no row from the claim) resolves the reservation's stored `state` - * (held ⇒ ok, failed ⇒ OUT_OF_STOCK, pending ⇒ finalize-or-await) — never a - * blind ok. - */ -export class KyselyInventoryStore implements InventoryStore { - /** Test hook: invoked inside the finalize tx after the `held` flip, before - * the decrement — used to inject the W2 fault or force an ordering. */ - readonly hooks: { beforeDecrement?: (reservationId: string) => Promise | void } = {}; - - readonly #db: Kysely; - readonly #idGen: IdGen; - readonly #clock: Clock; - readonly #maxAttempts: number; - readonly #delayMs: number; - - constructor(options: KyselyInventoryStoreOptions) { - this.#db = options.db; - this.#idGen = options.idGen; - this.#clock = options.clock; - this.#maxAttempts = options.await?.maxAttempts ?? 200; - this.#delayMs = options.await?.delayMs ?? 5; - } - - async reserve(sku: string, qty: number, key: IdempotencyKey): Promise { - if (!Number.isSafeInteger(qty) || qty <= 0) { - throw new RangeError(`reserve() requires a positive integer qty, got ${String(qty)}`); - } - - // 1. Idempotency claim. The `reservations.sku → inventory.sku` FK means an - // unknown/unseeded sku raises an FK violation here (no inventory row to - // reference), which maps to OUT_OF_STOCK below. - let claim: { id: string } | undefined; - try { - claim = await this.#db - .insertInto("reservations") - .values({ - id: this.#idGen.newId(), - sku, - qty, - state: "pending", - idempotency_key: key, - created_at: this.#clock.now().toISOString(), - }) - .onConflict((oc) => oc.column("idempotency_key").doNothing()) - .returning("id") - .executeTakeFirst(); - } catch (err) { - // Unknown-sku FK abort: no reservation row is written, so the key is NOT - // consumed — a pre-claim rejection OUTSIDE R2's idempotency scope ("no - // product row ⇒ no idempotency scope"). A later replay of the same key - // against a now-seeded sku is therefore a fresh reserve. This is distinct - // from a genuine OUT_OF_STOCK on a known sku, which stays `failed` and - // keeps the key consumed (R2). - if (isForeignKeyViolation(err)) return { ok: false, reason: "OUT_OF_STOCK" }; - throw err; - } - - if (claim !== undefined) { - // Freshly claimed by this caller — finalize as the claimant. - return this.#finalizeOrAwait(claim.id, sku, qty); - } - - // 2. Key already claimed — resolve from the stored state (replay-by-state). - const existing = await this.#selectByKey(key); - return this.#resolveState(existing.id, existing.state, existing.sku, existing.qty); - } - - async commit(reservationId: string): Promise { - // GUARD-FIRST (defense-in-depth, not SELECT-then-UPDATE): the conditional - // flip itself is the authority — a hold that leaves `held|adopted` between - // any read and this statement can never be silently "committed". Widened - // (Phase 4 §5) to accept `adopted` alongside Phase-0's `held`. - const flipped = await this.#db - .updateTable("reservations") - .set({ state: "committed" }) - .where("id", "=", reservationId) - .where("state", "in", ["held", "adopted"]) - .returning("id") - .executeTakeFirst(); - if (flipped !== undefined) return; - - // 0 rows: re-read to distinguish the benign idempotent replay (already - // `committed`) from a LOST hold (released/failed/unknown) — the latter is - // the loud anomaly (§5), never a silent no-op. - const row = await this.#selectById(reservationId); - if (row.state === "committed") return; // double-commit / idempotent replay: no-op - throw new ReservationCommitLostError(reservationId, row.state); - } - - async release(reservationId: string): Promise { - const row = await this.#selectById(reservationId); - if (row.state === "released") return; // double-release: no-op - // Widened (Phase 4 §5) to accept `adopted` alongside Phase-0's `held`. - if (row.state !== "held" && row.state !== "adopted") { - throw new Error(`cannot release reservation ${reservationId} in state ${row.state}`); - } - // Flip `held|adopted → released` and return the stock all-or-nothing; the - // state guard makes exactly one caller increment (no double return). - await this.#db.transaction().execute(async (trx) => { - const flipped = await trx - .updateTable("reservations") - .set({ state: "released" }) - .where("id", "=", reservationId) - .where("state", "in", ["held", "adopted"]) - .returning("id") - .executeTakeFirst(); - if (flipped === undefined) return; // lost the race: peer already released - await trx - .updateTable("inventory") - .set({ on_hand: sql`on_hand + ${row.qty}` }) - .where("sku", "=", row.sku) - .execute(); - }); - } - - /** - * Order-scoped release (review G2): the guarded `adopted → released` flip - * additionally scoped `WHERE order_id = :orderId`, so an order can only ever - * release a hold IT adopted. 0 rows is ALWAYS a silent no-op — already - * released/committed (benign replay), adopted by another order, or still - * cart-`held` (not this order's to touch). Never throws on state: an unscoped - * release here is how a stale order could free a live checkout's hold, or - * crash the expiry sweep forever on a committed one. - */ - async releaseAdopted(reservationId: string, orderId: string): Promise { - const row = await this.#db - .selectFrom("reservations") - .select(["sku", "qty"]) - .where("id", "=", reservationId) - .executeTakeFirst(); - if (row === undefined) return; // unknown id: nothing to release - await this.#db.transaction().execute(async (trx) => { - const flipped = await trx - .updateTable("reservations") - .set({ state: "released" }) - .where("id", "=", reservationId) - .where("state", "=", "adopted") - .where("order_id", "=", orderId) - .returning("id") - .executeTakeFirst(); - if (flipped === undefined) return; // not this order's adopted hold: no-op - await trx - .updateTable("inventory") - .set({ on_hand: sql`on_hand + ${row.qty}` }) - .where("sku", "=", row.sku) - .execute(); - }); - } - - /** - * The guarded `held → adopted` flip (Phase 4 §5): a single conditional - * statement, no interactive transaction. Scoped `state='held' AND expires_at > - * :now`, so it can never adopt a hold the Phase-3 sweep is about to reap. Sets - * `order_id` and re-points `expires_at` to the order's hold deadline, taking the - * hold out of the `held`-scoped sweep's reach. 0 rows ⇒ re-read: an already- - * `adopted` row for THIS order is an idempotent replay (ok); anything else is - * `RESERVATION_LOST`. - */ - async adopt(input: AdoptInput): Promise { - const flipped = await this.#db - .updateTable("reservations") - .set({ state: "adopted", order_id: input.orderId, expires_at: input.holdExpiresAt }) - .where("id", "=", input.reservationId) - .where("state", "=", "held") - .where("expires_at", ">", input.now) - .returning("id") - .executeTakeFirst(); - if (flipped !== undefined) return { ok: true }; - - const row = await this.#db - .selectFrom("reservations") - .select(["state", "order_id"]) - .where("id", "=", input.reservationId) - .executeTakeFirst(); - if (row?.state === "adopted" && row.order_id === input.orderId) return { ok: true }; - return { ok: false, reason: "RESERVATION_LOST" }; - } - - /** - * Batched `adopt` (PR B): one order's physical `held → adopted` flips folded - * into a SINGLE guarded `UPDATE … WHERE id IN (:ids) AND state='held' AND - * expires_at > :now`, then ONE classification `SELECT` over the misses. Per-id - * semantics are `adopt`'s, byte-for-byte: - * - a flipped row ⇒ `adopted`; - * - a 0-row id that is already `adopted` for THIS order ⇒ idempotent replay, - * folded into `adopted` — NO `expires_at` predicate on the classification - * (singular `adopt`'s replay branch ignores it, so an adopted-for-this-order - * hold past its deadline is still success, never lost); - * - every other missing id — wrong state, another order, or UNKNOWN (no row) — - * is `RESERVATION_LOST` (in `lost`), matching singular `adopt` (which returns - * `RESERVATION_LOST`, not a throw, for an unknown id). - * Empty ids short-circuit (no DB round trip — `IN ()` is invalid SQL). - */ - async adoptMany(input: AdoptManyInput): Promise { - const { reservationIds, orderId, holdExpiresAt, now } = input; - if (reservationIds.length === 0) return { adopted: [], lost: [] }; - - const flipped = await this.#db - .updateTable("reservations") - .set({ state: "adopted", order_id: orderId, expires_at: holdExpiresAt }) - .where("id", "in", reservationIds) - .where("state", "=", "held") - .where("expires_at", ">", now) - .returning("id") - .execute(); - const adopted = flipped.map((r) => r.id); - - const adoptedSet = new Set(adopted); - const missing = reservationIds.filter((id) => !adoptedSet.has(id)); - if (missing.length === 0) return { adopted, lost: [] }; - - const rows = await this.#db - .selectFrom("reservations") - .select(["id", "state", "order_id"]) - .where("id", "in", missing) - .execute(); - const rowById = new Map(rows.map((r) => [r.id, r])); - const lost: string[] = []; - for (const id of missing) { - const row = rowById.get(id); - // Idempotent replay: adopted-for-THIS-order ⇒ success (no expires_at check, - // matching singular adopt). Everything else (incl. unknown) ⇒ lost. - if (row !== undefined && row.state === "adopted" && row.order_id === orderId) { - adopted.push(id); - } else { - lost.push(id); - } - } - return { adopted, lost }; - } - - /** - * Batched `commit` (PR B): a paid order's physical `held|adopted → committed` - * flips folded into a SINGLE guarded `UPDATE … WHERE id IN (:ids) AND state IN - * ('held','adopted')`, then ONE classification `SELECT` over the misses. - * Deliberately order-UNSCOPED, exactly like singular `commit`. Per-id semantics - * are `commit`'s, byte-for-byte: - * - a flipped row ⇒ benign success (absent from `lost`); - * - a 0-row id that is already `committed` ⇒ benign idempotent replay (dropped); - * - a 0-row id in any OTHER existing state (released/failed/…) ⇒ LOST; - * - a 0-row id with NO row (unknown) ⇒ THROW `ReservationNotFoundError`, - * matching singular `commit`'s `#selectById` — never folded into `lost`. - * Empty ids short-circuit (no DB round trip). - */ - async commitMany(reservationIds: string[]): Promise { - if (reservationIds.length === 0) return { lost: [] }; - - const flipped = await this.#db - .updateTable("reservations") - .set({ state: "committed" }) - .where("id", "in", reservationIds) - .where("state", "in", ["held", "adopted"]) - .returning("id") - .execute(); - const committedSet = new Set(flipped.map((r) => r.id)); - const missing = reservationIds.filter((id) => !committedSet.has(id)); - if (missing.length === 0) return { lost: [] }; - - const rows = await this.#db - .selectFrom("reservations") - .select(["id", "state"]) - .where("id", "in", missing) - .execute(); - const stateById = new Map(rows.map((r) => [r.id, r.state])); - const lost: string[] = []; - for (const id of missing) { - const state = stateById.get(id); - if (state === undefined) { - // Unknown id: match singular commit's #selectById, which throws the - // typed ReservationNotFoundError — a truly-unknown id PROPAGATES, - // never folded into lost. - throw new ReservationNotFoundError(id); - } - if (state === "committed") continue; // benign idempotent replay - lost.push(id); - } - return { lost }; - } - - /** - * Additive (Phase 1 §8 Risk 4): create-if-absent initial stock write, a - * single portable statement — `INSERT … ON CONFLICT (sku) DO NOTHING` — - * NOT part of the reserve/commit/release finalize choreography. It can - * never clobber a concurrent reserve/release/adjust because a conflict - * leaves the existing row untouched. - */ - async seedOnHand(sku: string, qty: number): Promise { - if (!Number.isSafeInteger(qty) || qty < 0) { - throw new RangeError(`seedOnHand() requires a non-negative integer, got ${String(qty)}`); - } - await this.#db - .insertInto("inventory") - .values({ sku, on_hand: qty }) - .onConflict((oc) => oc.column("sku").doNothing()) - .execute(); - } - - /** Additive (admin-UX Increment 2, product detail): a bare `SELECT on_hand - * FROM inventory WHERE sku = :sku` — a single-row read, no join, no - * idempotency key. A missing row reads as `0` (mirrors `listCommerceByIds`'s - * LEFT JOIN "no row ⇒ out of stock" semantics). */ - async getOnHand(sku: string): Promise { - const row = await this.#db - .selectFrom("inventory") - .select("on_hand") - .where("sku", "=", sku) - .executeTakeFirst(); - return row?.on_hand ?? 0; - } - - /** Additive (INC-23, admin product detail): the SAME single-row primary-key - * lookup as `getOnHand`, differing ONLY in what a miss means — `null` ("no - * inventory row", unknown) instead of `0` ("known sku, out of stock"), which - * is the same distinction `listProducts`'s LEFT JOIN makes for the list. */ - async findOnHand(sku: string): Promise { - const row = await this.#db - .selectFrom("inventory") - .select("on_hand") - .where("sku", "=", sku) - .executeTakeFirst(); - return row?.on_hand ?? null; - } - - /** - * Merchant restock (admin-UX Increment 2): ADD `qty` to an existing sku's - * on-hand. A single UNCONDITIONAL `on_hand + qty` — commutative with every - * concurrent guarded decrement (`reserve`/`removeStock`), so it can never - * cause an oversell (no `WHERE on_hand >= …` needed). Ledger-first exactly- - * once via `#applyStockMovement`; an unknown sku is UNKNOWN_SKU (key not - * consumed — never auto-creates the row). - */ - async restock(sku: string, qty: number, key: IdempotencyKey): Promise { - if (!Number.isSafeInteger(qty) || qty <= 0) { - throw new RangeError(`restock() requires a positive integer qty, got ${String(qty)}`); - } - const res = await this.#applyStockMovement(sku, qty, key, "restock"); - // A restock never yields INSUFFICIENT_STOCK (unconditional increment); the - // only failure the movement can record is UNKNOWN_SKU. - if (res.ok) return { ok: true, onHand: res.onHand }; - return { ok: false, reason: "UNKNOWN_SKU" }; - } - - /** - * Merchant stock removal (admin-UX Increment 2): REMOVE `qty` from an existing - * sku's on-hand. The oversell-critical DECREMENT — a single GUARDED `on_hand - - * qty WHERE on_hand >= qty`, the SAME guard shape as `reserve`'s decrement, so - * it can never drive on-hand below 0 or race a reservation into oversell. - * Ledger-first exactly-once; INSUFFICIENT_STOCK on a known sku consumes the - * key (R2), UNKNOWN_SKU does not. - */ - async removeStock(sku: string, qty: number, key: IdempotencyKey): Promise { - if (!Number.isSafeInteger(qty) || qty <= 0) { - throw new RangeError(`removeStock() requires a positive integer qty, got ${String(qty)}`); - } - return this.#applyStockMovement(sku, qty, key, "removal"); - } - - /** - * The shared exactly-once choreography for both admin stock movements (mirrors - * `adjust`'s claim discipline): - * 1. ledger-first — a recorded `inventory_stock_movements` row for `key` - * short-circuits to its outcome (a replay moves NOTHING; a key reused for - * a different (sku, direction, qty) is a typed `StockMovementMismatchError`). - * 2. one short tx: claim the key (`INSERT … ON CONFLICT DO NOTHING`), then - * the movement — an unconditional `+qty` (restock) or a guarded `-qty - * WHERE on_hand >= qty` (removal). Claim + movement commit all-or-nothing, - * so only the claim winner moves stock. - * 3. an unknown sku (no inventory row) throws `UnknownSkuError` INSIDE the tx - * → the claim rolls back → UNKNOWN_SKU (key not consumed, mirroring - * `reserve`). A lost claim (a concurrent same-key peer holds it) re-reads - * the ledger until the peer's outcome lands. - */ - async #applyStockMovement( - sku: string, - qty: number, - key: IdempotencyKey, - direction: "restock" | "removal", - ): Promise { - for (let attempt = 0; attempt < this.#maxAttempts; attempt++) { - const replay = await this.#replayStockMovement(key, sku, direction, qty); - if (replay !== undefined) return replay; - - const outcome = await this.#db - .transaction() - .execute(async (trx) => { - const claim = await trx - .insertInto("inventory_stock_movements") - .values({ - idempotency_key: key, - sku, - direction, - qty, - outcome: "ok", - result_on_hand: 0, - created_at: this.#clock.now().toISOString(), - }) - .onConflict((oc) => oc.column("idempotency_key").doNothing()) - .returning("idempotency_key") - .executeTakeFirst(); - if (claim === undefined) return LOST; // peer holds the key: re-read. - - if (direction === "restock") { - // Unconditional additive increment (oversell-safe: only raises). - const updated = await trx - .updateTable("inventory") - .set({ on_hand: sql`on_hand + ${qty}` }) - .where("sku", "=", sku) - .returning("on_hand") - .executeTakeFirst(); - if (updated === undefined) throw new UnknownSkuError(); // rollback claim - await trx - .updateTable("inventory_stock_movements") - .set({ result_on_hand: updated.on_hand }) - .where("idempotency_key", "=", key) - .execute(); - return { ok: true, onHand: updated.on_hand }; - } - - // removal: oversell-critical single GUARDED decrement. - const decremented = await trx - .updateTable("inventory") - .set({ on_hand: sql`on_hand - ${qty}` }) - .where("sku", "=", sku) - .where("on_hand", ">=", qty) - .returning("on_hand") - .executeTakeFirst(); - if (decremented !== undefined) { - await trx - .updateTable("inventory_stock_movements") - .set({ result_on_hand: decremented.on_hand }) - .where("idempotency_key", "=", key) - .execute(); - return { ok: true, onHand: decremented.on_hand }; - } - - // 0 rows: unknown sku (no row) OR genuinely insufficient (guard failed). - const row = await trx - .selectFrom("inventory") - .select("on_hand") - .where("sku", "=", sku) - .executeTakeFirst(); - if (row === undefined) throw new UnknownSkuError(); // rollback claim - // Genuine INSUFFICIENT_STOCK on a known sku: record it, key CONSUMED. - await trx - .updateTable("inventory_stock_movements") - .set({ outcome: "insufficient_stock", result_on_hand: row.on_hand }) - .where("idempotency_key", "=", key) - .execute(); - return { ok: false, reason: "INSUFFICIENT_STOCK", onHand: row.on_hand }; - }) - .catch((err: unknown): StockRemovalResult | typeof LOST => { - if (err instanceof UnknownSkuError) return { ok: false, reason: "UNKNOWN_SKU" }; - throw err; - }); - - if (outcome !== LOST) return outcome; - await delay(this.#delayMs); - } - throw new Error(`stock movement (${direction}) of sku ${sku} did not settle in time`); - } - - /** Ledger-first replay resolver for a stock movement: returns the recorded - * result for a replayed key (throwing on a mis-keyed reuse), or undefined if - * unseen. */ - async #replayStockMovement( - key: string, - sku: string, - direction: "restock" | "removal", - qty: number, - ): Promise { - const recorded = await this.#db - .selectFrom("inventory_stock_movements") - .select(["sku", "direction", "qty", "outcome", "result_on_hand"]) - .where("idempotency_key", "=", key) - .executeTakeFirst(); - if (recorded === undefined) return undefined; - if (recorded.sku !== sku || recorded.direction !== direction || recorded.qty !== qty) { - throw new StockMovementMismatchError( - key, - `${recorded.direction} ${recorded.qty}×${recorded.sku}`, - `${direction} ${qty}×${sku}`, - ); - } - if (recorded.outcome === "insufficient_stock") { - return { ok: false, reason: "INSUFFICIENT_STOCK", onHand: recorded.result_on_hand }; - } - return { ok: true, onHand: recorded.result_on_hand }; - } - - async adjust(reservationId: string, newQty: number, key: IdempotencyKey): Promise { - if (!Number.isSafeInteger(newQty) || newQty <= 0) { - throw new RangeError(`adjust() requires a positive integer newQty, got ${String(newQty)}`); - } - - // Exactly-once choreography (mirrors reserve's claim discipline): - // 1. ledger-first — a recorded `inventory_adjustments` row for `key` - // short-circuits to its outcome: a replay (even a stale one after - // later same-reservation adjusts) moves NOTHING. - // 2. one short tx: claim the key (`INSERT … ON CONFLICT DO NOTHING`), - // then a guarded CAS on the reservation (`WHERE state='held' AND - // qty=:prev`) BEFORE the movement, then the conditional movement. - // Claim + CAS + movement commit all-or-nothing, so only the claim - // winner moves stock and `held` qty ⟺ the durable movement. - // 3. a lost CAS (a different-key adjust or a checkout raced the row) - // rolls the tx back — claim included — and retries with fresh reads; - // a hold no longer `held` throws `ReservationNotHeldError` (typed, - // guard-first: no movement ever lands against a non-held hold). - for (let attempt = 0; attempt < this.#maxAttempts; attempt++) { - const recorded = await this.#db - .selectFrom("inventory_adjustments") - .select(["outcome", "reservation_id"]) - .where("idempotency_key", "=", key) - .executeTakeFirst(); - if (recorded !== undefined) { - // A key recorded against a DIFFERENT reservation is a mis-keyed - // caller: typed rejection, never an ok echoed for the wrong hold. - if (recorded.reservation_id !== reservationId) { - throw new AdjustReservationMismatchError(key, recorded.reservation_id, reservationId); - } - return recorded.outcome === "ok" - ? { ok: true, reservationId } - : { ok: false, reason: "OUT_OF_STOCK" }; - } - - const row = await this.#selectById(reservationId); - if (row.state !== "held") { - throw new ReservationNotHeldError(reservationId, row.state); - } - const prevQty = row.qty; - const delta = newQty - prevQty; - - const outcome = await this.#db - .transaction() - .execute(async (trx) => { - // Claim: exactly one caller per key proceeds to move inventory. A - // concurrent same-key peer blocks here until this tx resolves, then - // falls to the recorded row (or retries if this tx aborted). - const claim = await trx - .insertInto("inventory_adjustments") - .values({ - idempotency_key: key, - reservation_id: reservationId, - to_qty: newQty, - outcome: "ok", - created_at: this.#clock.now().toISOString(), - }) - .onConflict((oc) => oc.column("idempotency_key").doNothing()) - .returning("idempotency_key") - .executeTakeFirst(); - if (claim === undefined) return LOST; // peer holds the key: re-read - - if (delta === 0) return { ok: true, reservationId }; - - // Guard-first CAS: move the reservation to `newQty` only if it is - // still `held` at the qty we computed the delta from. This row lock - // serializes every adjust/checkout on the hold; 0 rows ⇒ the state - // or qty changed under us ⇒ roll everything back and re-read. - const cas = await trx - .updateTable("reservations") - .set({ qty: newQty }) - .where("id", "=", reservationId) - .where("state", "=", "held") - .where("qty", "=", prevQty) - .returning("id") - .executeTakeFirst(); - if (cas === undefined) throw new LostCasError(); - - if (delta > 0) { - // Increase: oversell-critical single conditional decrement. - const decremented = await trx - .updateTable("inventory") - .set({ on_hand: sql`on_hand - ${delta}` }) - .where("sku", "=", row.sku) - .where("on_hand", ">=", delta) - .returning("on_hand") - .executeTakeFirst(); - if (decremented === undefined) { - // OUT_OF_STOCK: restore the reservation qty and record the failed - // outcome in the SAME tx (key stays consumed, R2) — externally the - // hold never moved. - await trx - .updateTable("reservations") - .set({ qty: prevQty }) - .where("id", "=", reservationId) - .execute(); - await trx - .updateTable("inventory_adjustments") - .set({ outcome: "out_of_stock" }) - .where("idempotency_key", "=", key) - .execute(); - return { ok: false, reason: "OUT_OF_STOCK" }; - } - return { ok: true, reservationId }; - } - - // Decrease: unconditional partial release, always succeeds. - await trx - .updateTable("inventory") - .set({ on_hand: sql`on_hand + ${-delta}` }) - .where("sku", "=", row.sku) - .execute(); - return { ok: true, reservationId }; - }) - .catch((err: unknown): ReserveResult | typeof LOST => { - if (err instanceof LostCasError) return LOST; - throw err; - }); - - if (outcome !== LOST) return outcome; - await delay(this.#delayMs); - } - throw new Error(`adjust of reservation ${reservationId} did not settle in time`); - } - - // -- internals ------------------------------------------------------------ - - async #resolveState( - id: string, - state: ReservationState, - sku: string, - qty: number, - ): Promise { - switch (state) { - case "held": - case "committed": - case "released": - case "adopted": - // `held` (and its post-commit/release/adopt terminals) is proof the - // stock was durably removed together with the flip. - return { ok: true, reservationId: id }; - case "failed": - return { ok: false, reason: "OUT_OF_STOCK" }; - case "pending": - // Claimed but never finalized (in-flight peer or a crash after the - // claim). A `pending` row proves no decrement committed, so it is - // safe to run the finalize ourselves, racing whoever else observes it. - return this.#finalizeOrAwait(id, sku, qty); - } - } - - async #finalizeOrAwait(id: string, sku: string, qty: number): Promise { - const outcome = await this.#finalize(id, sku, qty); - if (outcome !== LOST) return outcome; - return this.#awaitTerminal(id); - } - - /** The finalize transaction (§0.5 step 2). */ - async #finalize(id: string, sku: string, qty: number): Promise { - return this.#db.transaction().execute(async (trx) => { - // Claim guard: exactly one caller flips a given reservation. - const flipped = await trx - .updateTable("reservations") - .set({ state: "held" }) - .where("id", "=", id) - .where("state", "=", "pending") - .returning("id") - .executeTakeFirst(); - if (flipped === undefined) return LOST; // peer finalized; rollback, re-read. - - await this.hooks.beforeDecrement?.(id); - - // Oversell-critical decrement: single conditional statement. - const decremented = await trx - .updateTable("inventory") - .set({ on_hand: sql`on_hand - ${qty}` }) - .where("sku", "=", sku) - .where("on_hand", ">=", qty) - .returning("on_hand") - .executeTakeFirst(); - - if (decremented !== undefined) { - // `held` flip + decrement commit together. - return { ok: true, reservationId: id }; - } - - // OUT_OF_STOCK: overwrite the transient `held` with `failed` in the - // same tx (never externally visible); the key stays consumed. - await trx.updateTable("reservations").set({ state: "failed" }).where("id", "=", id).execute(); - return { ok: false, reason: "OUT_OF_STOCK" }; - }); - } - - /** Bounded re-read until the reservation reaches a terminal state. */ - async #awaitTerminal(id: string): Promise { - for (let attempt = 0; attempt < this.#maxAttempts; attempt++) { - const row = await this.#selectById(id); - if (row.state === "failed") return { ok: false, reason: "OUT_OF_STOCK" }; - if (row.state !== "pending") return { ok: true, reservationId: id }; - await delay(this.#delayMs); - } - throw new Error(`reservation ${id} did not reach a terminal state in time`); - } - - async #selectByKey( - key: string, - ): Promise<{ id: string; state: ReservationState; sku: string; qty: number }> { - const row = await this.#db - .selectFrom("reservations") - .select(["id", "state", "sku", "qty"]) - .where("idempotency_key", "=", key) - .executeTakeFirst(); - if (row === undefined) { - throw new Error(`no reservation found for idempotency key ${key}`); - } - return row; - } - - async #selectById( - id: string, - ): Promise<{ id: string; state: ReservationState; sku: string; qty: number }> { - const row = await this.#db - .selectFrom("reservations") - .select(["id", "state", "sku", "qty"]) - .where("id", "=", id) - .executeTakeFirst(); - if (row === undefined) { - throw new ReservationNotFoundError(id); - } - return row; - } -} - -function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -/** Portable FK-violation check: pg SQLSTATE `23503` / better-sqlite3 code. */ -function isForeignKeyViolation(err: unknown): boolean { - if (typeof err !== "object" || err === null) return false; - const code = (err as { code?: unknown }).code; - return code === "23503" || code === "SQLITE_CONSTRAINT_FOREIGNKEY"; -} diff --git a/packages/store-postgres/src/kysely-order-notes-store.ts b/packages/store-postgres/src/kysely-order-notes-store.ts deleted file mode 100644 index f4c5ec65..00000000 --- a/packages/store-postgres/src/kysely-order-notes-store.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { - type AppendOrderNoteInput, - type AppendOrderNoteResult, - type Clock, - type IdGen, - type OrderId, - orderId as toOrderId, - type OrderNote, - type OrderNotesStore, -} from "@otta-sh/domain"; -import type { Kysely, Selectable } from "kysely"; -import type { Database, OrderNotesTable } from "./schema.js"; - -export interface KyselyOrderNotesStoreOptions { - db: Kysely; - idGen: IdGen; - clock: Clock; -} - -/** - * `OrderNotesStore` over Kysely (admin-UX Increment 0), dialect-agnostic across - * better-sqlite3 and pg. `append` is a single INSERT guarded by - * `order_notes.idempotency_key` UNIQUE via `ON CONFLICT DO NOTHING RETURNING`: - * the first append wins and returns the new row; a replay (or a concurrent - * duplicate-key race) inserts nothing and reloads the already-stored note — so - * exactly one note lands per key even under concurrency. `listForOrder` reads the - * one order's notes in append order (`created_at ASC, id ASC`; `created_at` is - * fixed-width ISO-8601 text ⇒ lexical order IS chronological, so the comparison is - * dialect-identical). Notes are insert-once: no code path updates a stored note. - */ -export class KyselyOrderNotesStore implements OrderNotesStore { - readonly #db: Kysely; - readonly #idGen: IdGen; - readonly #clock: Clock; - - constructor(options: KyselyOrderNotesStoreOptions) { - this.#db = options.db; - this.#idGen = options.idGen; - this.#clock = options.clock; - } - - async append(input: AppendOrderNoteInput): Promise { - const inserted = await this.#db - .insertInto("order_notes") - .values({ - id: this.#idGen.newId(), - order_id: input.orderId, - author: input.author, - body: input.body, - idempotency_key: input.idempotencyKey, - created_at: this.#clock.now().toISOString(), - }) - .onConflict((oc) => oc.column("idempotency_key").doNothing()) - .returningAll() - .executeTakeFirst(); - - if (inserted !== undefined) return { appended: true, note: toNote(inserted) }; - - // Key already present ⇒ replay (or lost the insert race): reload the stored - // note so both callers see the identical, once-only note. - const existing = await this.#db - .selectFrom("order_notes") - .selectAll() - .where("idempotency_key", "=", input.idempotencyKey) - .executeTakeFirstOrThrow(); - return { appended: false, note: toNote(existing) }; - } - - async listForOrder(orderId: OrderId): Promise { - const rows = await this.#db - .selectFrom("order_notes") - .selectAll() - .where("order_id", "=", orderId) - .orderBy("created_at", "asc") - .orderBy("id", "asc") - .execute(); - return rows.map(toNote); - } -} - -function toNote(row: Selectable): OrderNote { - return { - id: row.id, - orderId: toOrderId(row.order_id), - author: row.author, - body: row.body, - createdAt: row.created_at, - }; -} diff --git a/packages/store-postgres/src/kysely-order-store.ts b/packages/store-postgres/src/kysely-order-store.ts deleted file mode 100644 index 2f566976..00000000 --- a/packages/store-postgres/src/kysely-order-store.ts +++ /dev/null @@ -1,1343 +0,0 @@ -import { - cents, - currency as toCurrency, - emailTemplateForState, - idempotencyKey as toIdempotencyKey, - isLegalOrderTransition, - orderId as toOrderId, - productId as toProductId, - reservationId as toReservationId, - sku as toSku, - type CancellationReason, - type CancelOrderInput, - type CancelOrderStoreResult, - type CapturedPayment, - type Clock, - type CreateOrderInput, - type CreateOrderResult, - type CustomerId, - type FinalizeRefundInput, - type FinalizeRefundStoreResult, - type FulfillmentKind, - type IdempotencyKey, - type IdGen, - type Order, - type OrderAddress, - type OrderEvent, - type OrderId, - type OrderLine, - type OrderListFilter, - type OrderListPage, - type OrderListResult, - type OrderState, - type OrderStore, - type OrderSummary, - type OrderTotals, - type OrderTransitionInput, - type OrderTransitionResult, - type OutboxEmail, - type PaymentMethod, - type ReconciliationOutcome, - type RecordFulfillmentInput, - type RecordFulfillmentStoreResult, - type RecordPaymentInput, - type RecordRefundInput, - type RecordRefundStoreResult, - type RefundKind, - type RefundRecord, - type RefundStatus, - type ResolveReconciliationInput, - type ResolveReconciliationStoreResult, -} from "@otta-sh/domain"; -import { - type Expression, - expressionBuilder, - type ExpressionBuilder, - type Kysely, - type Selectable, - sql, - type SqlBool, - type Transaction, - type Updateable, -} from "kysely"; -import type { - Database, - OrderEventsTable, - OrderItemsTable, - OrdersTable, - OrderShippingAddressTable, - OrderTotalsTable, - RefundsTable, -} from "./schema.js"; - -export interface KyselyOrderStoreOptions { - db: Kysely; - idGen: IdGen; - clock: Clock; -} - -/** - * `OrderStore` over Kysely (§4), dialect-agnostic across better-sqlite3 and pg. - * `createFromCart` co-locates the `orders` + `order_items` + `order_totals` writes - * in one short transaction guarded by `orders.idempotency_key` UNIQUE: the order - * row is durably persisted before the caller adopts any reservation (§5). A replay - * (key conflict) returns the existing order, re-snapshotting nothing. Every state - * change is a guarded flip (0 rows ⇒ someone else won). `order_items` are - * insert-once — no code path ever updates a snapshot (immutability is structural). - */ -export class KyselyOrderStore implements OrderStore { - readonly #db: Kysely; - readonly #idGen: IdGen; - readonly #clock: Clock; - - constructor(options: KyselyOrderStoreOptions) { - this.#db = options.db; - this.#idGen = options.idGen; - this.#clock = options.clock; - } - - async createFromCart(input: CreateOrderInput): Promise { - const now = this.#clock.now().toISOString(); - const created = await this.#db.transaction().execute(async (trx) => { - const inserted = await trx - .insertInto("orders") - .values({ - id: input.orderId, - cart_id: input.cartId, - currency: input.currency, - state: "pending", - idempotency_key: input.idempotencyKey, - hold_expires_at: input.holdExpiresAt, - payment_method: input.paymentMethod, - buyer_ref: input.buyerRef, - created_at: now, - updated_at: now, - }) - .onConflict((oc) => oc.column("idempotency_key").doNothing()) - .returning("id") - .executeTakeFirst(); - if (inserted === undefined) return false; // key exists ⇒ replay - - // One multi-row INSERT for the whole cart instead of a per-line loop: - // `newId()` is still called PER LINE (ids stay one-per-line) and every - // column mapping is byte-for-byte the old loop body. The length guard is - // defensive — a real order always has ≥1 line, but an empty array would - // otherwise emit invalid `INSERT ... VALUES ()` SQL. - if (input.lines.length > 0) { - await trx - .insertInto("order_items") - .values( - input.lines.map((line) => ({ - id: this.#idGen.newId(), - order_id: input.orderId, - product_id: line.productId, - sku: line.sku, - title: line.title, - unit_price_cents: line.unitPrice, - currency: line.currency, - quantity: line.quantity, - fulfillment_kind: line.fulfillmentKind, - reservation_id: line.reservationId, - })), - ) - .execute(); - } - await trx - .insertInto("order_totals") - .values({ - order_id: input.orderId, - currency: input.totals.currency, - subtotal_cents: input.totals.subtotal, - // Phase 6: the full computed breakdown. Phase-4/5 callers pass none - // of these ⇒ 0 / null, reproducing the stub byte-for-byte. - discount_cents: input.totals.discount ?? 0, - shipping_cents: input.totals.shipping ?? 0, - tax_cents: input.totals.tax ?? 0, - total_cents: input.totals.total, - applied_coupon_code: input.totals.appliedCouponCode ?? null, - // jsonb-as-text: stored as a JSON string (null stays null). - shipping_method_snapshot: jsonOrNull(input.totals.shippingMethodSnapshot), - tax_breakdown: jsonOrNull(input.totals.taxBreakdown), - }) - .execute(); - // ADR-0009: freeze the ship-to snapshot in the SAME guarded transaction as - // the order + totals, iff one was captured. A replay never reaches here - // (the order insert conflicted and returned `false` above), so the address - // is written exactly once — the line-snapshot precedent. - const address = input.shippingAddress; - if (address !== undefined && address !== null) { - await trx - .insertInto("order_shipping_address") - .values({ - order_id: input.orderId, - name: address.name, - line1: address.line1, - line2: address.line2, - city: address.city, - region: address.region, - postal_code: address.postalCode, - country: address.country, - email: address.email, - phone: address.phone, - }) - .execute(); - } - return true; - }); - - const order = created - ? await this.#loadById(input.orderId) - : await this.#loadByKey(input.idempotencyKey); - if (order === null) throw new Error("order vanished immediately after createFromCart"); - return { created, order }; - } - - async getById(orderId: OrderId): Promise { - return this.#loadById(orderId); - } - - async getByIdempotencyKey(key: IdempotencyKey): Promise { - return this.#loadByKey(key); - } - - async markPaid(orderId: OrderId): Promise { - // pending → paid also enqueues the order-confirmation email (§5), in the - // same transaction as the flip. - return this.#guardedTransition({ - orderId, - fromState: "pending", - toState: "paid", - enqueueEmail: true, - }); - } - - async markFailed(orderId: OrderId): Promise { - // pending → failed has no template (§9 Risk 8), so no outbox row. - return this.#guardedTransition({ - orderId, - fromState: "pending", - toState: "failed", - enqueueEmail: false, - }); - } - - async expire(orderId: OrderId, now: string): Promise { - // Phase 4's deadline-guarded flip, now wrapped with the outbox INSERT in one - // transaction (§5 "Wiring, not duplication"). The guard predicate is - // unchanged; the reservation-release logic stays in `expireOrders`. - return this.#guardedTransition({ - orderId, - fromState: "pending", - toState: "expired", - enqueueEmail: true, - holdExpiresBefore: now, - }); - } - - async listExpirable(now: string): Promise { - const rows = await this.#db - .selectFrom("orders") - .select("id") - .where("state", "=", "pending") - .where("hold_expires_at", "<=", now) - .execute(); - return rows.map((r) => toOrderId(r.id)); - } - - async recordPayment(input: RecordPaymentInput): Promise { - await this.#db - .insertInto("payments") - .values({ - id: this.#idGen.newId(), - order_id: input.orderId, - gateway: input.gateway, - provider_ref: input.providerRef, - amount_cents: input.amount, - currency: input.currency, - status: input.status, - created_at: this.#clock.now().toISOString(), - }) - .onConflict((oc) => oc.column("provider_ref").doNothing()) - .execute(); - } - - // -- Refunds ledger (ADR-0008) -------------------------------------------- - - async getCapturedPayments(orderId: OrderId): Promise { - const rows = await this.#db - .selectFrom("payments") - .select(["gateway", "provider_ref", "amount_cents", "currency", "status"]) - .where("order_id", "=", orderId) - .execute(); - return rows.map((r) => ({ - gateway: r.gateway as PaymentMethod, - providerRef: r.provider_ref, - amount: cents(r.amount_cents), - currency: toCurrency(r.currency), - status: r.status, - })); - } - - async listRefunds(orderId: OrderId): Promise { - const rows = await this.#db - .selectFrom("refunds") - .selectAll() - .where("order_id", "=", orderId) - .orderBy("created_at", "asc") - .orderBy("id", "asc") - .execute(); - return rows.map(toRefund); - } - - async getRefundByIdempotencyKey(key: IdempotencyKey): Promise { - const row = await this.#db - .selectFrom("refunds") - .selectAll() - .where("idempotency_key", "=", key) - .executeTakeFirst(); - return row === undefined ? null : toRefund(row); - } - - async recordRefund(input: RecordRefundInput): Promise { - // The MANUAL/record-only one-shot (ADR-0008): reserve + finalize collapsed - // into ONE transaction because there is no gateway leg. Inserts a FINALIZED - // (`status:'recorded'`) row and — when the finalized Σ reaches the ceiling — - // flips `→ refunded`. The gateway path does NOT use this; it goes through - // reserveRefund → gateway → finalizeRefund. See `#insertRefundRow` for the - // shared locked/dedupe/arbitrate body. - return this.#insertRefundRow(input, { status: "recorded", driveFlip: true }); - } - - async reserveRefund(input: RecordRefundInput): Promise { - // RESERVE the ledger slot BEFORE any gateway issuance (ADR-0008): the SAME - // atomic arbitration as recordRefund (row lock + dedupe + ACTIVE-sum ceiling) - // but the row lands `status:'reserved'` and the `→ refunded` flip is NEVER - // driven — a reservation is capacity held, not money moved. This is the - // arbitration point for the gateway path: a rejected reservation never - // reaches the provider, so money cannot leave without a ledger row holding - // its capacity. - return this.#insertRefundRow(input, { status: "reserved", driveFlip: false }); - } - - /** Shared locked/dedupe/arbitrate/insert body for {@link recordRefund} (finalized - * one-shot) and {@link reserveRefund} (held slot). ONE transaction that first - * LOCKS the order row (on pg the row lock serializes N concurrent refunds so - * none reads a stale Σ; sqlite serializes writes globally ⇒ a harmless no-op), - * then: dedupe → ceiling `min(Σ captured, total)` over the ACTIVE (non-'voided') - * refund Σ → insert → (finalized path only) full-refund flip. NEVER touches - * order_items/order_totals. */ - async #insertRefundRow( - input: RecordRefundInput, - opts: { status: Extract; driveFlip: boolean }, - ): Promise { - const now = this.#clock.now().toISOString(); - const result = await this.#db - .transaction() - .execute(async (trx): Promise> => { - const locked = await trx - .updateTable("orders") - .set({ updated_at: now }) - .where("id", "=", input.orderId) - .returning(["id", "state"]) - .executeTakeFirst(); - if (locked === undefined) { - return { - outcome: "order_not_found", - refund: null, - fullyRefunded: false, - capturedTotal: cents(0), - frozenTotal: cents(0), - }; - } - - const capturedRow = await trx - .selectFrom("payments") - .select(sql`coalesce(sum(amount_cents), 0)`.as("captured")) - .where("order_id", "=", input.orderId) - .where("status", "=", "succeeded") - .executeTakeFirstOrThrow(); - const capturedTotal = cents(Number(capturedRow.captured)); - const totalsRow = await trx - .selectFrom("order_totals") - .select("total_cents") - .where("order_id", "=", input.orderId) - .executeTakeFirstOrThrow(); - const frozenTotal = cents(totalsRow.total_cents); - - const existing = await trx - .selectFrom("refunds") - .selectAll() - .where("idempotency_key", "=", input.idempotencyKey) - .executeTakeFirst(); - if (existing !== undefined) { - return { - outcome: "duplicate", - refund: toRefund(existing), - fullyRefunded: locked.state === "refunded", - capturedTotal, - frozenTotal, - }; - } - - const ceiling = Math.min(capturedTotal, frozenTotal); - // ACTIVE Σ — every non-'voided' row (finalized + held reservations + - // unverified) consumes ceiling capacity. A voided row released its slot. - const activeRow = await trx - .selectFrom("refunds") - .select(sql`coalesce(sum(amount_cents), 0)`.as("active")) - .where("order_id", "=", input.orderId) - .where("status", "!=", "voided") - .executeTakeFirstOrThrow(); - const activePrior = Number(activeRow.active); - if (activePrior + input.amount > ceiling) { - return { - outcome: "exceeds_ceiling", - refund: null, - fullyRefunded: false, - capturedTotal, - frozenTotal, - }; - } - - const id = this.#idGen.newId(); - await trx - .insertInto("refunds") - .values({ - id, - order_id: input.orderId, - amount_cents: input.amount, - currency: input.currency, - kind: input.kind, - gateway: input.gateway, - refund_ref: input.refundRef, - reason: input.reason, - refunded_by: input.refundedBy, - idempotency_key: input.idempotencyKey, - status: opts.status, - created_at: now, - }) - .execute(); - - let fullyRefunded = false; - // FULL refund (finalized Σ reached the ceiling) → flip → refunded - // atomically with the ledger row, through the SAME guarded flip + audit + - // outbox as cancel/fulfillment. Driven ONLY on the finalized (record) - // path — a held reservation never flips. The finalized prior counts - // 'recorded' rows only. - if (opts.driveFlip) { - const finalizedRow = await trx - .selectFrom("refunds") - .select(sql`coalesce(sum(amount_cents), 0)`.as("finalized")) - .where("order_id", "=", input.orderId) - .where("status", "=", "recorded") - .executeTakeFirstOrThrow(); - const finalizedTotal = Number(finalizedRow.finalized); // includes the row just inserted - if ( - finalizedTotal === ceiling && - isLegalOrderTransition(locked.state as OrderState, "refunded") - ) { - await this.#flipAndEnqueue(trx, { - orderId: input.orderId, - fromState: locked.state as OrderState, - toState: "refunded", - enqueueEmail: emailTemplateForState("refunded") !== null, - now, - actor: input.refundedBy, - }); - fullyRefunded = true; - } - } - - const refund: RefundRecord = { - id, - orderId: input.orderId, - amount: input.amount, - currency: input.currency, - kind: input.kind, - gateway: input.gateway, - refundRef: input.refundRef, - reason: input.reason, - refundedBy: input.refundedBy, - status: opts.status, - idempotencyKey: input.idempotencyKey, - createdAt: now, - }; - return { outcome: "recorded", refund, fullyRefunded, capturedTotal, frozenTotal }; - }); - const order = await this.#loadById(input.orderId); - return { ...result, order }; - } - - async finalizeRefund(input: FinalizeRefundInput): Promise { - // FINALIZE a reserved refund after the gateway confirmed issuance (ADR-0008): - // stamp the provider refundRef, flip the row `reserved|unverified → recorded`, - // and — when the FINALIZED Σ now reaches the ceiling — drive `→ refunded`, - // all in ONE transaction under the same orders row lock as the reserve. - // Finalize can NEVER lose arbitration: the reservation already holds the - // capacity. The row UPDATE is STATUS-GUARDED so a stray finalize can never - // clobber a voided/recorded row; a key already `recorded` with the SAME - // refundRef (a concurrent same-key caller finalized first — the provider's - // native idempotency guarantees one refund) is a BENIGN duplicate; anything - // else is `found:false` — the loud residual. - const now = this.#clock.now().toISOString(); - - /** The no-held-row disposition: benign duplicate (recorded, SAME ref) vs - * the loud residual (voided / different ref / no row at all). */ - const settleMissing = async ( - trx: Transaction, - ): Promise> => { - const existing = await trx - .selectFrom("refunds") - .selectAll() - .where("idempotency_key", "=", input.idempotencyKey) - .executeTakeFirst(); - if ( - existing !== undefined && - existing.status === "recorded" && - existing.refund_ref === input.refundRef - ) { - const ord = await trx - .selectFrom("orders") - .select("state") - .where("id", "=", existing.order_id) - .executeTakeFirst(); - return { - found: true, - alreadyFinalized: true, - refund: toRefund(existing), - fullyRefunded: ord?.state === "refunded", - }; - } - return { found: false, alreadyFinalized: false, refund: null, fullyRefunded: false }; - }; - - const result = await this.#db - .transaction() - .execute(async (trx): Promise> => { - // Resolve the reserved/unverified row FIRST — its order_id is the lock - // target. A finalize only ever runs after a committed reservation, so an - // absent held row is either the benign same-ref duplicate or the loud - // residual the use-case surfaces (never a drop). - const row = await trx - .selectFrom("refunds") - .selectAll() - .where("idempotency_key", "=", input.idempotencyKey) - .where("status", "in", ["reserved", "unverified"]) - .executeTakeFirst(); - if (row === undefined) return settleMissing(trx); - // Lock the order row (serialize with any concurrent refund on this order), - // then finalize the reserved row: stamp refundRef, flip → recorded — the - // UPDATE is STATUS-GUARDED (`reserved|unverified` only), so it can never - // clobber a row a concurrent settle already moved to voided/recorded. - const lockedOrder = await trx - .updateTable("orders") - .set({ updated_at: now }) - .where("id", "=", row.order_id) - .returning(["id", "state"]) - .executeTakeFirst(); - const won = await trx - .updateTable("refunds") - .set({ status: "recorded", refund_ref: input.refundRef }) - .where("idempotency_key", "=", input.idempotencyKey) - .where("status", "in", ["reserved", "unverified"]) - .returning("id") - .executeTakeFirst(); - // Lost the settle to a concurrent same-key caller between the read and the - // lock — re-disposition under the lock (benign same-ref dup, or residual). - if (won === undefined) return settleMissing(trx); - - const capturedRow = await trx - .selectFrom("payments") - .select(sql`coalesce(sum(amount_cents), 0)`.as("captured")) - .where("order_id", "=", row.order_id) - .where("status", "=", "succeeded") - .executeTakeFirstOrThrow(); - const totalsRow = await trx - .selectFrom("order_totals") - .select("total_cents") - .where("order_id", "=", row.order_id) - .executeTakeFirstOrThrow(); - const ceiling = Math.min(Number(capturedRow.captured), totalsRow.total_cents); - // FINALIZED Σ (now includes the row just flipped to 'recorded') — the flip - // to → refunded counts ONLY finalized money, never held reservations. - const finalizedRow = await trx - .selectFrom("refunds") - .select(sql`coalesce(sum(amount_cents), 0)`.as("finalized")) - .where("order_id", "=", row.order_id) - .where("status", "=", "recorded") - .executeTakeFirstOrThrow(); - const finalizedTotal = Number(finalizedRow.finalized); - - let fullyRefunded = false; - if ( - lockedOrder !== undefined && - finalizedTotal === ceiling && - isLegalOrderTransition(lockedOrder.state as OrderState, "refunded") - ) { - await this.#flipAndEnqueue(trx, { - orderId: toOrderId(row.order_id), - fromState: lockedOrder.state as OrderState, - toState: "refunded", - enqueueEmail: emailTemplateForState("refunded") !== null, - now, - actor: row.refunded_by, - }); - fullyRefunded = true; - } - - const refund: RefundRecord = { - ...toRefund(row), - status: "recorded", - refundRef: input.refundRef, - }; - return { found: true, alreadyFinalized: false, refund, fullyRefunded }; - }); - const order = result.refund === null ? null : await this.#loadById(result.refund.orderId); - return { ...result, order }; - } - - async voidRefund(idempotencyKey: IdempotencyKey): Promise { - // Guarded `reserved → voided` (ADR-0008): the gateway leg definitively did - // not issue (fail-closed pre-flight / terminal rejection / unsupported). A - // voided row RELEASES its ceiling capacity but stays as an audit record. - const won = await this.#db - .updateTable("refunds") - .set({ status: "voided" }) - .where("idempotency_key", "=", idempotencyKey) - .where("status", "=", "reserved") - .returning("id") - .executeTakeFirst(); - return won !== undefined; - } - - async markRefundUnverified(idempotencyKey: IdempotencyKey): Promise { - // Guarded `reserved → unverified` (ADR-0008): an ambiguous gateway outcome. - // The row KEEPS holding its ceiling capacity — the safe direction — until a - // human re-checks the provider. - const won = await this.#db - .updateTable("refunds") - .set({ status: "unverified" }) - .where("idempotency_key", "=", idempotencyKey) - .where("status", "=", "reserved") - .returning("id") - .executeTakeFirst(); - return won !== undefined; - } - - async flagReconciliation(orderId: OrderId, detail: string): Promise { - await this.#db - .updateTable("orders") - .set({ reconciliation_flag: detail, updated_at: this.#clock.now().toISOString() }) - .where("id", "=", orderId) - .execute(); - } - - async resolveReconciliation( - input: ResolveReconciliationInput, - ): Promise { - // EQUALITY-guarded compare-and-clear on the reconciliation axis — the - // `transition` fromState precedent: `WHERE reconciliation_flag = - // :expectedFlag` makes the resolve once-only under concurrency (exactly one - // caller clears the flag + records the disposition) AND stale-review-safe (a - // NEW anomaly re-flagging the order after the admin loaded the page no longer - // matches — a 0-row miss, never a blind clear). RETURNING id tells us who - // won. NEVER touches state / order_items / order_totals — only the mutable - // reconciliation envelope. input.idempotencyKey is intentionally unused: - // dedup is structural via the guard (mirrors `transition`, H4). - const now = this.#clock.now().toISOString(); - const won = await this.#db - .updateTable("orders") - .set({ - reconciliation_flag: null, - reconciliation_outcome: input.outcome, - reconciliation_reason: input.reason, - reconciliation_resolved_by: input.resolvedBy, - reconciliation_resolved_at: now, - updated_at: now, - }) - .where("id", "=", input.orderId) - .where("reconciliation_flag", "=", input.expectedFlag) - .returning("id") - .executeTakeFirst(); - const order = await this.#loadById(input.orderId); - return { resolved: won !== undefined, order }; - } - - async recordFulfillment(input: RecordFulfillmentInput): Promise { - // Record + ship + enqueue, atomically (admin-UX Increment 1). Routed through - // the SAME `#flipAndEnqueue` primitive as `transition`/`markPaid`/`expire` - // (PR #63 review — one guarded-flip implementation, no parallel copy that - // could drift): the fulfillment columns ride the guarded `WHERE id=:id AND - // state=:fromState` UPDATE as `extraSet`, then — when `enqueueEmail` — the - // `shipped` outbox row is inserted (`ON CONFLICT DO NOTHING`), all in ONE - // transaction on one connection. So no reachable state is "shipped without - // fulfillment" via this path, and the shipped email that drains carries the - // tracking. The fromState guard (validated by the use-case against the state - // machine) makes it once-only AND composes with the machine: an order a - // concurrent cancel already moved is a 0-row miss (recorded:false). NEVER - // touches order_items/order_totals (the snapshot invariant). - // input.idempotencyKey is intentionally unused — dedup is structural via the - // guard (mirrors `transition`/`resolveReconciliation`, H4). - const now = this.#clock.now().toISOString(); - const recorded = await this.#db.transaction().execute((trx) => - this.#flipAndEnqueue(trx, { - orderId: input.orderId, - fromState: input.fromState, - toState: "shipped", - enqueueEmail: input.enqueueEmail, - now, - // The recorder is the who this domain knows for a fulfillment flip — - // stamped onto the state-change audit event. - actor: input.recordedBy, - extraSet: { - fulfillment_carrier: input.carrier, - fulfillment_tracking_number: input.trackingNumber, - fulfillment_tracking_url: input.trackingUrl, - fulfillment_shipped_at: input.shippedAt ?? now, - fulfillment_recorded_by: input.recordedBy, - fulfillment_recorded_at: now, - }, - }), - ); - const order = await this.#loadById(input.orderId); - return { recorded, order }; - } - - async cancelOrder(input: CancelOrderInput): Promise { - // Cancel + record + enqueue, atomically (admin-UX Increment 1, "cancel with - // reason"). Routed through the SAME `#flipAndEnqueue` primitive as - // `transition`/`recordFulfillment` (one guarded-flip implementation, no - // parallel copy that could drift): the cancellation columns ride the guarded - // `WHERE id=:id AND state=:fromState` UPDATE as `extraSet`, then — when - // `enqueueEmail` — the `cancelled` outbox row is inserted (`ON CONFLICT DO - // NOTHING`), all in ONE transaction on one connection. So no reachable state - // is "cancelled without a reason" via this path, and the cancelled email - // that drains carries it. The fromState guard (validated by the use-case - // against the state machine) makes it once-only AND composes with the - // machine: an order a concurrent recordFulfillment/transition already moved - // is a 0-row miss (cancelled:false). NEVER touches order_items/order_totals - // (the snapshot invariant). input.idempotencyKey is intentionally unused — - // dedup is structural via the guard (mirrors `transition`/ - // `recordFulfillment`, H4). - const now = this.#clock.now().toISOString(); - const cancelled = await this.#db.transaction().execute((trx) => - this.#flipAndEnqueue(trx, { - orderId: input.orderId, - fromState: input.fromState, - toState: "cancelled", - enqueueEmail: input.enqueueEmail, - now, - // The canceller is the who this domain knows for a cancellation flip — - // stamped onto the state-change audit event. - actor: input.cancelledBy, - extraSet: { - cancellation_reason: input.reason, - cancellation_detail: input.detail, - cancellation_cancelled_by: input.cancelledBy, - cancellation_cancelled_at: now, - }, - }), - ); - const order = await this.#loadById(input.orderId); - return { cancelled, order }; - } - - // -- Phase 5: state machine + email outbox -------------------------------- - - async transition(input: OrderTransitionInput): Promise { - // input.idempotencyKey is intentionally unused: dedup here is structural — - // the guarded `WHERE state=:fromState` flip plus the outbox - // `UNIQUE(order_id, to_state)` already make a replay a no-op (review round - // H4). The field is kept on the port for CLAUDE.md command-shape - // consistency ("every command carries one"), not because this adapter - // keys off it. - const transitioned = await this.#guardedTransition({ - orderId: input.orderId, - fromState: input.fromState, - toState: input.toState, - enqueueEmail: input.enqueueEmail, - }); - const order = await this.#loadById(input.orderId); - return { transitioned, order }; - } - - async listForCustomer(customerId: CustomerId): Promise { - const rows = await this.#db - .selectFrom("orders") - .select("id") - .where("customer_id", "=", customerId) - .orderBy("created_at") - .orderBy("id") - .execute(); - const orders: Order[] = []; - for (const row of rows) { - const order = await this.#loadById(row.id); - if (order !== null) orders.push(order); - } - return orders; - } - - async listEventsForOrder(orderId: OrderId): Promise { - // The one order's state-change audit in chronological order. `at` is - // fixed-width ISO-8601 text ⇒ lexical order IS chronological, so `at ASC, id - // ASC` (the `order_events_list_idx` order) is dialect-identical; `id` is the - // stable tie-break when two events share a timestamp under a fixed clock. - const rows = await this.#db - .selectFrom("order_events") - .selectAll() - .where("order_id", "=", orderId) - .orderBy("at", "asc") - .orderBy("id", "asc") - .execute(); - return rows.map(toEvent); - } - - async listOrders(filter: OrderListFilter, page: OrderListPage): Promise { - // A SINGLE SELECT joining orders → order_totals 1:1 (no N+1 into - // order_items/order_totals per row — the list is a projection, not a full - // Order load). Keyset pagination on `(created_at DESC, id DESC)`: fetch - // `limit + 1` to detect a next page, emit `nextCursor` from the last RETURNED - // row. `created_at` is fixed-width ISO-8601 text ⇒ lexical order IS - // chronological, so the raw text comparisons below are dialect-identical - // (no casts) across better-sqlite3 and pg. - let q = this.#db - .selectFrom("orders") - .innerJoin("order_totals", "order_totals.order_id", "orders.id") - .select([ - "orders.id as id", - "orders.state as state", - "orders.currency as currency", - "orders.buyer_ref as buyer_ref", - "orders.customer_id as customer_id", - "orders.payment_method as payment_method", - "orders.created_at as created_at", - "orders.reconciliation_flag as reconciliation_flag", - "order_totals.total_cents as total_cents", - ]); - - const conds = orderFilterConditions(filter); - if (conds.length > 0) q = q.where((eb) => eb.and(conds)); - if (page.cursor !== undefined && page.cursor !== null) { - const cursor = page.cursor; - // (created_at < :c) OR (created_at = :c AND id < :cid) — everything - // strictly "after" the cursor position under `created_at DESC, id DESC`. - q = q.where((eb) => - eb.or([ - eb("orders.created_at", "<", cursor.createdAt), - eb.and([eb("orders.created_at", "=", cursor.createdAt), eb("orders.id", "<", cursor.id)]), - ]), - ); - } - - const rows = await q - .orderBy("orders.created_at", "desc") - .orderBy("orders.id", "desc") - .limit(page.limit + 1) - .execute(); - - const hasMore = rows.length > page.limit; - const returned = hasMore ? rows.slice(0, page.limit) : rows; - const last = returned.at(-1); - const nextCursor = - hasMore && last !== undefined ? { createdAt: last.created_at, id: toOrderId(last.id) } : null; - - const orders: OrderSummary[] = returned.map((r) => ({ - id: toOrderId(r.id), - state: r.state as OrderState, - currency: toCurrency(r.currency), - buyerRef: r.buyer_ref, - customerId: r.customer_id, - paymentMethod: r.payment_method === null ? null : (r.payment_method as PaymentMethod), - createdAt: r.created_at, - total: cents(r.total_cents), - reconciliationFlag: r.reconciliation_flag !== null, - })); - return { orders, nextCursor }; - } - - async countOrders(filter: OrderListFilter): Promise { - // The SAME predicate as `listOrders` (one builder — `orderFilterConditions`) - // over `orders` alone: no totals join, no ordering, one scalar. A count can - // therefore never disagree with the list it captions. - let q = this.#db.selectFrom("orders").select(sql`count(*)`.as("n")); - const conds = orderFilterConditions(filter); - if (conds.length > 0) q = q.where((eb) => eb.and(conds)); - const row = await q.executeTakeFirstOrThrow(); - return Number(row.n); - } - - async linkGuestOrders(customerId: CustomerId, buyerRef: string): Promise { - // Case-insensitive on buyer_ref (review round H2): checkout stores the - // buyer's email VERBATIM, while the login email is lower-normalized — - // compare-side folding (lower(buyer_ref) = lower(?)) links a mixed-case - // guest checkout without rewriting the Phase-4 value. Dialect-agnostic: - // `lower()` is standard SQL, works identically on sqlite and pg. - const res = await this.#db - .updateTable("orders") - .set({ customer_id: customerId, updated_at: this.#clock.now().toISOString() }) - .where(sql`lower(buyer_ref)`, "=", buyerRef.toLowerCase()) - .where("customer_id", "is", null) - .executeTakeFirst(); - return Number(res.numUpdatedRows); - } - - async claimNextEmail(now: string, leaseUntil: string): Promise { - // Lease-driven claimability (§5): not sent, not failed, and no live lease - // (null, or elapsed) — covers fresh-pending, crashed-'sending', and - // rescheduled-with-backoff uniformly. - const claimable = (eb: import("kysely").ExpressionBuilder) => - eb.and([ - eb("sent_at", "is", null), - eb("status", "!=", "failed"), - eb.or([eb("lease_until", "is", null), eb("lease_until", "<=", now)]), - ]); - - const candidate = await this.#db - .selectFrom("order_emails_outbox") - .select("id") - .where(claimable) - .orderBy("created_at") - .orderBy("id") - .limit(1) - .executeTakeFirst(); - if (candidate === undefined) return null; - - // Guarded claim — only one runner wins even under concurrent dispatch. - const claimed = await this.#db - .updateTable("order_emails_outbox") - .set({ status: "sending", lease_until: leaseUntil, attempts: sql`attempts + 1` }) - .where("id", "=", candidate.id) - .where(claimable) - .returning(["id", "order_id", "to_state", "attempts"]) - .executeTakeFirst(); - if (claimed === undefined) return null; // lost the claim race — next tick retries - - return { - id: claimed.id, - orderId: toOrderId(claimed.order_id), - toState: claimed.to_state as OrderState, - attempts: claimed.attempts, - }; - } - - async markEmailSent(id: string, now: string): Promise { - await this.#db - .updateTable("order_emails_outbox") - .set({ status: "sent", sent_at: now, lease_until: null }) - .where("id", "=", id) - .execute(); - } - - async rescheduleEmail(id: string, retryAt: string | null): Promise { - await this.#db - .updateTable("order_emails_outbox") - .set( - retryAt === null - ? { status: "failed", lease_until: null } - : { status: "pending", lease_until: retryAt }, // backoff until retryAt - ) - .where("id", "=", id) - .execute(); - } - - /** - * TEST-ONLY (§5 / 5.5 atomicity case; review round H5) — run the REAL - * transition transaction — guarded `UPDATE` + outbox `INSERT` — then throw - * before `COMMIT`, forcing a rollback. Proves the two writes are atomic: - * after this rejects, neither is visible. - * - * Safe co-location, not a production path: this method is NOT part of the - * `OrderStore` port, so no port consumer (use-case, route, dispatcher) can - * reach it through the interface they're typed against — only test code - * holding a concrete `KyselyOrderStore` (see `order-harness.ts`'s - * `forceFailedTransition`) can call it. It also isn't a candidate to hoist - * into a standalone test helper: it reuses the private `#flipAndEnqueue` to - * exercise the exact production write path rather than a re-implementation - * that could drift from it. - */ - async transitionForTestRollback(input: { - orderId: OrderId; - fromState: OrderState; - toState: OrderState; - }): Promise { - const now = this.#clock.now().toISOString(); - await this.#db.transaction().execute(async (trx) => { - await this.#flipAndEnqueue(trx, { - orderId: input.orderId, - fromState: input.fromState, - toState: input.toState, - enqueueEmail: true, - now, - }); - throw new Error("injected mid-transition failure"); - }); - } - - // -- internals ------------------------------------------------------------ - - /** The guarded flip + conditional outbox insert, in one transaction on one - * connection (§5). Returns whether this call won the flip. */ - async #guardedTransition(input: { - orderId: OrderId; - fromState: OrderState; - toState: OrderState; - enqueueEmail: boolean; - holdExpiresBefore?: string; - }): Promise { - const now = this.#clock.now().toISOString(); - return this.#db.transaction().execute((trx) => - this.#flipAndEnqueue(trx, { - orderId: input.orderId, - fromState: input.fromState, - toState: input.toState, - enqueueEmail: input.enqueueEmail, - now, - ...(input.holdExpiresBefore !== undefined - ? { holdExpiresBefore: input.holdExpiresBefore } - : {}), - }), - ); - } - - async #flipAndEnqueue( - trx: Transaction, - input: { - orderId: OrderId; - fromState: OrderState; - toState: OrderState; - enqueueEmail: boolean; - now: string; - holdExpiresBefore?: string; - /** Who triggered the flip, when this domain knows (recorder/canceller); - * stamped onto the state-change audit event, else null. */ - actor?: string; - /** Extra columns written IN the same guarded UPDATE as the flip (e.g. - * `recordFulfillment`'s tracking envelope) — so a caller composing "flip + - * record" atomically reuses THIS primitive instead of hand-rolling a - * parallel guarded UPDATE that could drift from it. */ - extraSet?: Updateable; - }, - ): Promise { - let flip = trx - .updateTable("orders") - .set({ ...input.extraSet, state: input.toState, updated_at: input.now }) - .where("id", "=", input.orderId) - .where("state", "=", input.fromState); - if (input.holdExpiresBefore !== undefined) { - flip = flip.where("hold_expires_at", "<=", input.holdExpiresBefore); - } - const flipped = await flip.returning("id").executeTakeFirst(); - if (flipped === undefined) return false; // already transitioned / not due - - // State-change audit — written IN the same transaction as the (won) flip, so - // a row exists iff this call won: the 0-row miss above already returned, so a - // replay/lost race records NO event. Append-only (unique id, no ON CONFLICT). - await trx - .insertInto("order_events") - .values({ - id: this.#idGen.newId(), - order_id: input.orderId, - at: input.now, - kind: "state_change", - from_state: input.fromState, - to_state: input.toState, - actor: input.actor ?? null, - }) - .execute(); - - if (input.enqueueEmail) { - await trx - .insertInto("order_emails_outbox") - .values({ - id: this.#idGen.newId(), - order_id: input.orderId, - to_state: input.toState, - status: "pending", - attempts: 0, - lease_until: null, - sent_at: null, - created_at: input.now, - }) - .onConflict((oc) => oc.columns(["order_id", "to_state"]).doNothing()) - .execute(); - } - return true; - } - - async #loadByKey(key: string): Promise { - const row = await this.#db - .selectFrom("orders") - .select("id") - .where("idempotency_key", "=", key) - .executeTakeFirst(); - return row === undefined ? null : this.#loadById(toOrderId(row.id)); - } - - async #loadById(orderId: string): Promise { - const order = await this.#db - .selectFrom("orders") - .selectAll() - .where("id", "=", orderId) - .executeTakeFirst(); - if (order === undefined) return null; - const items = await this.#db - .selectFrom("order_items") - .selectAll() - .where("order_id", "=", orderId) - .orderBy("id") - .execute(); - const totals = await this.#db - .selectFrom("order_totals") - .selectAll() - .where("order_id", "=", orderId) - .executeTakeFirstOrThrow(); - // ADR-0009: the 1:1 ship-to snapshot, or undefined when none was captured - // (a historical/digital-only order) — mapped to `null` on the model. - const address = await this.#db - .selectFrom("order_shipping_address") - .selectAll() - .where("order_id", "=", orderId) - .executeTakeFirst(); - return toOrder(order, items, totals, address ?? null); - } -} - -/** - * The ONE `OrderListFilter` predicate, shared by `listOrders` and `countOrders` - * so their semantics (incl. `lower()` case-folding) can never drift apart. - * Returns standalone expressions (a detached `expressionBuilder` — Kysely - * expressions are self-contained) to AND onto either query. The `customer` key - * is a UNION inside the key (`customer_id = :id OR lower(buyer_ref) = - * lower(:buyerRef)`) — lazy linking means one person's orders split across the - * two columns; a key with neither half set constrains nothing. `search` is the - * operator's fuzzy lookup (id prefix OR buyer_ref substring OR an exact line - * sku, the last as an EXISTS over `order_items` — never a join) and is - * deliberately NOT the same predicate as the customer key's exact `buyerRef`. - */ -function orderFilterConditions(filter: OrderListFilter): Expression[] { - const eb: ExpressionBuilder = expressionBuilder(); - const conds: Expression[] = []; - if (filter.states !== undefined && filter.states.length > 0) { - conds.push(eb("orders.state", "in", filter.states as OrderState[])); - } - if (filter.from !== undefined) conds.push(eb("orders.created_at", ">=", filter.from)); // inclusive - if (filter.to !== undefined) conds.push(eb("orders.created_at", "<", filter.to)); // EXCLUSIVE (half-open, MOD-7) - if (filter.search !== undefined) { - // An order-id PREFIX, a buyer_ref SUBSTRING, or an EXACT purchase-time line - // sku — all folded on BOTH sides (port doc). `lower(:pattern)` rather than a - // JS `.toLowerCase()` so ONE function folds both operands — within a dialect - // the two sides are then folded identically by construction. The explicit - // fold is also what makes the dialects agree at all: a bare LIKE is - // case-sensitive on pg and ASCII-case-insensitive on SQLite. `ESCAPE '\'` - // over an escaped pattern keeps a `%`/`_` in the operator's search a literal - // character; the sku half is an equality, so it needs no pattern and is - // literal by construction. - // - // The sku half is a CORRELATED `EXISTS`, never a join onto `order_items` - // (port doc — the named hazard). `listOrders` selects one row per order via - // a 1:1 `order_totals` join; joining a 1:N table would emit an order once - // PER matching line, so a two-line order would appear twice, the `limit + 1` - // next-page detection would count duplicates as rows, and `countOrders` - // would over-count the very page it captions. `EXISTS` asks "does this order - // have such a line?" and stops at the first — one row per order, always. - // - // This is a SEQUENTIAL SCAN and that is the design (port doc): the - // unanchored buyer_ref half cannot use `idx_orders_buyer_ref_lower`, and - // the anchored id half cannot use the primary key under a default - // collation. The two dialects then plan the sku arm OPPOSITELY, and both - // shapes were read off EXPLAIN rather than assumed (port doc): pg - // DE-CORRELATES this EXISTS into a hashed subplan — one extra sequential - // pass over `order_items` on `lower(sku)`, hashed by order_id and probed in - // memory, paid by every search and by both statements a page issues, with - // the per-row index probe measurably the SLOWER plan there — while SQLite - // keeps it CORRELATED and probes `idx_order_items_order_product - // (order_id=?)` per row, skipping the arm entirely on a row the two cheaper - // arms (written FIRST, deliberately, since `OR` short-circuits) already - // matched. Both equality paths that DO use the buyer_ref index — - // `linkGuestOrders` and the `customer` key below — are untouched. - const escaped = escapeLikePattern(filter.search); - conds.push( - eb.or([ - sql`lower(orders.id) like lower(${`${escaped}%`}) escape '\\'`, - sql`lower(orders.buyer_ref) like lower(${`%${escaped}%`}) escape '\\'`, - eb.exists( - eb - .selectFrom("order_items") - .select("order_items.id") - .whereRef("order_items.order_id", "=", "orders.id") - .where(sql`lower(order_items.sku) = lower(${filter.search})`), - ), - ]), - ); - } - if (filter.customer !== undefined) { - const { customerId, buyerRef } = filter.customer; - const halves: Expression[] = []; - if (customerId !== undefined) halves.push(eb("orders.customer_id", "=", customerId)); - if (buyerRef !== undefined) { - halves.push(eb(sql`lower(orders.buyer_ref)`, "=", buyerRef.toLowerCase())); - } - if (halves.length > 0) conds.push(eb.or(halves)); - } - return conds; -} - -/** Escape a raw user string for safe embedding in a SQL `LIKE` pattern — - * `\`, `%`, and `_` are LIKE metacharacters (the escape char first, so it never - * double-escapes itself). Portable across pg and better-sqlite3, both of which - * support `LIKE … ESCAPE '\'`. A search for a literal `%`/`_` (an operator - * hunting `50%off@…`) must match literally, never as a wildcard. - * - * A DELIBERATE TWIN of the identical helper in `kysely-product-commerce-store - * .ts` — the two lists grew their substring search separately and neither file - * exports it. Lifting both into one shared module is a tidy-up worth doing on - * its own, not a drive-by inside a semantics change. */ -function escapeLikePattern(value: string): string { - return value.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_"); -} - -/** Serialize a jsonb-as-text column value (null passes through). */ -function jsonOrNull(value: unknown | null | undefined): string | null { - return value === null || value === undefined ? null : JSON.stringify(value); -} - -/** Parse a jsonb-as-text column back to data (null/invalid passes through as null). */ -function parseJsonOrNull(value: string | null): unknown | null { - if (value === null) return null; - try { - return JSON.parse(value); - } catch { - return value; // tolerate a legacy/plain-string value - } -} - -function toEvent(row: Selectable): OrderEvent { - return { - id: row.id, - orderId: toOrderId(row.order_id), - at: row.at, - kind: "state_change", - fromState: row.from_state === null ? null : (row.from_state as OrderState), - toState: row.to_state === null ? null : (row.to_state as OrderState), - actor: row.actor, - }; -} - -function toRefund(row: Selectable): RefundRecord { - return { - id: row.id, - orderId: toOrderId(row.order_id), - amount: cents(row.amount_cents), - currency: toCurrency(row.currency), - kind: row.kind as RefundKind, - gateway: row.gateway as PaymentMethod, - refundRef: row.refund_ref, - reason: row.reason, - refundedBy: row.refunded_by, - status: row.status as RefundStatus, - idempotencyKey: toIdempotencyKey(row.idempotency_key), - createdAt: row.created_at, - }; -} - -function toAddress(row: Selectable | null): OrderAddress | null { - if (row === null) return null; - return { - name: row.name, - line1: row.line1, - line2: row.line2, - city: row.city, - region: row.region, - postalCode: row.postal_code, - country: row.country, - email: row.email, - phone: row.phone, - }; -} - -function toOrder( - order: Selectable, - items: Selectable[], - totals: Selectable, - shippingAddress: Selectable | null, -): Order { - const oid = toOrderId(order.id); - const lines: OrderLine[] = items.map((i) => ({ - id: i.id, - orderId: oid, - productId: toProductId(i.product_id), - sku: toSku(i.sku), - title: i.title, - unitPrice: cents(i.unit_price_cents), - currency: toCurrency(i.currency), - quantity: i.quantity, - fulfillmentKind: i.fulfillment_kind as FulfillmentKind, - reservationId: i.reservation_id === null ? null : toReservationId(i.reservation_id), - })); - const t: OrderTotals = { - orderId: oid, - currency: toCurrency(totals.currency), - subtotal: cents(totals.subtotal_cents), - discount: cents(totals.discount_cents), - shipping: cents(totals.shipping_cents), - tax: cents(totals.tax_cents), - total: cents(totals.total_cents), - appliedCouponCode: totals.applied_coupon_code, - shippingMethodSnapshot: parseJsonOrNull(totals.shipping_method_snapshot), - taxBreakdown: parseJsonOrNull(totals.tax_breakdown), - }; - return { - id: oid, - cartId: order.cart_id, - currency: toCurrency(order.currency), - state: order.state, - idempotencyKey: toIdempotencyKey(order.idempotency_key), - holdExpiresAt: order.hold_expires_at, - paymentMethod: order.payment_method === null ? null : (order.payment_method as PaymentMethod), - buyerRef: order.buyer_ref, - customerId: order.customer_id, - createdAt: order.created_at, - updatedAt: order.updated_at, - lines, - totals: t, - shippingAddress: toAddress(shippingAddress), - reconciliationFlag: order.reconciliation_flag, - // A resolution exists iff the flag was resolved (all four columns written - // atomically by resolveReconciliation); `resolved_at` is the presence witness. - reconciliationResolution: - order.reconciliation_resolved_at === null - ? null - : { - outcome: order.reconciliation_outcome as ReconciliationOutcome, - reason: order.reconciliation_reason ?? "", - resolvedBy: order.reconciliation_resolved_by ?? "", - resolvedAt: order.reconciliation_resolved_at, - }, - // A fulfillment exists iff it was recorded (all columns written atomically by - // recordFulfillment); `recorded_at` is the presence witness. - fulfillment: - order.fulfillment_recorded_at === null - ? null - : { - carrier: order.fulfillment_carrier ?? "", - trackingNumber: order.fulfillment_tracking_number ?? "", - trackingUrl: order.fulfillment_tracking_url, - shippedAt: order.fulfillment_shipped_at ?? order.fulfillment_recorded_at, - recordedBy: order.fulfillment_recorded_by ?? "", - recordedAt: order.fulfillment_recorded_at, - }, - // A cancellation exists iff it was recorded via cancelOrder (all columns - // written atomically); `cancelled_at` is the presence witness. A - // bare-transition cancellation (state='cancelled', no reason on file) reads - // as null here — an honest "no reason recorded" state. - cancellation: - order.cancellation_cancelled_at === null - ? null - : { - reason: order.cancellation_reason as CancellationReason, - detail: order.cancellation_detail, - cancelledBy: order.cancellation_cancelled_by ?? "", - cancelledAt: order.cancellation_cancelled_at, - }, - }; -} diff --git a/packages/store-postgres/src/kysely-payment-event-store.ts b/packages/store-postgres/src/kysely-payment-event-store.ts deleted file mode 100644 index 165c7ed2..00000000 --- a/packages/store-postgres/src/kysely-payment-event-store.ts +++ /dev/null @@ -1,81 +0,0 @@ -import type { - IdGen, - OrderId, - PaymentEventStore, - PaymentMethod, - RecordAnomalyInput, -} from "@otta-sh/domain"; -import type { Kysely } from "kysely"; -import type { Database } from "./schema.js"; - -export interface KyselyPaymentEventStoreOptions { - db: Kysely; - idGen: IdGen; -} - -/** - * `PaymentEventStore` over Kysely (§5). Dedupe is - * `INSERT … ON CONFLICT (dedupe_key) DO NOTHING RETURNING` — a returned row is the - * FIRST delivery; a conflict (no row) is a redelivery no-op. Anomalies are - * separate rows (null `dedupe_key`, set `kind`/`detail`) — durably recorded, - * never swallowed. - */ -export class KyselyPaymentEventStore implements PaymentEventStore { - readonly #db: Kysely; - readonly #idGen: IdGen; - - constructor(options: KyselyPaymentEventStoreOptions) { - this.#db = options.db; - this.#idGen = options.idGen; - } - - async dedupe( - dedupeKey: string, - orderId: OrderId, - gateway: PaymentMethod, - now: string, - ): Promise { - const inserted = await this.#db - .insertInto("payment_events") - .values({ - id: this.#idGen.newId(), - dedupe_key: dedupeKey, - order_id: orderId, - gateway, - kind: null, - detail: null, - received_at: now, - }) - .onConflict((oc) => oc.column("dedupe_key").doNothing()) - .returning("id") - .executeTakeFirst(); - return inserted !== undefined; - } - - /** The order a recorded `dedupe_key` names. Only the event rows have a - * `dedupe_key` (anomalies write `null`), and it is UNIQUE, so this matches at - * most one row. Asked only on the duplicate arm of `settleOrder`. */ - async orderForDedupeKey(dedupeKey: string): Promise { - const row = await this.#db - .selectFrom("payment_events") - .select("order_id") - .where("dedupe_key", "=", dedupeKey) - .executeTakeFirst(); - return row === undefined ? null : (row.order_id as OrderId); - } - - async recordAnomaly(input: RecordAnomalyInput): Promise { - await this.#db - .insertInto("payment_events") - .values({ - id: this.#idGen.newId(), - dedupe_key: null, - order_id: input.orderId, - gateway: input.gateway, - kind: input.kind, - detail: input.detail, - received_at: input.now, - }) - .execute(); - } -} diff --git a/packages/store-postgres/src/kysely-product-commerce-store.ts b/packages/store-postgres/src/kysely-product-commerce-store.ts deleted file mode 100644 index d921f532..00000000 --- a/packages/store-postgres/src/kysely-product-commerce-store.ts +++ /dev/null @@ -1,2052 +0,0 @@ -import { - cents, - currency, - idempotencyKey as toIdempotencyKey, - InvalidLowStockThresholdError, - isValidLowStockThreshold, - MissingProductIdError, - MissingVariantKeyError, - money, - productId as toProductId, - sku as toSku, - SkuConflictError, - SkuHeldStockError, - SkuStockConflictError, - type Clock, - type IdempotencyKey, - type InventoryPolicy, - type ProductCommerce, - type ProductCommerceStore, - type ProductCommerceView, - type ProductId, - type ProductKind, - type ProductListFilter, - type ProductCommerceUpdateResult, - type ProductListPage, - type ProductListResult, - type ProductSummary, - type ProductVariant, - type ProductVariantSummary, - type ProductVariantUpdateResult, - type UpdateProductCommerceFieldsInput, - type UpdateProductVariantFieldsInput, - type UpsertProductCommerceInput, - type UpsertProductVariantInput, -} from "@otta-sh/domain"; -import { - type Expression, - expressionBuilder, - type ExpressionBuilder, - type Kysely, - sql, - type SqlBool, -} from "kysely"; -import type { Database, ProductCommerceTable, ProductVariantsTable } from "./schema.js"; - -export interface KyselyProductCommerceStoreOptions { - db: Kysely; - clock: Clock; -} - -/** - * `ProductCommerceStore` over Kysely (Phase 1 step 4), dialect-agnostic - * across better-sqlite3 and pg. - * - * `upsert` is ONE conditional statement WHEN THE INPUT CARRIES NO `sku` — the - * shape it has always had, and still the shape of every CMS-sync save (the - * sync writes title and watermark only). An input that DOES carry a sku may be - * a rename, and a rename is a stock movement that has to commit with the row, - * so that path opens a transaction and runs THE SKU-RENAME RULE inside it (see - * the port doc, and `#carrySkuStock` below). The conditional statement itself - * is unchanged in either case: an - * `INSERT … ON CONFLICT (product_id) DO UPDATE … WHERE ` with two - * guards ANDed together: - * 1. replay dedupe — `product_commerce.idempotency_key != :key` (per-row - * compare-on-write, plan §4 — deliberately NOT a global unique - * constraint); - * 2. sync ordering (review S1) — `excluded.content_updated_at IS NULL OR - * product_commerce.content_updated_at IS NULL OR - * excluded.content_updated_at >= product_commerce.content_updated_at`: - * a sync carrying a STRICTLY OLDER content watermark than the stored one - * is a stale no-op (out-of-order hook delivery converges); panel saves - * (no watermark ⇒ excluded is NULL) always pass — explicit merchant - * intent is last-writer-wins, the documented lost-update semantics. - * ISO-8601 text compares lexicographically = chronologically, identically on - * both dialects. - * - * Fields omitted from the input (`undefined`) resolve to the EXISTING column - * via the `product_commerce.` reference in the SET list rather than - * `excluded.`, so a partial upsert never clobbers fields it didn't - * touch. When a WHERE guard makes the statement a no-op (same-key replay or - * stale sync), `RETURNING` yields no row, and the current row is re-read - * with one follow-up `SELECT`. - * - * CONCURRENCY, on the sku-bearing path only: the rename carry moves real units, - * so it IS a race target, and `test/sku-rename-race.pg.test.ts` covers it on - * Postgres (concurrent renames through both writers, a rename against the seed - * that can contend its claim, and a rename against a restock of the sku it is - * leaving). The no-sku path is unchanged and remains none of that. - * - * Live-sku uniqueness is the migration's partial unique index - * (`UNIQUE (sku) WHERE deleted_at IS NULL`, review S3) — the `ON CONFLICT` - * target stays the `product_id` PK, so the partial index never arbitrates - * the upsert; a genuinely conflicting live sku surfaces as a constraint - * error on both dialects, and a soft-deleted row's sku is reusable. - * - * ─── THE LOCK ORDER ──────────────────────────────────────────────────────── - * - * The VARIANT writers take row locks in one order, and this is the only place it - * is written down: - * - * product_commerce → inventory, IN SKU ORDER → product_variants - * - * Skipping a stage is always allowed; taking one out of order is not. - * - * WHAT IS ACTUALLY PROVED, stated as the obligation rather than as a slogan, - * because "one total order" is stronger than what holds here: - * 1. Every variant writer that will APPLY takes the parent's lock first — except - * the single-lock writer in (2), which needs no ordering to be safe. Two - * writers under one product therefore never interleave at all, which makes - * every intra-product cycle unreachable rather than merely ordered — including - * the one inside `product_variants_live_sku_unique`. - * 2. Writers that take at most ONE lock cannot participate in a cycle, since a - * cycle needs someone waiting while holding. `deactivateVariant` is a single - * conditional UPDATE and is in that class. - * 3. Across DIFFERENT parents, the only shared resources are `inventory` rows, - * and those are taken in sorted sku order by every writer that takes two. - * 4. The `couldApply`-FALSE branch of `updateVariantFields` skips the parent lock - * and so is outside (1). It is cycle-free only because such an edit never - * applies, and THAT rests on the monotonic-clock assumption named on - * `updateVariantFields` — the same assumption its guard 4b already rests on. - * If that assumption is ever false, this branch is the second thing to fix. - * 5. `#applyVariantDeclare` is the one deliberate inversion: it cannot know which - * sku to lock until it has read the row, so it takes the parent, then the - * variant row, then at most ONE `inventory` row. It never holds two stock rows, - * and every writer that could wait on its variant row holds the parent it - * already owns — so it closes no cycle. Raced directly against a price edit of - * the SAME variant in the variant race suite. - * - * WHY THIS ORDER, rather than any other: - * - `product_commerce` FIRST because it is the aggregate root — a product's - * currency is a fact about the product, so both the product's own repricing - * and any variant's pricing have to agree under one lock. - * - `inventory` before `product_variants`, which is the opposite of what the - * reading order suggests and is the whole lesson of this class. A variant's - * guarded UPDATE looks like the decision that precedes the movement, but - * writing a sku also takes an entry in `product_variants_live_sku_unique`, - * and two writers crossing skus each end up waiting on the other's - * uncommitted index entry — a cycle formed inside the index, before either - * has touched a stock row. - * - `inventory` rows IN SKU ORDER — sorted, never by role. A carry touches two - * of them, and which is "source" and which is "target" belongs to the caller - * rather than to the rows: two crossing renames, X→Y and Y→X, disagree about - * role order on the same pair, so ordering by role is ordering by nothing and - * they deadlock (measured: `40P01` around one loop in 250). Sorting gives every - * writer one agreed order over any pair. - * - * KNOWN, PRE-EXISTING, AND NOT ADDRESSED HERE: the PRODUCT-side writers have the - * same exposure this order closes for variants, and it predates variants. `upsert` - * and `#applyCommerceFields` write `sku` in their guarded statement, which takes - * an entry in `product_commerce_live_sku_unique`, BEFORE any `inventory` lock — so - * two products renaming onto each other's skus can deadlock inside that index - * exactly as two variants could. Restructuring them is deliberately out of scope - * for the change that introduced variants: it touches the two writers the whole - * catalog runs through, and it deserves its own change and its own race. Recorded - * so the next reader finds a known follow-up rather than an oversight. - * - * WHY IT IS WRITTEN DOWN RATHER THAN INFERRED. Every lock here is a portable - * self-assignment `UPDATE` (`FOR UPDATE` is not SQLite), so the locks are - * invisible at the call sites that need them and are easy to add in the wrong - * place. A violation does not fail a unit test: better-sqlite3 serializes every - * writer onto one connection and cannot deadlock at all, so an inversion is - * green on the fast tier and surfaces only on Postgres, as an unmapped `40P01` - * reaching a merchant instead of the typed refusal this port documents. The - * race suites (`test/sku-rename-race.pg.test.ts`, - * `test/variant-sku-rename-race.pg.test.ts`) are where that is caught, and each - * inversion this order rules out has a case named after it. - */ -export class KyselyProductCommerceStore implements ProductCommerceStore { - readonly #db: Kysely; - readonly #clock: Clock; - - constructor(options: KyselyProductCommerceStoreOptions) { - this.#db = options.db; - this.#clock = options.clock; - } - - async upsert(input: UpsertProductCommerceInput, key: IdempotencyKey): Promise { - if (typeof input.productId !== "string" || input.productId.length === 0) { - throw new MissingProductIdError(); - } - // No sku in play ⇒ no rename is possible ⇒ the statement stays exactly - // what it was, on the plain connection. Only a write that could move the - // sku pays for a transaction (the CMS sync, which is every upsert on the - // hot path, never carries one). - if (input.sku === undefined) return this.#applyUpsert(this.#db, input, key, null); - return this.#db.transaction().execute(async (trx) => { - // The row's sku BEFORE the write — half of THE SKU-RENAME RULE's input, - // read THROUGH THE ROW LOCK rather than with a plain SELECT. - // - // This is load-bearing, and a plain SELECT here is a silent-stranding - // bug. `upsert` has no compare-and-set: under READ COMMITTED a peer that - // renames the same product between the read and the `ON CONFLICT DO - // UPDATE` is simply waited for and then written over, so the carry would - // run against a sku that is no longer the row's. Concretely — this tx - // reads A, a peer commits A→B (units follow to B), this tx then applies - // sku=C and carries A(now empty)→C, leaving the units orphaned under B - // with no error raised: exactly the loss this rule exists to prevent. - // Taking the lock on the read makes the peer's rename either entirely - // before us (so we read B and carry B→C) or entirely after. - // - // A self-assignment UPDATE is how you take that lock portably — - // `FOR UPDATE` is not SQLite. Assigning `product_id` to itself touches - // no observable column, and in particular leaves `updated_at` alone, so - // the replay and watermark guards below still see exactly what they did. - const before = await trx - .updateTable("product_commerce") - .set((eb) => ({ product_id: eb.ref("product_id") })) - .where("product_id", "=", input.productId) - .returning("sku") - .executeTakeFirst(); - return this.#applyUpsert(trx, input, key, before?.sku ?? null); - }); - } - - /** - * `upsert`'s statement, on whichever executor the caller opened (the plain - * connection, or the transaction a sku change needs). `beforeSku` is the - * row's sku as it stood before this write, or null when there was no row / - * no sku; the carry compares it against the RESOLVED row, so the upsert's - * own no-op branches (same-key replay, stale watermark) move nothing without - * needing to know they were no-ops. - */ - async #applyUpsert( - exec: Kysely, - input: UpsertProductCommerceInput, - key: IdempotencyKey, - beforeSku: string | null, - ): Promise { - const now = this.#clock.now().toISOString(); - const hasSku = input.sku !== undefined; - const hasPrice = input.price !== undefined; - const hasTitle = input.title !== undefined; - const hasTaxClass = input.taxClass !== undefined; - const hasWeightGrams = input.weightGrams !== undefined; - const hasLengthMm = input.lengthMm !== undefined; - const hasWidthMm = input.widthMm !== undefined; - const hasHeightMm = input.heightMm !== undefined; - const hasProductKind = input.productKind !== undefined; - const hasContentUpdatedAt = input.contentUpdatedAt !== undefined; - - let row: ProductCommerceTable | undefined; - try { - row = await exec - .insertInto("product_commerce") - .values({ - product_id: input.productId, - sku: input.sku ?? null, - price_cents: input.price?.amount ?? null, - price_currency: input.price?.currency ?? null, - title: input.title ?? null, - tax_class: input.taxClass ?? null, - // compare-at / cost / inventory-policy are EDIT-ONLY (never a - // CMS-sync upsert field) — a NEW row starts at defaults, and a later - // upsert PRESERVES them by omitting them from the DO UPDATE SET below. - compare_at_cents: null, - compare_at_currency: null, - unit_cost_cents: null, - unit_cost_currency: null, - inventory_policy: "deny", - weight_grams: input.weightGrams ?? null, - length_mm: input.lengthMm ?? null, - width_mm: input.widthMm ?? null, - height_mm: input.heightMm ?? null, - product_kind: input.productKind ?? "physical", - active: 0, - deleted_at: null, - idempotency_key: key, - content_updated_at: input.contentUpdatedAt ?? null, - active_updated_at: null, - created_at: now, - updated_at: now, - }) - .onConflict((oc) => - oc - .column("product_id") - .doUpdateSet((eb) => ({ - sku: hasSku ? eb.ref("excluded.sku") : eb.ref("product_commerce.sku"), - price_cents: hasPrice - ? eb.ref("excluded.price_cents") - : eb.ref("product_commerce.price_cents"), - price_currency: hasPrice - ? eb.ref("excluded.price_currency") - : eb.ref("product_commerce.price_currency"), - title: hasTitle ? eb.ref("excluded.title") : eb.ref("product_commerce.title"), - tax_class: hasTaxClass - ? eb.ref("excluded.tax_class") - : eb.ref("product_commerce.tax_class"), - weight_grams: hasWeightGrams - ? eb.ref("excluded.weight_grams") - : eb.ref("product_commerce.weight_grams"), - length_mm: hasLengthMm - ? eb.ref("excluded.length_mm") - : eb.ref("product_commerce.length_mm"), - width_mm: hasWidthMm - ? eb.ref("excluded.width_mm") - : eb.ref("product_commerce.width_mm"), - height_mm: hasHeightMm - ? eb.ref("excluded.height_mm") - : eb.ref("product_commerce.height_mm"), - product_kind: hasProductKind - ? eb.ref("excluded.product_kind") - : eb.ref("product_commerce.product_kind"), - idempotency_key: eb.ref("excluded.idempotency_key"), - content_updated_at: hasContentUpdatedAt - ? eb.ref("excluded.content_updated_at") - : eb.ref("product_commerce.content_updated_at"), - updated_at: eb.ref("excluded.updated_at"), - })) - // Guard 1: same-key replay is a no-op. - .where("product_commerce.idempotency_key", "!=", key) - // Guard 2 (review S1): a strictly-older sync watermark is a stale - // no-op; NULL on either side (panel save / never-synced row) - // passes. Raw SQL for the excluded-vs-row comparison — portable - // text comparison on both dialects. - .where( - sql`(excluded.content_updated_at is null or product_commerce.content_updated_at is null or excluded.content_updated_at >= product_commerce.content_updated_at)`, - ), - ) - .returningAll() - .executeTakeFirst(); - } catch (err) { - // Review F2: surface a LIVE-sku uniqueness conflict (the partial - // index) as the structured domain error, never an opaque 500. The - // match is narrowly scoped to THIS constraint (mirroring Phase 0's - // FK catch) — any other violation still propagates untouched. - if (input.sku !== undefined && isLiveSkuUniqueViolation(err)) { - throw new SkuConflictError(input.sku); - } - throw err; - } - - const resolved = row ?? (await this.#selectByProductId(input.productId, exec)); - if (resolved === undefined) { - throw new Error(`product_commerce upsert lost its row for product_id ${input.productId}`); - } - const renaming = beforeSku !== null && resolved.sku !== null && resolved.sku !== beforeSku; - // The RECIPROCAL half of "a sku names one live sellable unit" (port doc): - // the partial index covers product↔product, and this covers product↔variant, - // which no index can. `row !== undefined` is the "the statement applied" - // witness, so a same-key replay or a stale-watermark no-op refuses nothing — - // the same position the index occupies. Inside the sku-bearing path's own - // transaction, so the throw rolls the write back exactly as the index would. - // - // BEFORE THE CARRY, and this is a PRECEDENCE decision rather than a locking - // one (locks are acquired below and above in the class's order regardless; - // LOCKS ARE NOT CHECKS). Both refusals can apply to one rename — a target sku - // that another live unit holds will, in production-normal state, also have an - // `inventory` row, because every applied assignment seeds one. Whichever runs - // first decides what the operator is told, so the order is fixed rather than - // incidental: "that sku names another sellable unit" is the actionable truth, - // and "units are parked under that sku" would be a misleading description of - // the same state. `SkuConflictError` therefore outranks - // `SkuStockConflictError` whenever a live unit holds the target. - if (row !== undefined && input.sku !== undefined) { - const pair = renaming && beforeSku !== null ? [beforeSku, input.sku].toSorted() : [input.sku]; - for (const s of pair) await this.#lockSkuRowIfPresent(exec, s); - if (await this.#skuTakenByLiveVariant(exec, input.sku)) { - throw new SkuConflictError(input.sku); - } - } - // THE SKU-RENAME RULE (port doc), against the row's before/after values — so - // a same-key replay and a stale-watermark no-op, which both re-read and return - // the STORED row, compare equal here and move nothing. The carry re-acquires - // the same sorted pair, which is a no-op now that this path holds it. - if (renaming && resolved.sku !== null) { - await this.#carrySkuStock(exec, beforeSku as string, resolved.sku, key); - } - return toDomain(resolved); - } - - /** - * THE SKU-RENAME RULE's inventory half (port doc on `ProductCommerceStore`), - * on the SAME executor as the product-row write so the rename and the stock - * movement commit or roll back together. Portable across both dialects: - * - * 1. LOCK AND READ the source — a self-assignment `UPDATE … SET on_hand = - * on_hand … RETURNING on_hand`. Reading through an UPDATE rather than a - * SELECT takes the row's write lock for the rest of the transaction - * (portably — `FOR UPDATE` is not SQLite), so the count read here cannot - * move under a concurrent reserve/restock/removal before step 4 zeroes - * it, which is exactly how units would go missing. Taking it FIRST also - * serializes this whole carry against `reserve`, whose guarded decrement - * needs the same lock — so the hold check below cannot be outrun by a - * reservation landing a moment later. No row ⇒ nothing to carry. - * 2. REFUSE on live holds — `SkuHeldStockError` when any `held`/`adopted` - * reservation still names the source. Their units are already out of - * `on_hand` and the hold cannot follow the rename, so there is nothing - * honest to move; see the port doc for what leaks if this is skipped. - * 3. CLAIM the target — `INSERT … ON CONFLICT (sku) DO NOTHING RETURNING`. - * The insert IS the occupancy test, which is what makes it safe under - * concurrency: two creators of one free target cannot both see it free, - * because the second conflicts with the first's uncommitted row (the - * other creator being `seedOnHand`, which every product save attempts — - * pinned in `test/sku-rename-race.pg.test.ts`). Zero rows back ⇒ the sku - * already has an inventory row ⇒ refuse. The test never looks at what - * that row HOLDS, so a row at 0 refuses exactly like a stocked one. - * 4. MOVE — the count onto the target, then zero the source, then record the - * pair in the stock-movement ledger. The source row is RETAINED: - * `reservations.sku` references `inventory.sku`, so a sku that has ever - * been reserved can be neither deleted nor re-keyed, and a stock row is - * never deleted regardless. - * - * `commandKey` is the idempotency key of the product write this carry belongs - * to, and the audit rows in step 4 derive their own keys from it. It is not a - * uniqueness guarantee — it is client-supplied — so see `#recordCarry` for - * what those keys do and do not promise, and why a collision there is - * survivable. - */ - async #carrySkuStock( - exec: Kysely, - sourceSku: string, - targetSku: string, - commandKey: IdempotencyKey, - ): Promise { - if (sourceSku === targetSku) return; - - // THE PAIR IS ACQUIRED AS A PAIR, IN SKU ORDER — never one role and then the - // other, which is what deadlocked before this loop existed. - // - // A carry touches two `inventory` rows, and which of them is "source" and - // which is "target" is a property of the CALLER, not of the rows. Two - // crossing renames — X→Y and Y→X — therefore disagree about role order on - // the same two rows, so ordering by role is ordering by nothing: each locks - // its own source and then waits on the other's. It deadlocks even though the - // claim is an `ON CONFLICT DO NOTHING` that looks lock-free, because a - // speculative insert must wait on a conflicting tuple another transaction has - // updated — and the peer's source lock is exactly such an update. Measured: - // this is a `40P01` roughly one loop in 250, i.e. rare enough to survive - // review and frequent enough to reach a merchant. - // - // Sorting the two skus gives every carry in the system ONE agreed order over - // any pair, which is the textbook resolution and the only one that does not - // depend on who called. Rows that do not exist yet lock nothing here; the - // claim below is still what arbitrates those, via the speculative-insert - // conflict. - for (const s of [sourceSku, targetSku].toSorted()) { - await this.#lockSkuRowIfPresent(exec, s); - } - - const source = await exec - .updateTable("inventory") - .set((eb) => ({ on_hand: eb.ref("on_hand") })) - .where("sku", "=", sourceSku) - .returning("on_hand") - .executeTakeFirst(); - - const held = await exec - .selectFrom("reservations") - .select((eb) => eb.fn.countAll().as("n")) - .where("sku", "=", sourceSku) - .where("state", "in", ["held", "adopted"]) - .executeTakeFirst(); - const liveHolds = Number(held?.n ?? 0); - if (liveHolds > 0) throw new SkuHeldStockError(sourceSku, liveHolds); - - const claimed = await exec - .insertInto("inventory") - .values({ sku: targetSku, on_hand: 0 }) - .onConflict((oc) => oc.column("sku").doNothing()) - .returning("sku") - .executeTakeFirst(); - if (claimed === undefined) throw new SkuStockConflictError(sourceSku, targetSku); - - if (source === undefined || source.on_hand === 0) return; - - await exec - .updateTable("inventory") - .set({ on_hand: source.on_hand }) - .where("sku", "=", targetSku) - .execute(); - await exec.updateTable("inventory").set({ on_hand: 0 }).where("sku", "=", sourceSku).execute(); - await this.#recordCarry(exec, sourceSku, targetSku, source.on_hand, commandKey); - } - - /** - * The carry's AUDIT TRAIL: one row out of the source and one into the target - * in `inventory_stock_movements`, written inside the carry's own transaction - * so a move can never be durable without its record. - * - * Every other `on_hand` mutation an operator can trigger already lands in - * this ledger (`restock`, `removeStock`); without these two rows a rename - * would be the one way to move forty units and leave nothing behind - * explaining where they went. - * - * `rename_out` / `rename_in` are their own directions rather than a reused - * `removal` + `restock` pair, so the ledger does not claim a merchant - * counted anything: the column is plain text and needs no migration, and - * `InventoryStore`'s own paths keep their narrowed `"restock" | "removal"` - * signatures, so nothing can mistake a carry row for a replayable movement. - * The keys are derived from the command's key, which is unique per command - * and never reaches here twice (a replay applies no update, so it never - * carries). `qty > 0` is a column CHECK, which is why this is called only - * when units actually moved — a rename that carries NOTHING (an empty or - * absent source row) writes no ledger entry at all, there being no movement - * to record. - * - * WRITE-ONLY TODAY. Nothing reads these rows yet: no admin screen, report or - * endpoint surfaces stock movements, and `InventoryStore`'s own ledger reads - * are per-key replay lookups that can never match a `rename_*` key. The trail - * exists so the history is already there when something does surface it, and - * so a rename stops being the one stock movement that leaves no record; a - * movements view is a separate change. - */ - async #recordCarry( - exec: Kysely, - sourceSku: string, - targetSku: string, - qty: number, - commandKey: IdempotencyKey, - ): Promise { - const at = this.#clock.now().toISOString(); - await exec - .insertInto("inventory_stock_movements") - .values([ - { - idempotency_key: `${commandKey}:sku-rename:out:${sourceSku}`, - sku: sourceSku, - direction: "rename_out", - qty, - outcome: "ok", - result_on_hand: 0, - created_at: at, - }, - { - idempotency_key: `${commandKey}:sku-rename:in:${targetSku}`, - sku: targetSku, - direction: "rename_in", - qty, - outcome: "ok", - result_on_hand: qty, - created_at: at, - }, - ]) - // THE AUDIT ROW MUST NEVER FAIL THE MOVE IT DESCRIBES. These keys derive - // from `commandKey`, which is client-supplied (the wire's - // `Idempotency-Key`), so this primary key is not ours to guarantee: a - // client reusing one key across two renames of the SAME source sku, or a - // caller crafting a `restock` key that happens to equal one of these, - // would otherwise abort a perfectly legal rename with a raw unique - // violation. DO NOTHING makes that pathological case cost the audit row - // rather than the merchant's rename. Pinned in - // `test/sku-rename-ledger.dialects.test.ts`. - .onConflict((oc) => oc.doNothing()) - .execute(); - } - - async getByProductId(productId: ProductId): Promise { - const row = await this.#selectByProductId(productId); - return row === undefined ? null : toDomain(row); - } - - /** - * Bulk snapshot read (port doc): the batch companion to `getByProductId`, - * ONE `SELECT … WHERE product_id IN (:ids)` so the two checkout paths fetch - * every cart line's projection in a single round trip instead of one per - * line (the per-cart-line N+1 this method kills). - * - * The RAW row read — `selectAll()`, NO inventory join, NO deleted_at / sku / - * price guards (identical row semantics to `getByProductId`, deliberately - * NOT `listCommerceByIds`): each row goes through the same `toDomain`, which - * reads only `product_commerce` columns, so no join is needed. Missing ids - * are simply absent from the Map; `IN` collapses duplicates (one row per - * PK); no ORDER BY. The empty id list short-circuits without touching the DB - * (`IN ()` is not SQL). - */ - async getManyByProductId(productIds: ProductId[]): Promise> { - if (productIds.length === 0) return new Map(); - const rows = await this.#db - .selectFrom("product_commerce") - .selectAll() - .where("product_commerce.product_id", "in", productIds) - .execute(); - - const result = new Map(); - for (const row of rows) { - result.set(toProductId(row.product_id), toDomain(row)); - } - return result; - } - - /** - * Batch catalog read (Phase 2 §6/§7 step 2): ONE statement — - * `product_commerce LEFT JOIN inventory ON inventory.sku = - * product_commerce.sku WHERE product_id IN (:ids) AND ` — identical on both dialects; no interactive transaction (a - * read, but the single-statement discipline holds). - * - * INVARIANT (do not weaken — see the port doc): `inStock` (`on_hand > 0`, - * LEFT JOIN so a missing inventory row reads as out-of-stock, never a - * dropped product) is computed HERE, in the same statement — never split - * into a separate inventory query. Pinned by the query-count test in - * `test/product-commerce-batch.dialects.test.ts`. - * - * Missing/soft-deleted/commerce-incomplete ids are simply absent from the - * result; `IN` collapses duplicates; no ORDER BY (no guaranteed order). - * Inactive rows are RETURNED with `active: false` — the purchasability - * gate is the plugin's `joinProduct`, not the store (port doc). The empty - * id list short-circuits without touching the DB (`IN ()` is not SQL). - */ - async listCommerceByIds(productIds: ProductId[]): Promise { - if (productIds.length === 0) return []; - const rows = await this.#db - .selectFrom("product_commerce") - .leftJoin("inventory", "inventory.sku", "product_commerce.sku") - .select([ - "product_commerce.product_id", - "product_commerce.sku", - "product_commerce.price_cents", - "product_commerce.price_currency", - "product_commerce.active", - "inventory.on_hand", - ]) - .where("product_commerce.product_id", "in", productIds) - .where("product_commerce.deleted_at", "is", null) - .where("product_commerce.sku", "is not", null) - .where("product_commerce.price_cents", "is not", null) - .where("product_commerce.price_currency", "is not", null) - .execute(); - - return rows.map((row) => { - // The WHERE guards make these non-null; the narrowing is for the - // type system, with a loud failure if the query ever drifts. - if (row.sku === null || row.price_cents === null || row.price_currency === null) { - throw new Error( - `listCommerceByIds returned a commerce-incomplete row for product_id ${row.product_id}`, - ); - } - return { - productId: toProductId(row.product_id), - sku: toSku(row.sku), - price: money(cents(row.price_cents), currency(row.price_currency)), - inStock: (row.on_hand ?? 0) > 0, - active: row.active === 1, - }; - }); - } - - async softDelete(productId: ProductId, key: IdempotencyKey): Promise { - const now = this.#clock.now().toISOString(); - await this.#db - .updateTable("product_commerce") - .set({ active: 0, deleted_at: now, idempotency_key: key, updated_at: now }) - .where("product_id", "=", productId) - .where("deleted_at", "is", null) - .execute(); - } - - /** - * Guarded admin edit (port doc): a conditional `UPDATE` under an optimistic - * compare-and-set — the atomic mirror of the fake's guard chain. It is the - * WHOLE statement list only when the input carries no `sku`; an edit that - * could rename runs in a transaction alongside THE SKU-RENAME RULE's stock - * movement, exactly as `upsert` does (see the class doc). - * The applying statement ANDs the guards: `product_id = :id`, `deleted_at IS - * NULL`, `updated_at = :expectedUpdatedAt` (the CAS), `idempotency_key != - * :key` (replay dedupe), and — only when a price is supplied — a currency- - * integrity guard (`price_currency IS NULL OR price_currency = :cur`). Fields - * omitted from `input` are absent from the SET clause, so they are preserved - * (the plain-UPDATE analogue of `upsert`'s excluded-vs-row SET). - * - * When the UPDATE applies, `RETURNING` yields the row → `ok`. When it matches - * ZERO rows (some guard failed), a follow-up `SELECT` classifies the no-op in - * the SAME order the fake does — not_found (missing / soft-deleted) FIRST, - * then replay (stored key == key), then stale (updatedAt moved), then - * currency_mismatch (see the port doc: not_found outranks replay, so a - * same-key replay after a soft delete is not_found on every adapter) — - * mirroring `upsert`'s no-op-then-reread pattern. A lost concurrent edit - * surfaces deterministically as `stale`, never a torn write. - * - * The FIELD edit is still not an oversell-style race target; the rename that - * may ride along with it IS, and is covered on Postgres by - * `test/sku-rename-race.pg.test.ts`. Unlike `upsert`, the before-read this - * path feeds the carry needs no lock of its own: the CAS already collapses an - * interleaved write into `stale`, so a carry can only run against the sku the - * applying statement matched. Live-sku collisions surface as - * `SkuConflictError`, exactly like `upsert`. - */ - async updateCommerceFields( - input: UpdateProductCommerceFieldsInput, - key: IdempotencyKey, - expectedUpdatedAt: string, - ): Promise { - // As in `upsert`: neither a sku (which may rename) nor a price (whose - // currency the live variants get a say in) ⇒ the edit stays the single - // statement it has always been, on the plain connection. - if (input.sku === undefined && input.price === undefined) { - return this.#applyCommerceFields(this.#db, input, key, expectedUpdatedAt, null, []); - } - return this.#db.transaction().execute(async (trx) => { - // Clause 4c reads `product_variants`, and the variant path reaches that - // table only AFTER locking this same parent row. So this path takes the - // parent lock FIRST — stage one of the class lock order — and only then - // reads. Reading before the lock is the inversion that lets a product - // repricing and a variant pricing each see the other's "before" state and - // both apply; it is not merely a weaker guard, it is no guard at all. - // - // Taken only when a price is in play: a sku-only edit consults no variant - // currencies, so it has nothing to serialize against and keeps the shape it - // has always had. The lock is a self-assignment UPDATE that touches no - // observable column, so the guarded UPDATE below still sees exactly what it - // did — in particular `updated_at` is untouched, so the CAS is unaffected. - if (input.price !== undefined) { - await trx - .updateTable("product_commerce") - .set((eb) => ({ product_id: eb.ref("product_id") })) - .where("product_id", "=", input.productId) - .execute(); - } - const before = await trx - .selectFrom("product_commerce") - .select("sku") - .where("product_id", "=", input.productId) - .executeTakeFirst(); - const variantCurrencies = - input.price === undefined ? [] : await this.#liveVariantCurrencies(trx, input.productId); - return this.#applyCommerceFields( - trx, - input, - key, - expectedUpdatedAt, - before?.sku ?? null, - variantCurrencies, - ); - }); - } - - /** - * `updateCommerceFields`'s statement and its zero-row classifier, on - * whichever executor the caller opened. `beforeSku` is the row's sku as it - * stood before this edit; the carry runs ONLY on the applying branch, which - * is what keeps every zero-row outcome — including the replay `ok` — free of - * stock movement. - * - * Reading `beforeSku` before the guarded UPDATE is safe for the same reason - * the edit itself is: the UPDATE only applies while `updated_at` still equals - * `expectedUpdatedAt`, and every writer advances it, so an interleaved write - * turns this into a `stale` no-op rather than a carry against a sku that has - * since moved. - */ - async #applyCommerceFields( - exec: Kysely, - input: UpdateProductCommerceFieldsInput, - key: IdempotencyKey, - expectedUpdatedAt: string, - beforeSku: string | null, - variantCurrencies: string[], - ): Promise { - const now = this.#clock.now().toISOString(); - // Clause 4c (port doc): a repricing that would leave a LIVE VARIANT of this - // product holding another currency. Checked in APP CODE and used to SUPPRESS - // the statement rather than short-circuit the method — the classifier below - // still runs, so a replay of a disagreeing edit still reports its replay `ok` - // and a stale one still reports `stale`, exactly as the guard order requires. - // Empty for an unvarianted product, so this can never fire on the catalog as - // it stands. - const variantCurrencyConflict = - input.price !== undefined && variantCurrencies.some((c) => c !== input.price?.currency); - const set: Record = { - idempotency_key: key, - updated_at: now, - }; - if (input.sku !== undefined) set.sku = input.sku; - if (input.price !== undefined) { - set.price_cents = input.price.amount; - set.price_currency = input.price.currency; - } - // No `title` branch: the edit input has no `title` field at all (ADR-0013 — - // the CMS content sync is its sole writer, through `upsert` above). - if (input.taxClass !== undefined) set.tax_class = input.taxClass; - if (input.compareAtPrice !== undefined) { - set.compare_at_cents = input.compareAtPrice === null ? null : input.compareAtPrice.amount; - set.compare_at_currency = - input.compareAtPrice === null ? null : input.compareAtPrice.currency; - } - if (input.unitCost !== undefined) { - set.unit_cost_cents = input.unitCost === null ? null : input.unitCost.amount; - set.unit_cost_currency = input.unitCost === null ? null : input.unitCost.currency; - } - if (input.inventoryPolicy !== undefined) set.inventory_policy = input.inventoryPolicy; - if (input.weightGrams !== undefined) set.weight_grams = input.weightGrams; - if (input.lengthMm !== undefined) set.length_mm = input.lengthMm; - if (input.widthMm !== undefined) set.width_mm = input.widthMm; - if (input.heightMm !== undefined) set.height_mm = input.heightMm; - if (input.productKind !== undefined) set.product_kind = input.productKind; - - let updated: ProductCommerceTable | undefined; - // The conflict SUPPRESSES the statement; the classifier below still runs, so - // guard order is preserved (see the note beside `variantCurrencyConflict`). - if (!variantCurrencyConflict) { - try { - let stmt = exec - .updateTable("product_commerce") - .set(set) - .where("product_id", "=", input.productId) - .where("deleted_at", "is", null) - .where("updated_at", "=", expectedUpdatedAt) - .where("idempotency_key", "!=", key); - if (input.price !== undefined) { - // Currency integrity: never silently switch an already-priced row's - // currency. NULL (first pricing) passes. - const cur = input.price.currency; - stmt = stmt.where(sql`(price_currency is null or price_currency = ${cur})`); - } else { - // compare-at / cost supplied WITHOUT a price in the same edit must EACH - // match the STORED price currency (the row currency) — BOTH fields are - // guarded INDEPENDENTLY, exactly like the fake's 4b loop (review of PR - // #70: a single either/or pick here let a "compare-at matches, cost - // doesn't" edit write a mixed-currency row). A NULL stored price - // currency FAILS the guard — compare-at / cost require a priced product. - // (The within-edit currency agreement, when a price IS present, is the - // use-case's `InvalidProductFieldError` concern, so this branch only - // runs when price is absent.) A cleared (null) field carries no - // currency and adds no guard. - for (const extra of [input.compareAtPrice, input.unitCost]) { - if (extra != null) { - const extraCur = extra.currency; - stmt = stmt.where( - sql`(price_currency is not null and price_currency = ${extraCur})`, - ); - } - } - } - updated = await stmt.returningAll().executeTakeFirst(); - } catch (err) { - if (input.sku !== undefined && isLiveSkuUniqueViolation(err)) { - throw new SkuConflictError(input.sku); - } - throw err; - } - } - - if (updated !== undefined) { - const renaming = beforeSku !== null && updated.sku !== null && updated.sku !== beforeSku; - // The RECIPROCAL of the variant writer's cross-table check (port doc): - // product↔product is the partial index, product↔variant is this. Only the - // applying branch reaches it, and the throw rolls back inside the - // sku-bearing path's own transaction. - // - // BEFORE THE CARRY — a PRECEDENCE decision, not a locking one. In - // production-normal state a sku another live unit holds ALSO has an - // `inventory` row (every applied assignment seeds one), so both refusals - // apply and whichever runs first is what the operator reads. - // `SkuConflictError` wins: "that sku names another sellable unit" is - // actionable, while "units are parked under that sku" describes the same - // state misleadingly. The sorted pair is acquired here so the check reads - // under the same locks the carry will re-acquire. - if (input.sku !== undefined) { - const pair = - renaming && beforeSku !== null ? [beforeSku, input.sku].toSorted() : [input.sku]; - for (const s of pair) await this.#lockSkuRowIfPresent(exec, s); - if (await this.#skuTakenByLiveVariant(exec, input.sku)) { - throw new SkuConflictError(input.sku); - } - } - // THE SKU-RENAME RULE (port doc) — the applying branch, and only it. - if (renaming && updated.sku !== null) { - await this.#carrySkuStock(exec, beforeSku as string, updated.sku, key); - } - return { ok: true, product: toDomain(updated) }; - } - - // Zero rows applied — classify the no-op from a fresh read, in the fake's - // guard order so fake/sqlite/pg agree byte-for-byte. - const current = await this.#selectByProductId(input.productId, exec); - if (current === undefined || current.deleted_at !== null) { - return { ok: false, reason: "not_found" }; - } - if (current.idempotency_key === key) { - return { ok: true, product: toDomain(current) }; // replay no-op. - } - if (current.updated_at !== expectedUpdatedAt) { - return { ok: false, reason: "stale", current: toDomain(current) }; - } - if ( - input.price !== undefined && - current.price_currency !== null && - current.price_currency !== input.price.currency - ) { - return { ok: false, reason: "currency_mismatch", current: toDomain(current) }; - } - // compare-at / cost supplied WITHOUT a price whose currency doesn't match the - // stored price currency (a null stored currency ⇒ mismatch — the product - // must be priced first). BOTH fields checked INDEPENDENTLY, mirroring the - // fake's 4b loop byte-for-byte. - if (input.price === undefined) { - for (const extra of [input.compareAtPrice, input.unitCost]) { - if (extra != null && current.price_currency !== extra.currency) { - return { ok: false, reason: "currency_mismatch", current: toDomain(current) }; - } - } - } - // 4c, LAST in the currency group so the pre-existing sub-axes keep reporting - // first and an unvarianted product's classification is byte-identical. - if (variantCurrencyConflict) { - return { ok: false, reason: "currency_mismatch", current: toDomain(current) }; - } - // No guard explains the no-op — the statement should have applied. Fail - // loudly rather than silently swallow a lost write. - throw new Error( - `updateCommerceFields matched zero rows but no guard explains it for product_id ${input.productId}`, - ); - } - - /** - * The afterPublish→activate follow-up (port doc): a single conditional - * `UPDATE`, mirroring `softDelete`'s shape. Guards ANDed together: - * - `deleted_at IS NULL` — the load-bearing invariant: a soft-deleted row - * is never resurrected by a publish. - * - `active = 0` — already-active is a stable no-op under replay (leaves - * `updated_at`/`idempotency_key` untouched). - * - the ORDERING guard `active_updated_at IS NULL OR active_updated_at <= - * :t` — a stale, out-of-order publish (a watermark strictly older than - * the one a newer `deactivate` already applied) is a no-op, so a delayed - * `activate` can never re-latch an unpublished product to purchasable. - * NULL (never transitioned) is `-infinity`, so the first flip wins. - * Mirrors `upsert`'s `content_updated_at` guard but over the SEPARATE - * `active_updated_at` column (a `content:afterSave` must not poison the - * gate). A winning flip ADVANCES `active_updated_at` to `:t` so the gate - * stays monotonic (EmDash's publish/unpublish both bump - * content.updatedAt). ISO-8601 text compares lexicographically = - * chronologically, identically on both dialects. - * An unknown `product_id` matches zero rows — also a no-op, no row minted. - */ - async activate( - productId: ProductId, - key: IdempotencyKey, - contentUpdatedAt: string, - ): Promise { - const now = this.#clock.now().toISOString(); - await this.#db - .updateTable("product_commerce") - .set({ - active: 1, - active_updated_at: contentUpdatedAt, - idempotency_key: key, - updated_at: now, - }) - .where("product_id", "=", productId) - .where("deleted_at", "is", null) - .where("active", "=", 0) - .where(sql`(active_updated_at is null or active_updated_at <= ${contentUpdatedAt})`) - .execute(); - } - - /** - * The afterUnpublish→deactivate follow-up (port doc): the exact mirror of - * `activate` — a single conditional `UPDATE`, guards ANDed together: - * - `deleted_at IS NULL` — a soft-deleted row's tombstone is left - * untouched (never resurrected, never re-stamped by an unpublish). - * - `active = 1` — already-inactive is a stable no-op under replay. - * - the ORDERING guard `active_updated_at IS NULL OR active_updated_at <= - * :t` — a stale, out-of-order unpublish is a no-op, so a delayed - * `deactivate` can never deactivate a row a newer `activate` has since - * re-published. A winning flip advances `active_updated_at` to `:t`. - * (See `activate` for the full watermark rationale.) - * An unknown `product_id` matches zero rows — also a no-op, no row minted. - * Flips ONLY the publish gate; `deleted_at` is never in the SET clause — - * deactivation is not a soft delete. - */ - async deactivate( - productId: ProductId, - key: IdempotencyKey, - contentUpdatedAt: string, - ): Promise { - const now = this.#clock.now().toISOString(); - await this.#db - .updateTable("product_commerce") - .set({ - active: 0, - active_updated_at: contentUpdatedAt, - idempotency_key: key, - updated_at: now, - }) - .where("product_id", "=", productId) - .where("deleted_at", "is", null) - .where("active", "=", 1) - .where(sql`(active_updated_at is null or active_updated_at <= ${contentUpdatedAt})`) - .execute(); - } - - /** - * Admin Products console list (view-only; port doc): ONE statement per page - * — `product_commerce` LEFT JOINed to `inventory` for the per-row `onHand`, - * never an N+1 of per-row stock reads. `inventory.sku` is that table's - * PRIMARY KEY, so the join matches at most one row and can never multiply - * the page; the LEFT half is what makes a missing inventory row surface as - * `onHand: null` ("unknown"), distinct from `0` ("out of stock"). A NULL - * `product_commerce.sku` simply never matches (SQL `NULL = …` is unknown), - * which lands on the same `null` — correct, and identical on both dialects. - * Measured (pg 16, 5,000 products / 3,997 inventory rows, page 25): p50 - * 0.43 → 0.58 ms, p95 0.61 → 0.91 ms; an N+1 was 2.60 ms p50. No index was - * added — the inner side is already `inventory`'s PK. - * - * Keyset pagination on `(created_at DESC, product_id DESC)`, byte-for-byte - * mirroring `listOrders`: fetch `limit + 1` to detect a next page, emit - * `nextCursor` from the last RETURNED row. `created_at` is fixed-width - * ISO-8601 text ⇒ lexical order IS chronological, so the raw text - * comparisons below are dialect-identical (no casts) across better-sqlite3 - * and pg. Always excludes soft-deleted rows (port doc). - */ - async listProducts(filter: ProductListFilter, page: ProductListPage): Promise { - assertValidLowStockThreshold(filter); - let q = this.#db - .selectFrom("product_commerce") - .leftJoin("inventory", "inventory.sku", "product_commerce.sku") - .select([ - "product_commerce.product_id as product_id", - "product_commerce.sku as sku", - "product_commerce.title as title", - "product_commerce.price_cents as price_cents", - "product_commerce.price_currency as price_currency", - "product_commerce.product_kind as product_kind", - "product_commerce.active as active", - "product_commerce.deleted_at as deleted_at", - "product_commerce.created_at as created_at", - "inventory.on_hand as on_hand", - ]) - // The tombstone axis (product lifecycle surfacing, port doc): the - // archive view (`filter.deleted: true`) flips this to `IS NOT NULL`; - // every other caller (the field omitted or `false`) keeps the - // ORIGINAL default — only live rows list. - .where("product_commerce.deleted_at", filter.deleted === true ? "is not" : "is", null); - - const conds = productFilterConditions(filter); - if (conds.length > 0) q = q.where((eb) => eb.and(conds)); - if (page.cursor !== undefined && page.cursor !== null) { - const cursor = page.cursor; - // (created_at < :c) OR (created_at = :c AND product_id < :cid) — - // everything strictly "after" the cursor position under - // `created_at DESC, product_id DESC`. - q = q.where((eb) => - eb.or([ - eb("product_commerce.created_at", "<", cursor.createdAt), - eb.and([ - eb("product_commerce.created_at", "=", cursor.createdAt), - eb("product_commerce.product_id", "<", cursor.productId), - ]), - ]), - ); - } - - const rows = await q - .orderBy("product_commerce.created_at", "desc") - .orderBy("product_commerce.product_id", "desc") - .limit(page.limit + 1) - .execute(); - - const hasMore = rows.length > page.limit; - const returned = hasMore ? rows.slice(0, page.limit) : rows; - const last = returned.at(-1); - const nextCursor = - hasMore && last !== undefined - ? { createdAt: last.created_at, productId: toProductId(last.product_id) } - : null; - - const products: ProductSummary[] = returned.map((r) => ({ - productId: toProductId(r.product_id), - sku: r.sku === null ? null : toSku(r.sku), - title: r.title, - price: - r.price_cents === null || r.price_currency === null - ? null - : money(cents(r.price_cents), currency(r.price_currency)), - productKind: r.product_kind as ProductKind, - active: r.active === 1, - // The LEFT JOIN miss IS the null — `?? null` would be a no-op here, - // and `?? 0` would be a BUG (it would invent "out of stock" for a - // product that has no inventory row at all). - onHand: r.on_hand, - deletedAt: r.deleted_at, - createdAt: r.created_at, - })); - return { products, nextCursor }; - } - - /** - * Count under the SAME predicate as `listProducts` (port doc) — including - * the tombstone axis, whose default (live rows only) is applied on the - * QUERY rather than inside `productFilterConditions`, so it is restated here - * exactly as the list states it. - * - * NO LEFT JOIN onto `inventory`: the list joins to fill a per-row stock - * column, and a count has no columns. Dropping it also keeps the count a - * single-table scan on the index the list already uses. - * - * MEASURED (pg 16, 5,000 products / 3,997 inventory rows, 60 runs), against - * the page read it accompanies — the route issues the two CONCURRENTLY: - * unfiltered count p50 1.26 ms / p95 2.27 ms · page(25) p50 39.3 ms - * active+kind+search count p50 4.27 ms / p95 5.86 ms · page(25) p50 26.9 ms - * The count is 3–16% of the read it rides along with and never its critical - * path, so it is NOT gated behind a flag or a first-page-only rule. The - * filtered figure is dominated by the same `lower(title) LIKE '%…%'` scan the - * LIST already pays for the identical predicate — a functional/trigram index - * would speed BOTH up and belongs to search, not to counting. No index was - * added for this method. (`countOrders` p50 1.00 ms and `countCoupons` p50 - * 0.80 ms at the same row count, against 1.14 / 0.60 ms page reads.) - */ - async countProducts(filter: ProductListFilter): Promise { - assertValidLowStockThreshold(filter); - let q = this.#db - .selectFrom("product_commerce") - // Joined back CONDITIONALLY — only `filter.lowStockThreshold`'s - // predicate needs `inventory.on_hand`; every other axis keeps the - // join-free plan this method was measured against (port doc). - .$if(filter.lowStockThreshold !== undefined, (qb) => - qb.leftJoin("inventory", "inventory.sku", "product_commerce.sku"), - ) - .select(sql`count(*)`.as("n")) - .where("product_commerce.deleted_at", filter.deleted === true ? "is not" : "is", null); - const conds = productFilterConditions(filter); - if (conds.length > 0) q = q.where((eb) => eb.and(conds)); - const row = await q.executeTakeFirstOrThrow(); - return Number(row.n); - } - - /** Count LIVE products referencing a tax class (port doc) — the product half - * of the `deleteTaxClass` delete-in-use guard. One aggregate `SELECT - * COUNT(*)`; excludes soft-deleted rows (a tombstone's tax reference is - * historical, not a live dependency). */ - async countByTaxClass(taxClassId: string): Promise { - const row = await this.#db - .selectFrom("product_commerce") - .select((eb) => eb.fn.countAll().as("n")) - .where("tax_class", "=", taxClassId) - .where("deleted_at", "is", null) - .executeTakeFirst(); - return Number(row?.n ?? 0); - } - - // -- Variants: one commerce row per sellable unit -------------------------- - - /** - * The CMS-sync declare (port doc): ONE conditional statement — an `INSERT … - * ON CONFLICT (product_id, variant_key) DO UPDATE … WHERE ` — on the - * plain connection, never a transaction, because this channel CANNOT carry a - * sku (the input has no such field) and therefore can never be a stock - * movement. That is the whole reason the sync path stays as cheap as the - * product-level title sync it rides beside. - * - * TWO PATHS, decided by an `INSERT … ON CONFLICT DO NOTHING RETURNING *`: - * - A key nobody has declared before INSERTS and is DONE — one statement, no - * transaction, no read. That is the steady-state cost of a new size. - * - A key that already has a row comes back with nothing, and the write then - * runs inside a TRANSACTION, because a resurrect has to REVALIDATE the - * stored commerce facts (port doc) and the revalidation and the row write - * must commit together. Deciding it this way rather than with a preliminary - * SELECT closes the window where a row is orphaned between the look and the - * write: the transaction re-reads THROUGH THE ROW LOCK and decides there. - * - * The guards mirror `upsert`'s: same-key replay first, then the watermark. The - * RESURRECT sits behind a THIRD, narrower guard — presence moves only on a - * delivery that carries a watermark AND is strictly newer than the stored one - * — which is what makes a redelivered or watermark-less declare unable to undo - * an orphan (port doc). The title is not held to it: it is an unordered cache. - * - * `variant_key` is absent from every SET clause and always will be: it is half - * the primary key, it is the identity, and a re-key is a refusal rather than - * an update. - */ - async upsertVariant( - input: UpsertProductVariantInput, - key: IdempotencyKey, - ): Promise { - if (typeof input.productId !== "string" || input.productId.length === 0) { - throw new MissingProductIdError(); - } - if (typeof input.variantKey !== "string" || input.variantKey.length === 0) { - throw new MissingVariantKeyError(); - } - const now = this.#clock.now().toISOString(); - - // A brand-new key: one statement. `sku` is null on a fresh row and NULLs do - // not collide in a partial unique index, so the PK is the only conflict - // target and a raw constraint error is unreachable here. - const inserted = await this.#db - .insertInto("product_variants") - .values({ - product_id: input.productId, - variant_key: input.variantKey, - // Declared, not priced — this channel has no field for either, so a - // fresh row is always absent on both (never 0, never ""). - sku: null, - price_cents: null, - price_currency: null, - title: input.title ?? null, - orphaned_at: null, - idempotency_key: key, - content_updated_at: input.contentUpdatedAt ?? null, - created_at: now, - updated_at: now, - }) - .onConflict((oc) => oc.columns(["product_id", "variant_key"]).doNothing()) - .returningAll() - .executeTakeFirst(); - if (inserted !== undefined) return toVariantDomain(inserted); - - return this.#db.transaction().execute((trx) => this.#applyVariantDeclare(trx, input, key)); - } - - /** - * `upsertVariant`'s existing-row path, inside the transaction the resurrect's - * revalidation needs. - * - * FULLY ENROLLED IN THE CLASS LOCK ORDER, because a resurrect is a writer like - * any other — it can restore a sku and a price, so it contends with exactly the - * writers that assign them: - * 1. `product_commerce` (the parent) FIRST, unconditionally on this path. The - * guarded edit reaches the parent before it touches its own variant row, so - * a declare that took the variant row first would be a clean ABBA against a - * price edit of the same variant — one deadlocking as an unmapped `40P01` - * out of the CMS sync, which is the one thing this channel must never do. - * It is taken unconditionally rather than "only when resurrecting" because - * whether this IS a resurrect can only be known after reading the row, and - * reading it means holding it: deciding first and locking second is the - * inversion all over again. - * 2. the variant row, read THROUGH its lock — a self-assignment `UPDATE … - * RETURNING *`, the portable form (`FOR UPDATE` is not SQLite), touching no - * observable column so no guard below sees it. - * 3. the stored sku's `inventory` row, before the revalidation reads it. That - * is what makes the revalidation an answer rather than a guess: without it a - * concurrent claim of the same sku either lands after our read — leaving two - * live units on one sku when the claimant is a product — or races our own - * restore into a raw `23505` escaping the sync when the claimant is another - * variant. Both are the failures the clearing exists to prevent. - * - * PERFORMANCE, stated plainly because it is not free: a re-declare is the - * STEADY STATE — every CMS save re-declares every key the repeater still - * carries — and each one now costs a transaction plus two row locks instead of - * one statement. That is the price of a resurrect that cannot corrupt, and it is - * paid on an admin-frequency path (a document save), never on a checkout or - * catalog read. A first declare of a new key is still the single INSERT above. - * - * AND THEY SERIALIZE ON THE PARENT. One save re-declaring N keys takes the SAME - * `product_commerce` row lock N times, so those N declares run strictly one - * after another rather than concurrently, and the save's variant sync is linear - * in the number of sizes. For a garment's handful of sizes that is nothing; a - * product with hundreds of variants would feel it, and the fix then is to batch - * the declares into one transaction that takes the parent once — not to weaken - * the lock. - */ - async #applyVariantDeclare( - exec: Kysely, - input: UpsertProductVariantInput, - key: IdempotencyKey, - ): Promise { - // Stage 1: the parent. Matches zero rows when the product row has not synced - // yet (the documented out-of-order case), which takes nothing and is correct. - await exec - .updateTable("product_commerce") - .set((eb) => ({ product_id: eb.ref("product_id") })) - .where("product_id", "=", input.productId) - .execute(); - - // Stage 2: the variant row. - const stored = await exec - .updateTable("product_variants") - .set((eb) => ({ product_id: eb.ref("product_id") })) - .where("product_id", "=", input.productId) - .where("variant_key", "=", input.variantKey) - .returningAll() - .executeTakeFirst(); - if (stored === undefined) { - // Variant rows are never deleted, so the row that just refused our INSERT - // cannot have gone. Fail loudly rather than mint a second one. - throw new Error( - `product_variants declare lost its row for ${input.productId}/${input.variantKey}`, - ); - } - - // Guard 1: same-key replay — a provable no-op, ahead of everything else. - if (stored.idempotency_key === key) return toVariantDomain(stored); - // Guard 2: a strictly older content revision never overwrites fresher data. - if ( - input.contentUpdatedAt !== undefined && - stored.content_updated_at !== null && - input.contentUpdatedAt < stored.content_updated_at - ) { - return toVariantDomain(stored); - } - - // PRESENCE moves only on an ordered, strictly newer delivery (port doc). - const resurrecting = - stored.orphaned_at !== null && - input.contentUpdatedAt !== undefined && - (stored.content_updated_at === null || input.contentUpdatedAt > stored.content_updated_at); - - // A resurrect REVALIDATES: an orphan's sku was free for reuse, so it may no - // longer be there to reclaim, and a price in a currency the product no - // longer holds is not a price. Cleared, never refused — the declare states a - // fact about the CMS and cannot be voted down by the commerce row. The - // `inventory` row is untouched: a cleared sku leaves its stock where it is, - // and re-assigning it later ADOPTS that row under THE FIRST-SKU ASYMMETRY. - let sku = stored.sku; - let priceCents = stored.price_cents; - let priceCurrency = stored.price_currency; - if (resurrecting) { - if (sku !== null) { - // Stage 3: the sku's stock row, before either read — the same row and - // the same terms the two assigning writers use, so a claim in flight is - // waited for and then SEEN rather than missed. - await this.#lockSkuRowIfPresent(exec, sku); - if ( - (await this.#skuTakenByLiveVariant(exec, sku, input.variantKey)) || - (await this.#skuTakenByLiveProduct(exec, sku)) - ) { - sku = null; - } - } - if (priceCurrency !== null) { - const productCurrency = await this.#resolveProductCurrency( - exec, - input.productId, - input.variantKey, - ); - if (productCurrency !== null && productCurrency !== priceCurrency) { - priceCents = null; - priceCurrency = null; - } - } - } - - const updated = await exec - .updateTable("product_variants") - .set({ - title: input.title !== undefined ? input.title : stored.title, - sku, - price_cents: priceCents, - price_currency: priceCurrency, - orphaned_at: resurrecting ? null : stored.orphaned_at, - idempotency_key: key, - content_updated_at: - input.contentUpdatedAt !== undefined ? input.contentUpdatedAt : stored.content_updated_at, - updated_at: this.#clock.now().toISOString(), - }) - .where("product_id", "=", input.productId) - .where("variant_key", "=", input.variantKey) - .returningAll() - .executeTakeFirstOrThrow(); - return toVariantDomain(updated); - } - - /** - * Every variant of one product (port doc): ONE statement — `product_variants` - * LEFT JOINed to `inventory` for the per-row `onHand`, never an N+1 of - * per-variant stock reads. `inventory.sku` is that table's PRIMARY KEY, so the - * join matches at most one row and can never multiply the result; the LEFT - * half is what makes a variant with no inventory row surface as `onHand: null` - * ("unknown"), distinct from `0` ("out of stock"). A NULL `sku` simply never - * matches, landing on the same null — correct, and identical on both dialects. - * - * Ordered `variant_key ASC` — the only stable order (see the port doc) — - * served by the `(product_id, variant_key)` primary key with no extra index. - * Orphaned rows are INCLUDED, flagged by a non-null `orphanedAt`. - */ - async listVariants(productId: ProductId): Promise { - const rows = await this.#db - .selectFrom("product_variants") - .leftJoin("inventory", "inventory.sku", "product_variants.sku") - .select([ - "product_variants.product_id as product_id", - "product_variants.variant_key as variant_key", - "product_variants.sku as sku", - "product_variants.price_cents as price_cents", - "product_variants.price_currency as price_currency", - "product_variants.title as title", - "product_variants.orphaned_at as orphaned_at", - "product_variants.created_at as created_at", - "product_variants.updated_at as updated_at", - "inventory.on_hand as on_hand", - ]) - .where("product_variants.product_id", "=", productId) - .orderBy("product_variants.variant_key", "asc") - .execute(); - - // NARROWED: `idempotency_key` and `content_updated_at` are not in the SELECT - // list at all (port doc — write-path bookkeeping a reader never needs), so - // the projection cannot leak them by accident. `updated_at` stays: it is the - // compare-and-set watermark an editor passes back. - // The LEFT JOIN miss IS the null — `?? 0` here would invent "out of stock" - // for a variant that has no inventory row at all. - return rows.map((r) => ({ - productId: toProductId(r.product_id), - variantKey: r.variant_key, - sku: r.sku === null ? null : toSku(r.sku), - price: - r.price_cents === null || r.price_currency === null - ? null - : money(cents(r.price_cents), currency(r.price_currency)), - title: r.title, - orphanedAt: r.orphaned_at === null ? null : new Date(r.orphaned_at), - createdAt: new Date(r.created_at), - updatedAt: new Date(r.updated_at), - onHand: r.on_hand, - })); - } - - /** - * The guarded admin edit at variant grain (port doc): a conditional `UPDATE` - * under an optimistic compare-and-set, the atomic mirror of the fake's guard - * chain and of `updateCommerceFields` one level down. An edit that touches - * neither `sku` nor `price` is the single statement it looks like; an edit - * that touches either opens a transaction, because both are movements that - * must commit with the row — the sku's stock carry, and the currency - * resolution's row lock. - * - * WHY THE BEFORE-READ NEEDS NO LOCK, unlike `upsert`'s: the applying statement - * only matches while `updated_at` still equals `expectedUpdatedAt`, and every - * writer advances it, so an interleaved write turns this into `stale` rather - * than a carry against a sku that has since moved. Same argument as - * `#applyCommerceFields`, unchanged. - * - * THE PARENT LOCK IS SKIPPED FOR AN EDIT THAT CANNOT APPLY. Currency resolution - * locks the `product_commerce` row (see `#resolveProductCurrency`), and that - * lock is held to the end of the transaction — so taking it for a merchant's - * stale or replayed save, of which there are many, would block every other - * write to that product for the length of this transaction. The before-read - * that already feeds the rename carry therefore also decides whether the lock - * is worth taking. When it IS taken it is still stage one of the class lock - * order, ahead of the guarded UPDATE's own lock on the variant row. - * - * WHAT THE PRE-CHECK RESTS ON, stated because it is load-bearing and was not - * obvious. Skipping the lock also skips resolving `productCurrency`, which - * switches OFF guard 4b — so the pre-check must never say "cannot apply" about - * an edit the statement then applies. It cannot, and the reason is the store's - * own compare-and-set contract rather than anything local: EVERY writer of a - * variant row advances `updated_at` from the injected `Clock`, and the guarded - * UPDATE matches only while `updated_at` still equals `expectedUpdatedAt`. So a - * row that failed the CAS at pre-read time can pass it at statement time only - * if some writer moved `updated_at` BACKWARDS onto the exact value the caller - * quoted — i.e. only under a non-monotonic clock. THAT IS THE ASSUMPTION, named - * here rather than left implicit: a `Clock` that can go backwards breaks the - * optimistic-concurrency design of this whole port long before it reaches this - * optimization. The orphaned and replay branches need no clock argument at all - * (a resurrect advances `updated_at`, and a stored key changes only by a write - * that does too). - * - * It is belt-and-braces rather than an argument alone: if the statement DOES - * apply with a price while the resolution was skipped, `#applyVariantFields` - * fails loudly instead of writing a currency it never checked. - * - * WHAT A REFUSED EDIT STILL COSTS, since the pre-check does not make it free: a - * stale or replayed edit that CARRIES A SKU still opens a transaction and still - * takes up to two `inventory` row locks before the guarded UPDATE classifies it, - * and holds them until the transaction commits. Those locks are on the sku rows, - * not on the product, so they block only writers touching the same stock — but a - * client retrying a stale save in a tight loop is contending for real rows, not - * merely failing. The pre-check spares such an edit the PARENT lock, which is - * the one that would serialize the whole product. - */ - async updateVariantFields( - input: UpdateProductVariantFieldsInput, - key: IdempotencyKey, - expectedUpdatedAt: string, - ): Promise { - if (input.sku === undefined && input.price === undefined) { - // No price in the input at all, so guard 4b has nothing to evaluate and - // `currencyResolved` is vacuously satisfied. - return this.#applyVariantFields(this.#db, input, key, expectedUpdatedAt, null, null, true); - } - return this.#db.transaction().execute(async (trx) => { - const before = await this.#selectVariant(trx, input.productId, input.variantKey); - const couldApply = - before !== undefined && - before.orphaned_at === null && - before.idempotency_key !== key && - before.updated_at === expectedUpdatedAt; - // Stage one of the lock order, taken for EVERY applying edit and not only - // a priced one. Two writers under one product then never interleave at - // all, which is what rules out every intra-product cycle — including the - // one on `product_variants`' own partial unique index, where two crossing - // renames each wait on the other's uncommitted index entry long before - // either reaches an `inventory` row. - if (couldApply) { - await trx - .updateTable("product_commerce") - .set((eb) => ({ product_id: eb.ref("product_id") })) - .where("product_id", "=", input.productId) - .execute(); - } - const resolveCurrency = input.price !== undefined && couldApply; - const productCurrency = resolveCurrency - ? await this.#resolveProductCurrency(trx, input.productId, input.variantKey) - : null; - return this.#applyVariantFields( - trx, - input, - key, - expectedUpdatedAt, - before?.sku ?? null, - productCurrency, - resolveCurrency, - ); - }); - } - - /** - * The currency every money value under one product must agree on: the product - * row's own price currency when it has one, else any OTHER live priced - * variant's (a product whose sizes carry the prices has no product-level price - * to read). `null` ⇒ nothing to match yet, so a first pricing is free. - * - * THE PARENT READ IS A LOCKING READ — a self-assignment `UPDATE … SET - * product_id = product_id`, the same portable row lock `upsert`'s before-read - * takes (`FOR UPDATE` is not SQLite), and it touches no observable column so - * no other guard sees it. It is here to SERIALIZE, not to read: the compare- - * and-set on each variant row is per-row, so two first-pricings of two - * DIFFERENT variants of one product have different CAS targets and nothing - * else would order them — both would read "no currency yet" and both would - * apply, leaving one product holding two currencies. Taking the parent's lock - * makes one wait for the other and then see its currency. - * - * KNOWN BOUND, deliberate: a product whose `product_commerce` row does not - * exist yet has no row to lock (the out-of-order delivery case the port - * documents), so that one interleaving is unserialized. Closing it would mean - * minting a product row from a variant write, which is a worse trade than a - * window that requires a variant to be priced before its product has synced. - */ - async #resolveProductCurrency( - exec: Kysely, - productId: string, - exceptVariantKey: string, - ): Promise { - const parent = await exec - .updateTable("product_commerce") - .set((eb) => ({ product_id: eb.ref("product_id") })) - .where("product_id", "=", productId) - .returning("price_currency") - .executeTakeFirst(); - if (parent?.price_currency != null) return parent.price_currency; - - const sibling = await exec - .selectFrom("product_variants") - .select("price_currency") - .where("product_id", "=", productId) - .where("variant_key", "!=", exceptVariantKey) - .where("orphaned_at", "is", null) - .where("price_currency", "is not", null) - .limit(1) - .executeTakeFirst(); - return sibling?.price_currency ?? null; - } - - /** - * `updateVariantFields`'s statement and its zero-row classifier, on whichever - * executor the caller opened. The classifier runs the SAME order the fake - * does — not_found (unknown / orphaned) FIRST, then replay, then stale, then - * currency_mismatch — so fake, sqlite and pg agree byte-for-byte. - * - * A product-level currency disagreement is checked in APP CODE rather than as - * a SQL guard (its two operands are both constants, which is a comparison no - * dialect should be asked to type), and it SUPPRESSES the statement entirely - * rather than short-circuiting the method: the classifier still runs, so a - * replay of a disagreeing edit still reports the replay `ok` and a stale one - * still reports `stale`, exactly as the guard order requires. - */ - async #applyVariantFields( - exec: Kysely, - input: UpdateProductVariantFieldsInput, - key: IdempotencyKey, - expectedUpdatedAt: string, - beforeSku: string | null, - productCurrency: string | null, - /** Whether the caller actually resolved the product currency. `false` means - * guard 4b is switched off for this call — legal only when the pre-check - * established the edit cannot apply, which the applying branch re-asserts. */ - currencyResolved: boolean, - ): Promise { - const now = this.#clock.now().toISOString(); - const productCurrencyConflict = - input.price !== undefined && - productCurrency !== null && - productCurrency !== input.price.currency; - - let updated: ProductVariantsTable | undefined; - if (!productCurrencyConflict) { - // The `inventory` pair, in SKU ORDER, BEFORE the guarded UPDATE — because - // the UPDATE is what writes the sku, and writing it takes an entry in - // `product_variants_live_sku_unique`. Two writers crossing skus each wait - // on the other's uncommitted index entry there, which is a cycle no - // later lock can undo. Serializing them on the shared `inventory` rows - // first means only one of them is ever inside the index at a time. - if (input.sku !== undefined) { - const pair = beforeSku === null ? [input.sku] : [beforeSku, input.sku].toSorted(); - for (const s of pair) await this.#lockSkuRowIfPresent(exec, s); - } - const set: Record = { - idempotency_key: key, - updated_at: now, - }; - if (input.sku !== undefined) set.sku = input.sku; - if (input.price !== undefined) { - set.price_cents = input.price.amount; - set.price_currency = input.price.currency; - } - // No `title` branch and no `variant_key` branch: neither field exists on - // this input (ADR-0016; the key is the identity). - try { - let stmt = exec - .updateTable("product_variants") - .set(set) - .where("product_id", "=", input.productId) - .where("variant_key", "=", input.variantKey) - // An edit is neither a create nor a resurrection — an orphaned row is - // unreachable from this surface, exactly like a tombstoned product. - .where("orphaned_at", "is", null) - .where("updated_at", "=", expectedUpdatedAt) - .where("idempotency_key", "!=", key); - if (input.price !== undefined) { - // Never silently switch this variant's own currency. NULL (a first - // pricing) passes. - const cur = input.price.currency; - stmt = stmt.where(sql`(price_currency is null or price_currency = ${cur})`); - } - updated = await stmt.returningAll().executeTakeFirst(); - } catch (err) { - // Variant↔variant live-sku uniqueness is the partial index; surface it - // as the structured domain error, never an opaque 500. - if (input.sku !== undefined && isLiveVariantSkuUniqueViolation(err)) { - throw new SkuConflictError(input.sku); - } - throw err; - } - } - - if (updated !== undefined) { - // The pre-check said this edit could not apply, so guard 4b was never - // resolved — and yet here it is applying, with a price. That is reachable - // only if `updated_at` moved BACKWARDS onto the caller's quoted value (see - // `updateVariantFields`'s note on the clock assumption). Fail loudly rather - // than write a currency nothing checked; the transaction rolls back. - if (input.price !== undefined && !currencyResolved) { - throw new Error( - `updateVariantFields applied a price whose product currency was never resolved for ${input.productId}/${input.variantKey}`, - ); - } - // A SKU NAMES ONE LIVE SELLABLE UNIT, and the other kind of unit is a - // `product_commerce` row, which no index can cover from here. Checked - // AFTER the guarded UPDATE matched, so the refusal keeps its place in the - // guard order (a stale or replayed edit never reaches it) — and inside the - // caller's transaction, so the throw rolls the write back exactly as the - // index's would. - const renaming = beforeSku !== null && updated.sku !== null && updated.sku !== beforeSku; - // BEFORE THE CARRY — a PRECEDENCE decision, not a locking one. The sorted - // pair was already acquired above, ahead of the guarded UPDATE, so this - // check reads under the locks it needs; what is decided here is only WHICH - // typed refusal wins when both apply, which in production-normal state is - // most of the time (an applied assignment seeds an `inventory` row, so a - // sku another live unit holds nearly always has one too). `SkuConflictError` - // wins: it names the real obstacle, where `SkuStockConflictError` would - // describe the same state as parked units and send the operator looking for - // stock to move. - if (input.sku !== undefined && (await this.#skuTakenByLiveProduct(exec, input.sku))) { - throw new SkuConflictError(input.sku); - } - // THE SKU-RENAME RULE (port doc) — the applying branch, and only it. The - // SAME carry the two product-level writers use: `inventory` is keyed by the - // bare sku and knows nothing about products or variants. - if (renaming && updated.sku !== null) { - await this.#carrySkuStock(exec, beforeSku as string, updated.sku, key); - } - return { ok: true, variant: toVariantDomain(updated) }; - } - - const current = await this.#selectVariant(exec, input.productId, input.variantKey); - if (current === undefined || current.orphaned_at !== null) { - return { ok: false, reason: "not_found" }; - } - if (current.idempotency_key === key) { - return { ok: true, variant: toVariantDomain(current) }; // replay no-op. - } - if (current.updated_at !== expectedUpdatedAt) { - return { ok: false, reason: "stale", current: toVariantDomain(current) }; - } - if ( - input.price !== undefined && - current.price_currency !== null && - current.price_currency !== input.price.currency - ) { - return { ok: false, reason: "currency_mismatch", current: toVariantDomain(current) }; - } - if (productCurrencyConflict) { - return { ok: false, reason: "currency_mismatch", current: toVariantDomain(current) }; - } - // No guard explains the no-op — fail loudly rather than swallow a lost write. - throw new Error( - `updateVariantFields matched zero rows but no guard explains it for ${input.productId}/${input.variantKey}`, - ); - } - - /** - * The ORPHAN transition (port doc): a single conditional `UPDATE`, mirroring - * `deactivate`'s shape one level down. Guards ANDed together: - * - `idempotency_key != :key` — a SAME-KEY REPLAY is a no-op unconditionally, - * the per-row compare-on-write both write paths already use. Without it a - * redelivered drop could apply a second time on a row that has since been - * re-declared, quietly un-selling a size the CMS currently lists. - * - `orphaned_at IS NULL` — already-orphaned is a stable no-op under replay, - * leaving the original tombstone instant and the watermark untouched. - * - `content_updated_at IS NULL OR content_updated_at <= :t` — a delayed "the - * repeater row is gone" can never orphan a variant a NEWER save has since - * re-declared. NULL (never synced) is `-infinity`, so the first transition - * wins. The SAME column `upsertVariant` guards on, because both transitions - * ride the same save event (see the port doc for why the product's publish - * gate needed a second column and this does not). `<=` here against the - * resurrect's strict `>`: one save may declare some keys and drop others at - * one watermark, so an orphan must apply at an equal one — while a resurrect - * at an equal watermark would re-litigate a decision already taken. - * An unknown `(product_id, variant_key)` matches zero rows — a no-op, no row - * minted. The row itself is RETAINED with its sku, price and stock: - * deactivation, never deletion. - */ - async deactivateVariant( - productId: ProductId, - variantKey: string, - key: IdempotencyKey, - contentUpdatedAt: string, - ): Promise { - const now = this.#clock.now().toISOString(); - await this.#db - .updateTable("product_variants") - .set({ - orphaned_at: now, - content_updated_at: contentUpdatedAt, - idempotency_key: key, - updated_at: now, - }) - .where("product_id", "=", productId) - .where("variant_key", "=", variantKey) - .where("idempotency_key", "!=", key) - .where("orphaned_at", "is", null) - .where( - sql`(content_updated_at is null or content_updated_at <= ${contentUpdatedAt})`, - ) - .execute(); - } - - /** - * A SKU NAMES ONE LIVE SELLABLE UNIT (port doc). Two halves, because no - * dialect indexes across two tables and each writer only needs the half its - * own unique index does not already cover. - * - * `#skuTakenByLiveProduct` is what a VARIANT writer asks; `#skuTakenByLiveVariant` - * is the reciprocal, what the two PRODUCT-level writers ask. Both are called - * only where the write actually applies, so a replayed, stale or - * watermark-rejected write refuses nothing — the exact position the partial - * unique index occupies on each same-table half. - */ - /** - * Take the target sku's `inventory` row lock, when it has one — the ONE row - * the two halves of the cross-table uniqueness rule can both contend on, since - * no dialect indexes across two tables and neither writer's unique index can - * see the other's. - * - * A self-assignment `UPDATE`, the same portable row lock `upsert`'s before-read - * takes (`FOR UPDATE` is not SQLite), touching no observable column. With it, - * a product write and a variant write reaching for one sku serialize: the - * second waits for the first to commit and then SEES it, so its cross-table - * check refuses instead of passing on a stale snapshot. - * - * THE BOUND, and it is worth stating precisely because the fix is otherwise - * airtight: a sku that has NEVER had an `inventory` row has nothing to lock, so - * two writers assigning that same never-used sku — one to a product, one to a - * variant, in the same instant — can still both pass. Every sku that has ever - * been stocked, restocked, renamed onto, or seeded by the caller's - * always-attempt `seedOnHand` after any earlier assignment is covered. Closing - * the remainder needs a row to contend on: either a sku assignment claims the - * `inventory` row (which would make a first sku a stock movement — the - * FIRST-SKU semantics deliberately say it is not), or a dedicated claim table - * carries a unique index spanning both kinds of unit. Both are their own - * decision; committed state is arbitrated correctly either way, on every - * adapter, and that is what the contract suite pins. - */ - async #lockSkuRowIfPresent(exec: Kysely, s: string): Promise { - await exec - .updateTable("inventory") - .set((eb) => ({ on_hand: eb.ref("on_hand") })) - .where("sku", "=", s) - .execute(); - } - - async #skuTakenByLiveProduct(exec: Kysely, s: string): Promise { - const row = await exec - .selectFrom("product_commerce") - .select("product_id") - .where("sku", "=", s) - .where("deleted_at", "is", null) - .limit(1) - .executeTakeFirst(); - return row !== undefined; - } - - /** As above, from the other side. `exceptVariantKey` excludes the variant doing - * the writing — re-supplying your own sku is not a conflict. */ - async #skuTakenByLiveVariant( - exec: Kysely, - s: string, - exceptVariantKey?: string, - ): Promise { - let q = exec - .selectFrom("product_variants") - .select("variant_key") - .where("sku", "=", s) - .where("orphaned_at", "is", null); - if (exceptVariantKey !== undefined) q = q.where("variant_key", "!=", exceptVariantKey); - const row = await q.limit(1).executeTakeFirst(); - return row !== undefined; - } - - /** - * The live-variant currencies of one product, for the reciprocal currency guard - * (`updateCommerceFields` clause 4c). Read under the parent row's own lock — the - * guarded UPDATE that lock belongs to is the very row being repriced — so a - * product repricing and a variant pricing cannot both pass by reading each - * other's "before" state. Empty for an unvarianted product, which is why the - * guard cannot fire on the catalog as it stands. - */ - async #liveVariantCurrencies(exec: Kysely, productId: string): Promise { - const rows = await exec - .selectFrom("product_variants") - .select("price_currency") - .where("product_id", "=", productId) - .where("orphaned_at", "is", null) - .where("price_currency", "is not", null) - .execute(); - return rows.flatMap((r) => (r.price_currency === null ? [] : [r.price_currency])); - } - - /** One variant row by its composite identity. `exec` is the caller's executor - * so a write that opened a transaction re-reads inside it. */ - async #selectVariant( - exec: Kysely, - productId: string, - variantKey: string, - ): Promise { - return exec - .selectFrom("product_variants") - .selectAll() - .where("product_id", "=", productId) - .where("variant_key", "=", variantKey) - .executeTakeFirst(); - } - - /** The row by its link key. `exec` defaults to the plain connection; a write - * that opened a transaction passes it in, so its own re-read sees the - * statement it just ran rather than the pre-transaction snapshot. */ - async #selectByProductId( - productId: string, - exec: Kysely = this.#db, - ): Promise { - return exec - .selectFrom("product_commerce") - .selectAll() - .where("product_id", "=", productId) - .executeTakeFirst(); - } -} - -function toDomain(row: ProductCommerceTable): ProductCommerce { - return { - productId: toProductId(row.product_id), - sku: row.sku === null ? null : toSku(row.sku), - price: - row.price_cents === null || row.price_currency === null - ? null - : money(cents(row.price_cents), currency(row.price_currency)), - title: row.title, - taxClass: row.tax_class, - compareAtPrice: - row.compare_at_cents === null || row.compare_at_currency === null - ? null - : money(cents(row.compare_at_cents), currency(row.compare_at_currency)), - unitCost: - row.unit_cost_cents === null || row.unit_cost_currency === null - ? null - : money(cents(row.unit_cost_cents), currency(row.unit_cost_currency)), - inventoryPolicy: row.inventory_policy as InventoryPolicy, - weightGrams: row.weight_grams, - lengthMm: row.length_mm, - widthMm: row.width_mm, - heightMm: row.height_mm, - productKind: row.product_kind as ProductKind, - active: row.active === 1, - deletedAt: row.deleted_at === null ? null : new Date(row.deleted_at), - idempotencyKey: toIdempotencyKey(row.idempotency_key), - contentUpdatedAt: row.content_updated_at, - createdAt: new Date(row.created_at), - updatedAt: new Date(row.updated_at), - }; -} - -/** One `product_variants` row → the domain shape. Money stays branded and - * NULLABLE: an absent price is absent, never zero. */ -function toVariantDomain(row: ProductVariantsTable): ProductVariant { - return { - productId: toProductId(row.product_id), - variantKey: row.variant_key, - sku: row.sku === null ? null : toSku(row.sku), - price: - row.price_cents === null || row.price_currency === null - ? null - : money(cents(row.price_cents), currency(row.price_currency)), - title: row.title, - orphanedAt: row.orphaned_at === null ? null : new Date(row.orphaned_at), - idempotencyKey: toIdempotencyKey(row.idempotency_key), - contentUpdatedAt: row.content_updated_at, - createdAt: new Date(row.created_at), - updatedAt: new Date(row.updated_at), - }; -} - -/** - * The variant-grain twin of {@link isLiveSkuUniqueViolation}, for the - * `product_variants_live_sku_unique` partial index — same two dialect shapes - * (pg SQLSTATE `23505` naming the constraint; better-sqlite3's - * `SQLITE_CONSTRAINT_UNIQUE` naming the violated columns in `table.column` - * form), same narrow scoping, so anything else still propagates untouched. - */ -function isLiveVariantSkuUniqueViolation(err: unknown): boolean { - if (typeof err !== "object" || err === null) return false; - const { code, constraint, message } = err as { - code?: unknown; - constraint?: unknown; - message?: unknown; - }; - if (code === "23505") { - return constraint === "product_variants_live_sku_unique"; - } - if (code === "SQLITE_CONSTRAINT_UNIQUE") { - return ( - typeof message === "string" && - (message.includes("product_variants_live_sku_unique") || - message.includes("product_variants.sku")) - ); - } - return false; -} - -/** - * Narrowly-scoped unique-violation check for the - * `product_commerce_live_sku_unique` partial index (review F2), mirroring - * Phase 0's `isForeignKeyViolation` shape: - * - pg: SQLSTATE `23505` with `constraint` naming the index; - * - better-sqlite3: `SQLITE_CONSTRAINT_UNIQUE` whose message names the - * violated columns as `product_commerce.sku` (SQLite reports partial - * UNIQUE-index violations in table.column form, verified against - * better-sqlite3 12.x) — the partial index is the ONLY unique constraint - * over that column, so the match stays exact. - * Anything else (other constraints, other tables) is NOT matched. - */ -function isLiveSkuUniqueViolation(err: unknown): boolean { - if (typeof err !== "object" || err === null) return false; - const { code, constraint, message } = err as { - code?: unknown; - constraint?: unknown; - message?: unknown; - }; - if (code === "23505") { - return constraint === "product_commerce_live_sku_unique"; - } - if (code === "SQLITE_CONSTRAINT_UNIQUE") { - return ( - typeof message === "string" && - (message.includes("product_commerce_live_sku_unique") || - message.includes("product_commerce.sku")) - ); - } - return false; -} - -/** - * Validates `filter.lowStockThreshold` BEFORE any query is built (port doc — - * `InvalidLowStockThresholdError`), via the SAME `isValidLowStockThreshold` - * guard the fake calls, so the two dialects sharing this class and the - * IO-free fake can never drift on out-of-domain input. Called at the top of - * BOTH `listProducts` and `countProducts` — never left to the driver: a raw - * out-of-domain value reaching Postgres fails binding an `integer` column - * ("invalid input syntax for type integer"), while better-sqlite3 accepts it - * and answers a DIFFERENT (wrong) row set, which is the exact three-way - * disagreement this guard exists to make unreachable. - */ -function assertValidLowStockThreshold(filter: ProductListFilter): void { - if ( - filter.lowStockThreshold !== undefined && - !isValidLowStockThreshold(filter.lowStockThreshold) - ) { - throw new InvalidLowStockThresholdError(filter.lowStockThreshold); - } -} - -/** - * The ONE `ProductListFilter` predicate `listProducts` builds from (mirrors - * `orderFilterConditions` — a single builder so semantics can never drift). - * Returns standalone expressions (a detached `expressionBuilder`) to AND onto - * the query. `search` matches EITHER an exact-lower sku OR a case-insensitive - * substring of `title` (port doc). The sku half is now the arm this predicate - * SHARES with `OrderListFilter.search` — which is an id PREFIX, a folded - * `buyer_ref` SUBSTRING, or an exact-lower sku of its own; what still differs is - * WHERE each reads that sku, this one from the live `product_commerce` row and - * the orders list from the purchase-time `order_items` snapshot. A NULL - * `sku`/`title` simply fails its half of the OR (SQL `NULL LIKE …` / `NULL = …` - * is unknown ⇒ false), never a throw. - * `deleted` is DELIBERATELY absent from this builder — it flips the base - * query's `deleted_at IS [NOT] NULL` clause in `listProducts` directly, not an - * ANDed condition here (the two are mutually exclusive branches, not a - * composable filter half). `lowStockThreshold` (port doc) reads - * `inventory.on_hand` — present in `listProducts`'s unconditional LEFT JOIN, - * and in `countProducts`'s CONDITIONAL one — so this builder is safe to share - * between both callers regardless of which one actually joined the table. - */ -function productFilterConditions(filter: ProductListFilter): Expression[] { - const eb: ExpressionBuilder = expressionBuilder(); - const conds: Expression[] = []; - if (filter.active !== undefined) { - conds.push(eb("product_commerce.active", "=", filter.active ? 1 : 0)); - } - if (filter.productKind !== undefined) { - conds.push(eb("product_commerce.product_kind", "=", filter.productKind)); - } - if (filter.search !== undefined) { - const search = filter.search; - const likePattern = `%${escapeLikePattern(search)}%`; - conds.push( - eb.or([ - eb(sql`lower(product_commerce.sku)`, "=", search.toLowerCase()), - sql`lower(product_commerce.title) like lower(${likePattern}) escape '\\'`, - ]), - ); - } - if (filter.lowStockThreshold !== undefined) { - // `inventory` isn't in this builder's typed FROM set (it's only ever - // LEFT JOINed onto the caller's query, not this detached - // `expressionBuilder`), so this is raw SQL rather than a typed `eb(...)` - // ref — same escape hatch the title half of `search` already uses. - // `on_hand IS NOT NULL` is load-bearing: a LEFT JOIN miss must fail this - // predicate (unknown stock is never "low"), never compare NULL <= n - // (which SQL evaluates to unknown/false anyway, but the explicit guard - // documents the intent rather than relying on that quirk). - conds.push( - sql`(inventory.on_hand is not null and inventory.on_hand <= ${filter.lowStockThreshold})`, - ); - } - return conds; -} - -/** Escape a raw user string for safe embedding in a SQL `LIKE` pattern — - * `\`, `%`, and `_` are LIKE metacharacters (the escape char first, so it - * never double-escapes itself). Portable across pg and better-sqlite3, both - * of which support `LIKE … ESCAPE '\'`. A search for a literal `%`/`_` (e.g. - * a title like "50% off") must match literally, never as a wildcard. */ -function escapeLikePattern(value: string): string { - return value.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_"); -} diff --git a/packages/store-postgres/src/kysely-reporting-store.ts b/packages/store-postgres/src/kysely-reporting-store.ts deleted file mode 100644 index 6ae70a7c..00000000 --- a/packages/store-postgres/src/kysely-reporting-store.ts +++ /dev/null @@ -1,258 +0,0 @@ -import { - cents, - currency as toCurrency, - type DateRange, - type LowStockRow, - type PeriodBucket, - type ReportInterval, - type ReportingStore, - REVENUE_COUNTING_STATES, - type StatusCount, - type TopProduct, - type TopProductsMetric, -} from "@otta-sh/domain"; -import { type Kysely, type RawBuilder, sql } from "kysely"; -import type { Database } from "./schema.js"; - -export type ReportingDialect = "sqlite" | "postgres"; - -export interface KyselyReportingStoreOptions { - db: Kysely; - /** The single piece of dialect knowledge this phase needs — the period-bucket - * expression branches on it (§4.2); everything else is portable SQL. */ - dialect: ReportingDialect; -} - -const MAX_SAFE_BIG = BigInt(Number.MAX_SAFE_INTEGER); - -/** - * Convert a SQL aggregate (`SUM`/`COUNT`) to a JS integer WITHOUT silent - * precision loss. Postgres returns `SUM(int)`/`COUNT(*)` as a bigint/numeric - * STRING; a naive `Number("9007199254740993")` would round to a nearby safe - * integer that `cents()` then happily accepts. Range-check via BigInt FIRST so an - * out-of-safe-range aggregate THROWS rather than coercing. SQLite returns a - * number directly; assert it's a safe integer (guards the dynamic-typing float - * footgun too). Exported for a focused unit test (an actual >2^53 sum would need - * millions of rows to reproduce end-to-end). - */ -export function parseAggregate(raw: number | string): number { - if (typeof raw === "number") { - if (!Number.isSafeInteger(raw)) { - throw new RangeError(`reporting aggregate ${String(raw)} is not a safe integer`); - } - return raw; - } - const big = BigInt(raw); - if (big > MAX_SAFE_BIG || big < -MAX_SAFE_BIG) { - throw new RangeError( - `reporting aggregate ${raw} exceeds Number.MAX_SAFE_INTEGER — refusing to coerce`, - ); - } - return Number(big); -} - -/** The revenue-counting state allow-list as a parameterized SQL `IN (…)` list — - * built ONCE from the domain constant so both revenue and top-products share it - * (never reimplemented, never drifts). */ -const REVENUE_STATES_IN: RawBuilder = sql`(${sql.join( - REVENUE_COUNTING_STATES.map((s) => sql.val(s)), -)})`; - -/** The ONE `refunds.status` that is money which actually came back — the - * `sumFinalizedRefunds` set (`PeriodBucket.refundedCents`). Deliberately an - * equality against the finalized state rather than `<> 'voided'`: the ACTIVE - * (non-voided) set is the CEILING's arbitration rule, and reusing it here would - * report a `reserved`/`unverified` attempt as a completed refund. */ -const FINALIZED_REFUND_STATUS = "recorded"; - -/** - * `ReportingStore` over Kysely (§4.2), read-only over the existing orders / - * order_totals / order_items / inventory tables. Money and quantity columns are - * integers on both dialects, so `SUM()`/`COUNT()` stay integers; pg returns those - * aggregates as strings (bigint/numeric) which `cents()` re-validates as safe - * integers. The ONLY dialect-specific SQL is the period-bucket expression - * (`#bucketSql`); the port and contract assertions are dialect-agnostic. - */ -export class KyselyReportingStore implements ReportingStore { - readonly #db: Kysely; - readonly #dialect: ReportingDialect; - - constructor(options: KyselyReportingStoreOptions) { - this.#db = options.db; - this.#dialect = options.dialect; - } - - /** - * Revenue AND refunds per (bucket, currency) in ONE statement (port doc). - * - * The shape is a `UNION ALL` of two contribution sets folded by a single - * `GROUP BY`, which is a deliberate choice over both a JOIN and two round - * trips: - * - the two halves have DIFFERENT predicates (revenue applies the state - * allow-list, refunds apply none and filter the ledger lifecycle instead), - * so neither can be expressed as a filter on the other's rows; - * - a bucket must exist when EITHER half contributes — a fully refunded - * order is excluded from revenue, so an inner join would drop exactly the - * row that field was added for, and `FULL OUTER JOIN` is the one join shape - * better-sqlite3 did not carry until 3.39. The union is portable, needs no - * dialect branch of its own, and yields the outer-join semantics for free. - * - * Both halves alias `orders` as `o`, so the ONE dialect-branched fragment - * (`#bucketSql`, which reads `o.created_at`) is shared verbatim — the refund's - * own timestamp is never the bucket key (port doc). - * - * MEASURED (pg 16, 5,000 orders over ~208 day buckets in 2 currencies, 417 - * refund rows, 60 runs after 10 warm-ups) against the single-scan shape this - * replaced: - * old (revenue only) p50 10.87 ms · p95 13.99 ms - * new (UNION ALL) p50 13.30 ms · p95 15.52 ms — +2.4 ms p50, ~22% - * The second branch scans a table two orders of magnitude smaller than - * `orders`, so the added cost tracks the REFUND count, not the order count. - * No index was added: the refunds side is driven from `orders` through the - * existing `refunds(order_id, created_at, id)` composite (migration 0020). - */ - async revenueByPeriod(range: DateRange, interval: ReportInterval): Promise { - const bucket = this.#bucketSql(interval); - const result = await sql<{ - currency: string; - bucket: string; - revenue: number | string; - refunded: number | string; - }>` - SELECT u.currency AS currency, - u.bucket AS bucket, - SUM(u.revenue_cents) AS revenue, - SUM(u.refunded_cents) AS refunded - FROM ( - SELECT ot.currency AS currency, - ${bucket} AS bucket, - ot.total_cents AS revenue_cents, - 0 AS refunded_cents - FROM orders o - JOIN order_totals ot ON ot.order_id = o.id - WHERE o.created_at BETWEEN ${range.from} AND ${range.to} - AND o.state IN ${REVENUE_STATES_IN} - UNION ALL - SELECT r.currency AS currency, - ${bucket} AS bucket, - 0 AS revenue_cents, - r.amount_cents AS refunded_cents - FROM refunds r - JOIN orders o ON o.id = r.order_id - WHERE o.created_at BETWEEN ${range.from} AND ${range.to} - AND r.status = ${FINALIZED_REFUND_STATUS} - ) AS u - GROUP BY u.currency, u.bucket - ORDER BY bucket ASC, currency ASC - `.execute(this.#db); - return result.rows.map((r) => ({ - bucketStart: r.bucket, - currency: toCurrency(r.currency), - revenueCents: cents(parseAggregate(r.revenue)), - refundedCents: cents(parseAggregate(r.refunded)), - })); - } - - async ordersByStatus(range: DateRange): Promise { - const result = await sql<{ status: string; order_count: number | string }>` - SELECT state AS status, COUNT(*) AS order_count - FROM orders - WHERE created_at BETWEEN ${range.from} AND ${range.to} - GROUP BY state - ORDER BY state ASC - `.execute(this.#db); - return result.rows.map((r) => ({ - status: r.status, - orderCount: parseAggregate(r.order_count), - })); - } - - async topProducts( - range: DateRange, - metric: TopProductsMetric, - limit: number, - ): Promise { - const orderMetric = metric === "quantity" ? sql`qty_sold` : sql`revenue`; - const result = await sql<{ - product_id: string; - title: string; - qty_sold: number | string; - revenue: number | string; - }>` - SELECT oi.product_id AS product_id, - oi.title AS title, - SUM(oi.quantity) AS qty_sold, - -- CAST one factor to bigint so the per-line product is computed in - -- 64 bits: on pg both columns are int4 and the qty*price product would - -- raise "integer out of range" BEFORE SUM widens (sqlite is 64-bit - -- natively). CAST(... AS bigint) is portable across both dialects. - SUM(CAST(oi.quantity AS bigint) * oi.unit_price_cents) AS revenue - FROM order_items oi - JOIN orders o ON o.id = oi.order_id - WHERE o.created_at BETWEEN ${range.from} AND ${range.to} - AND o.state IN ${REVENUE_STATES_IN} - GROUP BY oi.product_id, oi.title - ORDER BY ${orderMetric} DESC, oi.product_id ASC - LIMIT ${limit} - `.execute(this.#db); - return result.rows.map((r) => ({ - productId: r.product_id, - titleSnapshot: r.title, - qtySold: parseAggregate(r.qty_sold), - revenueCents: cents(parseAggregate(r.revenue)), - })); - } - - /** - * Low stock (port doc), with the LIVE product's title joined on. - * - * The `deleted_at IS NULL` half of the ON clause is LOAD-BEARING, not - * defensive noise: `product_commerce` enforces sku uniqueness with a - * PARTIAL unique index over live rows only (migration 0002), precisely so a - * soft-deleted product releases its sku for reuse. Join on sku alone and a - * tombstone sharing a live sku duplicates the low-stock row and can win the - * title; with the predicate the join is at most 1:1 and only a live product - * titles a row. A sku with no live product yields `title: null` — the sku - * is NEVER substituted (port doc). Identical DDL-free SQL on both dialects. - */ - async lowStock(threshold: number): Promise { - const rows = await this.#db - .selectFrom("inventory") - .leftJoin("product_commerce", (join) => - join - .onRef("product_commerce.sku", "=", "inventory.sku") - .on("product_commerce.deleted_at", "is", null), - ) - .select([ - "inventory.sku as sku", - "inventory.on_hand as on_hand", - "product_commerce.title as title", - ]) - .where("inventory.on_hand", "<=", threshold) - .orderBy("inventory.on_hand", "asc") - .orderBy("inventory.sku", "asc") - .execute(); - return rows.map((r) => ({ sku: r.sku, onHand: r.on_hand, title: r.title })); - } - - /** - * The one dialect-branched fragment (§4.2): a canonical UTC bucket-start text - * (`YYYY-MM-DDT00:00:00.000Z`), identical across dialects. `week` truncates to - * the ISO-8601 Monday on both (pg `date_trunc('week')`; SQLite `weekday 1`). - */ - #bucketSql(interval: ReportInterval): RawBuilder { - if (this.#dialect === "postgres") { - // Force UTC: cast the ISO-Z text to timestamptz, then re-anchor to UTC wall - // clock so date_trunc is session-timezone-independent. - return sql`to_char(date_trunc(${interval}, (o.created_at)::timestamptz AT TIME ZONE 'UTC'), 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')`; - } - // better-sqlite3: strftime over the ISO text (non-% chars are literal). - if (interval === "month") { - return sql`strftime('%Y-%m-01T00:00:00.000Z', o.created_at)`; - } - if (interval === "week") { - return sql`strftime('%Y-%m-%dT00:00:00.000Z', o.created_at, '-6 days', 'weekday 1')`; - } - return sql`strftime('%Y-%m-%dT00:00:00.000Z', o.created_at)`; - } -} diff --git a/packages/store-postgres/src/kysely-session-store.ts b/packages/store-postgres/src/kysely-session-store.ts deleted file mode 100644 index f0671171..00000000 --- a/packages/store-postgres/src/kysely-session-store.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { createHash } from "node:crypto"; -import { - customerId as toCustomerId, - type Clock, - type CustomerId, - type IdGen, - type Session, - type SessionStore, - type SessionSummary, -} from "@otta-sh/domain"; -import type { Kysely } from "kysely"; -import type { Database } from "./schema.js"; - -/** Default session lifetime — long-lived so magic-link isn't needed every visit. */ -export const DEFAULT_SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; - -export interface KyselySessionStoreOptions { - db: Kysely; - idGen: IdGen; - clock: Clock; - ttlMs?: number; -} - -/** - * `SessionStore` over Kysely (§4/§9 decision 5). Opaque DB-backed tokens (not - * JWT) so revocation actually works. **Only the token hash is stored** — a DB - * read can't leak a usable session. `validate` rejects unknown / expired / - * revoked tokens; it is the sole authority on identity for every `/me/*` handler. - */ -export class KyselySessionStore implements SessionStore { - readonly #db: Kysely; - readonly #idGen: IdGen; - readonly #clock: Clock; - readonly #ttlMs: number; - - constructor(options: KyselySessionStoreOptions) { - this.#db = options.db; - this.#idGen = options.idGen; - this.#clock = options.clock; - this.#ttlMs = options.ttlMs ?? DEFAULT_SESSION_TTL_MS; - } - - async create(customerId: CustomerId): Promise { - const token = this.#idGen.newId(); - const now = this.#clock.now(); - const expiresAt = new Date(now.getTime() + this.#ttlMs).toISOString(); - await this.#db - .insertInto("customer_sessions") - .values({ - id: this.#idGen.newId(), - customer_id: customerId, - token_hash: hashToken(token), - created_at: now.toISOString(), - expires_at: expiresAt, - revoked_at: null, - }) - .execute(); - return { token, expiresAt }; - } - - async validate(token: string): Promise { - const row = await this.#db - .selectFrom("customer_sessions") - .select("customer_id") - .where("token_hash", "=", hashToken(token)) - .where("revoked_at", "is", null) - .where("expires_at", ">", this.#clock.now().toISOString()) - .executeTakeFirst(); - return row === undefined ? null : toCustomerId(row.customer_id); - } - - async revoke(token: string): Promise { - await this.#db - .updateTable("customer_sessions") - .set({ revoked_at: this.#clock.now().toISOString() }) - .where("token_hash", "=", hashToken(token)) - .where("revoked_at", "is", null) - .execute(); - } - - async listForCustomer(customerId: CustomerId): Promise { - // Token-free session history for the admin customer-context read (admin-UX - // Increment 1). Deliberately NEVER selects `token_hash` — there is no path - // for credential material onto the admin surface even by accident. Includes - // expired + revoked rows (a history, not a liveness check — `validate` - // stays the sole authority on liveness). - const rows = await this.#db - .selectFrom("customer_sessions") - .select(["id", "created_at", "expires_at", "revoked_at"]) - .where("customer_id", "=", customerId) - .orderBy("created_at", "desc") - .orderBy("id", "desc") - .execute(); - return rows.map((r) => ({ - id: r.id, - createdAt: r.created_at, - expiresAt: r.expires_at, - revokedAt: r.revoked_at, - })); - } -} - -/** SHA-256 hex of the opaque token — the only thing persisted (§4). */ -export function hashToken(token: string): string { - return createHash("sha256").update(token).digest("hex"); -} diff --git a/packages/store-postgres/src/kysely-settings-store.ts b/packages/store-postgres/src/kysely-settings-store.ts deleted file mode 100644 index ca592ae9..00000000 --- a/packages/store-postgres/src/kysely-settings-store.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { - type Clock, - DEFAULT_OPERATIONAL_SETTINGS, - type IdempotencyKey, - type OperationalSettings, - type SettingsStore, -} from "@otta-sh/domain"; -import type { Kysely } from "kysely"; -import type { Database } from "./schema.js"; - -/** The single settings row's fixed primary key. */ -const SINGLETON_ID = "singleton"; - -export interface KyselySettingsStoreOptions { - db: Kysely; - clock: Clock; -} - -/** - * `SettingsStore` over Kysely (§5.2), dialect-agnostic across better-sqlite3 and - * pg. `get` returns the single row or the domain defaults (never an error for "no - * row yet"). `update` is idempotency-ledgered exactly like `coupon_redemptions`: - * inside one transaction it reads current, computes the merged result, CLAIMS the - * key (`INSERT … ON CONFLICT DO NOTHING`), and only the claim winner upserts the - * settings row — a replay (or concurrent same-key peer) returns the RECORDED - * result and never re-applies, so a stale replay cannot clobber a newer write. - */ -export class KyselySettingsStore implements SettingsStore { - readonly #db: Kysely; - readonly #clock: Clock; - - constructor(options: KyselySettingsStoreOptions) { - this.#db = options.db; - this.#clock = options.clock; - } - - async get(): Promise { - const row = await this.#db - .selectFrom("settings") - .select(["hold_ttl_minutes", "low_stock_threshold"]) - .where("id", "=", SINGLETON_ID) - .executeTakeFirst(); - if (row === undefined) return { ...DEFAULT_OPERATIONAL_SETTINGS }; - return { - holdTtlMinutes: row.hold_ttl_minutes, - lowStockThreshold: row.low_stock_threshold, - }; - } - - async update( - patch: Partial, - idempotencyKey: IdempotencyKey, - ): Promise { - // Fast-path replay short-circuit (outside the tx — mirrors reserve/redeem). - const recorded = await this.#findMutation(idempotencyKey); - if (recorded !== undefined) return recorded; - - const now = this.#clock.now().toISOString(); - return this.#db.transaction().execute(async (trx) => { - const current = await trx - .selectFrom("settings") - .select(["hold_ttl_minutes", "low_stock_threshold"]) - .where("id", "=", SINGLETON_ID) - .executeTakeFirst(); - const base: OperationalSettings = - current === undefined - ? { ...DEFAULT_OPERATIONAL_SETTINGS } - : { - holdTtlMinutes: current.hold_ttl_minutes, - lowStockThreshold: current.low_stock_threshold, - }; - const next: OperationalSettings = { - holdTtlMinutes: patch.holdTtlMinutes ?? base.holdTtlMinutes, - lowStockThreshold: patch.lowStockThreshold ?? base.lowStockThreshold, - }; - - // Claim the key with the RESULTING values. A concurrent same-key peer makes - // this a no-op conflict — re-read and return the recorded result, applying - // nothing (idempotent, no double-apply, no clobber of a newer write). - const claim = await trx - .insertInto("settings_mutations") - .values({ - idempotency_key: idempotencyKey, - hold_ttl_minutes: next.holdTtlMinutes, - low_stock_threshold: next.lowStockThreshold, - created_at: now, - }) - .onConflict((oc) => oc.column("idempotency_key").doNothing()) - .returning("idempotency_key") - .executeTakeFirst(); - if (claim === undefined) { - const raced = await trx - .selectFrom("settings_mutations") - .select(["hold_ttl_minutes", "low_stock_threshold"]) - .where("idempotency_key", "=", idempotencyKey) - .executeTakeFirstOrThrow(); - return { - holdTtlMinutes: raced.hold_ttl_minutes, - lowStockThreshold: raced.low_stock_threshold, - }; - } - - await trx - .insertInto("settings") - .values({ - id: SINGLETON_ID, - hold_ttl_minutes: next.holdTtlMinutes, - low_stock_threshold: next.lowStockThreshold, - updated_at: now, - }) - .onConflict((oc) => - oc.column("id").doUpdateSet({ - hold_ttl_minutes: next.holdTtlMinutes, - low_stock_threshold: next.lowStockThreshold, - updated_at: now, - }), - ) - .execute(); - return next; - }); - } - - async #findMutation(key: string): Promise { - const row = await this.#db - .selectFrom("settings_mutations") - .select(["hold_ttl_minutes", "low_stock_threshold"]) - .where("idempotency_key", "=", key) - .executeTakeFirst(); - if (row === undefined) return undefined; - return { - holdTtlMinutes: row.hold_ttl_minutes, - lowStockThreshold: row.low_stock_threshold, - }; - } -} diff --git a/packages/store-postgres/src/kysely-shipping-rules-store.ts b/packages/store-postgres/src/kysely-shipping-rules-store.ts deleted file mode 100644 index 2b74b4e3..00000000 --- a/packages/store-postgres/src/kysely-shipping-rules-store.ts +++ /dev/null @@ -1,277 +0,0 @@ -import { - cents, - currency as toCurrency, - type Cents, - type Currency, - type CreateShippingMethodInput, - type CreateShippingRateInput, - type CreateShippingZoneInput, - type DeleteShippingMethodResult, - type DeleteShippingRateResult, - type DeleteShippingZoneResult, - type ShippingMethod, - type ShippingMethodType, - type ShippingRate, - type ShippingRulesStore, - type ShippingZone, - type UpdateShippingMethodInput, - type UpdateShippingMethodResult, - type UpdateShippingRateInput, - type UpdateShippingRateResult, - type UpdateShippingZoneInput, - type UpdateShippingZoneResult, -} from "@otta-sh/domain"; -import type { Kysely } from "kysely"; -import type { Database } from "./schema.js"; - -/** `ShippingRulesStore` over Kysely — dialect-agnostic (better-sqlite3 + pg). */ -export class KyselyShippingRulesStore implements ShippingRulesStore { - readonly #db: Kysely; - - constructor(options: { db: Kysely }) { - this.#db = options.db; - } - - async createZone(input: CreateShippingZoneInput): Promise { - await this.#db - .insertInto("shipping_zones") - .values({ - id: input.id, - name: input.name, - regions: - input.regions === null || input.regions === undefined - ? null - : JSON.stringify(input.regions), - }) - .execute(); - return { id: input.id, name: input.name, regions: input.regions }; - } - - async listZones(): Promise { - const rows = await this.#db.selectFrom("shipping_zones").selectAll().orderBy("id").execute(); - return rows.map((r) => ({ id: r.id, name: r.name, regions: parseRegions(r.regions) })); - } - - async getZone(zoneId: string): Promise { - const r = await this.#db - .selectFrom("shipping_zones") - .selectAll() - .where("id", "=", zoneId) - .executeTakeFirst(); - return r === undefined ? null : { id: r.id, name: r.name, regions: parseRegions(r.regions) }; - } - - /** LWW edit (port doc). Zero rows updated ⇒ `not_found`. */ - async updateZone( - zoneId: string, - input: UpdateShippingZoneInput, - ): Promise { - const updated = await this.#db - .updateTable("shipping_zones") - .set({ - name: input.name, - regions: - input.regions === null || input.regions === undefined - ? null - : JSON.stringify(input.regions), - }) - .where("id", "=", zoneId) - .returningAll() - .executeTakeFirst(); - if (updated === undefined) return { ok: false, reason: "not_found" }; - return { - ok: true, - zone: { id: updated.id, name: updated.name, regions: parseRegions(updated.regions) }, - }; - } - - /** - * Forbid-if-children delete (port doc): the DELETE is conditioned on NO - * `shipping_method` referencing the zone, so a concurrent method insert can - * never orphan onto a just-deleted zone. Zero rows ⇒ classify unknown id vs - * still-referenced (mirrors `TaxRulesStore.deleteClass`). - */ - async deleteZone(zoneId: string): Promise { - const res = await this.#db - .deleteFrom("shipping_zones") - .where("id", "=", zoneId) - .where((eb) => - eb.not( - eb.exists( - eb - .selectFrom("shipping_methods") - .select("id") - .whereRef("shipping_methods.zone_id", "=", "shipping_zones.id"), - ), - ), - ) - .executeTakeFirst(); - if (Number(res.numDeletedRows) > 0) return { ok: true }; - const exists = await this.#db - .selectFrom("shipping_zones") - .select("id") - .where("id", "=", zoneId) - .executeTakeFirst(); - if (exists === undefined) return { ok: false, reason: "not_found" }; - return { ok: false, reason: "in_use_by_methods" }; - } - - async createMethod(input: CreateShippingMethodInput): Promise { - await this.#db - .insertInto("shipping_methods") - .values({ id: input.id, zone_id: input.zoneId, name: input.name, type: input.type }) - .execute(); - return { id: input.id, zoneId: input.zoneId, name: input.name, type: input.type }; - } - - async listMethods(zoneId: string): Promise { - const rows = await this.#db - .selectFrom("shipping_methods") - .selectAll() - .where("zone_id", "=", zoneId) - .orderBy("id") - .execute(); - return rows.map(toMethod); - } - - async getMethod(methodId: string): Promise { - const r = await this.#db - .selectFrom("shipping_methods") - .selectAll() - .where("id", "=", methodId) - .executeTakeFirst(); - return r === undefined ? null : toMethod(r); - } - - /** LWW edit (port doc). Zero rows updated ⇒ `not_found`. */ - async updateMethod( - methodId: string, - input: UpdateShippingMethodInput, - ): Promise { - const updated = await this.#db - .updateTable("shipping_methods") - .set({ name: input.name, type: input.type }) - .where("id", "=", methodId) - .returningAll() - .executeTakeFirst(); - if (updated === undefined) return { ok: false, reason: "not_found" }; - return { ok: true, method: toMethod(updated) }; - } - - /** Forbid-if-children delete (port doc): conditioned on NO `shipping_rate` - * referencing the method. Zero rows ⇒ unknown id vs still-referenced. */ - async deleteMethod(methodId: string): Promise { - const res = await this.#db - .deleteFrom("shipping_methods") - .where("id", "=", methodId) - .where((eb) => - eb.not( - eb.exists( - eb - .selectFrom("shipping_rates") - .select("method_id") - .whereRef("shipping_rates.method_id", "=", "shipping_methods.id"), - ), - ), - ) - .executeTakeFirst(); - if (Number(res.numDeletedRows) > 0) return { ok: true }; - const exists = await this.#db - .selectFrom("shipping_methods") - .select("id") - .where("id", "=", methodId) - .executeTakeFirst(); - if (exists === undefined) return { ok: false, reason: "not_found" }; - return { ok: false, reason: "in_use_by_rates" }; - } - - async createRate(input: CreateShippingRateInput): Promise { - await this.#db - .insertInto("shipping_rates") - .values({ - method_id: input.methodId, - currency: input.currency, - amount_cents: input.amountCents, - min_subtotal_cents: input.minSubtotalCents, - }) - .execute(); - return { ...input }; - } - - async getRate(methodId: string, currency: Currency): Promise { - const r = await this.#db - .selectFrom("shipping_rates") - .selectAll() - .where("method_id", "=", methodId) - .where("currency", "=", currency) - .executeTakeFirst(); - return r === undefined ? null : toRate(r); - } - - /** - * Guarded edit (port doc): optimistic CAS on the money-bearing `amount_cents`. - * Zero rows updated ⇒ a fresh read classifies unknown `(methodId, currency)` - * (`not_found`) vs a concurrent price change (`stale`). - */ - async updateRate( - methodId: string, - currency: Currency, - input: UpdateShippingRateInput, - expectedAmountCents: Cents, - ): Promise { - const updated = await this.#db - .updateTable("shipping_rates") - .set({ amount_cents: input.amountCents, min_subtotal_cents: input.minSubtotalCents }) - .where("method_id", "=", methodId) - .where("currency", "=", currency) - .where("amount_cents", "=", expectedAmountCents) - .returningAll() - .executeTakeFirst(); - if (updated !== undefined) return { ok: true, rate: toRate(updated) }; - const current = await this.#db - .selectFrom("shipping_rates") - .selectAll() - .where("method_id", "=", methodId) - .where("currency", "=", currency) - .executeTakeFirst(); - if (current === undefined) return { ok: false, reason: "not_found" }; - return { ok: false, reason: "stale", current: toRate(current) }; - } - - /** Leaf delete (port doc). Zero rows ⇒ `not_found` (idempotent no-op). */ - async deleteRate(methodId: string, currency: Currency): Promise { - const res = await this.#db - .deleteFrom("shipping_rates") - .where("method_id", "=", methodId) - .where("currency", "=", currency) - .executeTakeFirst(); - return Number(res.numDeletedRows) > 0 ? { ok: true } : { ok: false, reason: "not_found" }; - } -} - -function toMethod(r: { id: string; zone_id: string; name: string; type: string }): ShippingMethod { - return { id: r.id, zoneId: r.zone_id, name: r.name, type: r.type as ShippingMethodType }; -} - -function toRate(r: { - method_id: string; - currency: string; - amount_cents: number; - min_subtotal_cents: number | null; -}): ShippingRate { - return { - methodId: r.method_id, - currency: toCurrency(r.currency), - amountCents: cents(r.amount_cents), - minSubtotalCents: r.min_subtotal_cents === null ? null : cents(r.min_subtotal_cents), - }; -} - -function parseRegions(value: string | null): unknown { - if (value === null) return null; - try { - return JSON.parse(value); - } catch { - return value; - } -} diff --git a/packages/store-postgres/src/kysely-tax-rules-store.ts b/packages/store-postgres/src/kysely-tax-rules-store.ts deleted file mode 100644 index 18ef3786..00000000 --- a/packages/store-postgres/src/kysely-tax-rules-store.ts +++ /dev/null @@ -1,174 +0,0 @@ -import type { - CreateTaxClassInput, - CreateTaxRateInput, - DeleteTaxClassStoreResult, - DeleteTaxRateResult, - TaxClass, - TaxRate, - TaxRulesStore, - UpdateTaxClassInput, - UpdateTaxClassResult, - UpdateTaxRateInput, - UpdateTaxRateResult, -} from "@otta-sh/domain"; -import type { Kysely, Selectable } from "kysely"; -import type { Database, TaxRatesTable } from "./schema.js"; - -/** `TaxRulesStore` over Kysely — dialect-agnostic (better-sqlite3 + pg). */ -export class KyselyTaxRulesStore implements TaxRulesStore { - readonly #db: Kysely; - - constructor(options: { db: Kysely }) { - this.#db = options.db; - } - - async createClass(input: CreateTaxClassInput): Promise { - await this.#db.insertInto("tax_classes").values({ id: input.id, name: input.name }).execute(); - return { id: input.id, name: input.name }; - } - - async listClasses(): Promise { - const rows = await this.#db.selectFrom("tax_classes").selectAll().orderBy("id").execute(); - return rows.map((r) => ({ id: r.id, name: r.name })); - } - - /** - * Delete a tax class (port doc), with the store's own-grain delete-in-use - * guard: the DELETE is conditioned on NO `tax_rate` referencing the class, so - * a concurrent rate insert can never orphan onto a just-deleted class. Zero - * rows deleted ⇒ a fresh read classifies why (unknown id vs still-referenced), - * mirroring the product-commerce store's no-op-then-reread pattern. The - * PRODUCT-reference guard is the `deleteTaxClass` use-case's job (a different - * aggregate). - */ - async deleteClass(id: string): Promise { - const res = await this.#db - .deleteFrom("tax_classes") - .where("id", "=", id) - .where((eb) => - eb.not( - eb.exists( - eb - .selectFrom("tax_rates") - .select("id") - .whereRef("tax_rates.tax_class_id", "=", "tax_classes.id"), - ), - ), - ) - .executeTakeFirst(); - if (Number(res.numDeletedRows) > 0) return { ok: true }; - // Zero rows: either the class does not exist, or a rate still references it. - const exists = await this.#db - .selectFrom("tax_classes") - .select("id") - .where("id", "=", id) - .executeTakeFirst(); - if (exists === undefined) return { ok: false, reason: "not_found" }; - return { ok: false, reason: "in_use_by_rates" }; - } - - /** LWW rename (port doc): the UPDATE is unconditional on `name`; zero rows - * ⇒ unknown id (`not_found`, an edit never mints a row). */ - async updateClass(id: string, input: UpdateTaxClassInput): Promise { - const updated = await this.#db - .updateTable("tax_classes") - .set({ name: input.name }) - .where("id", "=", id) - .returningAll() - .executeTakeFirst(); - if (updated === undefined) return { ok: false, reason: "not_found" }; - return { ok: true, class: { id: updated.id, name: updated.name } }; - } - - /** Count of rates referencing a class (port doc) — the in-use-by-rates - * refusal's honest count, queried only on that failure path. */ - async countRatesByClass(id: string): Promise { - const row = await this.#db - .selectFrom("tax_rates") - .select((eb) => eb.fn.countAll().as("n")) - .where("tax_class_id", "=", id) - .executeTakeFirst(); - return Number(row?.n ?? 0); - } - - async createRate(input: CreateTaxRateInput): Promise { - await this.#db - .insertInto("tax_rates") - .values({ - id: input.id, - tax_class_id: input.taxClassId, - zone_id: input.zoneId, - rate_bps: input.rateBps, - applies_to_shipping: input.appliesToShipping ? 1 : 0, - }) - .execute(); - return { ...input }; - } - - async getRate(taxClassId: string, zoneId: string): Promise { - const r = await this.#db - .selectFrom("tax_rates") - .selectAll() - .where("tax_class_id", "=", taxClassId) - .where("zone_id", "=", zoneId) - .executeTakeFirst(); - return r === undefined ? null : toRate(r); - } - - async listRatesForZone(zoneId: string): Promise { - const rows = await this.#db - .selectFrom("tax_rates") - .selectAll() - .where("zone_id", "=", zoneId) - .orderBy("id") - .execute(); - return rows.map(toRate); - } - - /** - * Guarded edit (port doc): optimistic CAS on the money-bearing `rate_bps`. The - * UPDATE is conditioned on `rate_bps = expectedRateBps`; zero rows updated ⇒ a - * fresh read classifies unknown id (`not_found`) vs a concurrent change - * (`stale`), mirroring `deleteClass`'s no-op-then-reread pattern. - */ - async updateRate( - id: string, - input: UpdateTaxRateInput, - expectedRateBps: number, - ): Promise { - const updated = await this.#db - .updateTable("tax_rates") - .set({ - rate_bps: input.rateBps, - applies_to_shipping: input.appliesToShipping ? 1 : 0, - }) - .where("id", "=", id) - .where("rate_bps", "=", expectedRateBps) - .returningAll() - .executeTakeFirst(); - if (updated !== undefined) return { ok: true, rate: toRate(updated) }; - const current = await this.#db - .selectFrom("tax_rates") - .selectAll() - .where("id", "=", id) - .executeTakeFirst(); - if (current === undefined) return { ok: false, reason: "not_found" }; - return { ok: false, reason: "stale", current: toRate(current) }; - } - - /** Leaf delete (port doc). Zero rows ⇒ `not_found` (idempotent no-op). */ - async deleteRate(id: string): Promise { - const res = await this.#db.deleteFrom("tax_rates").where("id", "=", id).executeTakeFirst(); - return Number(res.numDeletedRows) > 0 ? { ok: true } : { ok: false, reason: "not_found" }; - } -} - -function toRate(r: Selectable): TaxRate { - return { - id: r.id, - taxClassId: r.tax_class_id, - zoneId: r.zone_id, - rateBps: r.rate_bps, - appliesToShipping: r.applies_to_shipping === 1, - }; -} diff --git a/packages/store-postgres/src/migrations/0001_phase0_inventory.ts b/packages/store-postgres/src/migrations/0001_phase0_inventory.ts deleted file mode 100644 index 8b685da7..00000000 --- a/packages/store-postgres/src/migrations/0001_phase0_inventory.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { type Kysely, sql } from "kysely"; -import type { Migration } from "kysely/migration"; - -/** - * Phase-0 forward-only migration (§6): `inventory` + `reservations`. - * Written with the Kysely schema builder so identical, portable DDL emits for - * both dialects. Never edit a shipped migration — correct forward with 0002_…. - */ -export const migration0001PhaseInventory: Migration = { - async up(db: Kysely): Promise { - await db.schema - .createTable("inventory") - .addColumn("sku", "text", (col) => col.primaryKey()) - .addColumn("on_hand", "integer", (col) => col.notNull().check(sql`on_hand >= 0`)) - .execute(); - - await db.schema - .createTable("reservations") - .addColumn("id", "text", (col) => col.primaryKey()) - .addColumn("sku", "text", (col) => col.notNull().references("inventory.sku")) - .addColumn("qty", "integer", (col) => col.notNull().check(sql`qty > 0`)) - .addColumn("state", "text", (col) => col.notNull()) - .addColumn("idempotency_key", "text", (col) => col.notNull().unique()) - .addColumn("created_at", "text", (col) => col.notNull()) - .execute(); - }, -}; diff --git a/packages/store-postgres/src/migrations/0002_product_commerce.ts b/packages/store-postgres/src/migrations/0002_product_commerce.ts deleted file mode 100644 index 4cc98bd5..00000000 --- a/packages/store-postgres/src/migrations/0002_product_commerce.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { type Kysely, sql } from "kysely"; -import type { Migration } from "kysely/migration"; - -/** - * Phase-1 forward-only migration (plan §4/§6 step 4): `product_commerce`, one - * row per product, keyed by the CMS content id (`product_id`). Portable - * types only (text/integer) so identical DDL emits for both dialects — - * mirrors 0001's style. Never edit a shipped migration; correct forward with - * 0003+ (reserved for later phases). (This migration is amended in place - * pre-merge — it has never shipped.) - * - * `sku` and `price_*` are NULLABLE — "create then price" (plan §1 case 3): - * `content:afterSave` may upsert a bare row (product_id only) before any - * commercial data is ever entered. `idempotency_key` is per-row, mutable, - * NOT unique (plan §4 — distinct from Phase 0's `reservations`, which is a - * global UNIQUE claim table). - * - * `sku` uniqueness is a PARTIAL unique index over LIVE rows only - * (`WHERE deleted_at IS NULL`, supported identically on Postgres and - * SQLite) — a hard UNIQUE would permanently lock a soft-deleted product's - * SKU against reuse (review S3; delete-and-recreate is a normal merchant - * flow), while the tombstoned row still retains its sku for order-history - * integrity. The upsert's `ON CONFLICT` target remains the `product_id` PK, - * so the partial index never participates in conflict arbitration — it only - * enforces live-sku uniqueness (a violating write errors, on both dialects). - * - * `content_updated_at` is the sync-ordering watermark (review S1): the CMS - * content's own `updatedAt` last applied by a `content:afterSave` sync. - * Stored as ISO-8601 text, so lexicographic comparison is chronological — - * the store's upsert guard uses it to make a strictly-older (out-of-order / - * delayed) sync a no-op. Null until a sync ever carries one; panel saves - * preserve it. - * - * `active` is stored as portable `integer` 0/1, not a SQL `boolean` — - * better-sqlite3 cannot bind a JS `boolean`, and Phase 0 already established - * "portable types only (text/integer)" across both dialects (schema.ts). - */ -export const migration0002ProductCommerce: Migration = { - async up(db: Kysely): Promise { - await db.schema - .createTable("product_commerce") - .addColumn("product_id", "text", (col) => col.primaryKey()) - .addColumn("sku", "text") - .addColumn("price_cents", "integer", (col) => col.check(sql`price_cents >= 0`)) - .addColumn("price_currency", "text") - .addColumn("tax_class", "text") - .addColumn("weight_grams", "integer") - .addColumn("length_mm", "integer") - .addColumn("width_mm", "integer") - .addColumn("height_mm", "integer") - .addColumn("product_kind", "text", (col) => col.notNull()) - .addColumn("active", "integer", (col) => col.notNull().defaultTo(0)) - .addColumn("deleted_at", "text") - .addColumn("idempotency_key", "text", (col) => col.notNull()) - .addColumn("content_updated_at", "text") - .addColumn("created_at", "text", (col) => col.notNull()) - .addColumn("updated_at", "text", (col) => col.notNull()) - .execute(); - - // Live-rows-only sku uniqueness (see the header comment). Raw predicate: - // Kysely's index builder only offers indexed columns to `where`'s typed - // overload, and the partial-index predicate is over `deleted_at`. - await db.schema - .createIndex("product_commerce_live_sku_unique") - .on("product_commerce") - .column("sku") - .unique() - .where(sql`deleted_at is null`) - .execute(); - }, -}; diff --git a/packages/store-postgres/src/migrations/0003_cart.ts b/packages/store-postgres/src/migrations/0003_cart.ts deleted file mode 100644 index cd809292..00000000 --- a/packages/store-postgres/src/migrations/0003_cart.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { type Kysely, sql } from "kysely"; -import type { Migration } from "kysely/migration"; - -/** - * Phase-3 forward-only migration (§4): the cart schema, plus the hold deadline - * on the existing `reservations` table. Written with the Kysely schema builder - * so identical, portable DDL emits for better-sqlite3 and pg. Never edit a - * shipped migration — correct forward. - * - * Numbered `0003`: `0002` is reserved for Phase 1's `product_commerce`, and this - * migration must not depend on it (its `cart_lines.product_id` is a plain, - * nullable forward hook with no FK to a product table). - */ -export const migration0003Cart: Migration = { - async up(db: Kysely): Promise { - // Add the hold deadline to Phase-0's `reservations` (state already exists — - // not redeclared here). Nullable: a raw `reserve` sets none; the cart stamps - // it, and a crashed hold with none is reaped via `created_at` + TTL. - await db.schema.alterTable("reservations").addColumn("expires_at", "text").execute(); - - await db.schema - .createTable("carts") - .addColumn("id", "text", (col) => col.primaryKey()) - // Forward hook for Phase 5 (anonymous → customer); merge logic is Phase 5. - .addColumn("customer_id", "text") - .addColumn("state", "text", (col) => col.notNull().defaultTo("active")) - .addColumn("currency", "text", (col) => col.notNull()) - .addColumn("created_at", "text", (col) => col.notNull()) - .addColumn("updated_at", "text", (col) => col.notNull()) - .execute(); - - await db.schema - .createTable("cart_lines") - .addColumn("id", "text", (col) => col.primaryKey()) - .addColumn("cart_id", "text", (col) => col.notNull().references("carts.id")) - .addColumn("product_id", "text") - .addColumn("sku", "text", (col) => col.notNull()) - .addColumn("qty", "integer", (col) => col.notNull().check(sql`qty > 0`)) - // Explicitly nullable: a digital line (Phase 4) carries no reservation, so - // Phase 4's design needs no forward-only ALTER. - .addColumn("reservation_id", "text", (col) => col.references("reservations.id")) - .addColumn("expires_at", "text") - .addColumn("created_at", "text", (col) => col.notNull()) - .addColumn("updated_at", "text", (col) => col.notNull()) - // One line per sku — the uniqueness that dedupes an add-to-cart. - .addUniqueConstraint("cart_lines_cart_id_sku_unique", ["cart_id", "sku"]) - .execute(); - - // Dedicated cart-mutation idempotency ledger (§4): one row per cart - // mutation, keyed uniquely on the client's idempotency key. Required (not a - // reuse of `reservations.idempotency_key`, which is already consumed by the - // original reserve and cannot guard the many adjusts over a line's life). - // Claim-first: the row is inserted `completed=0` BEFORE any inventory - // movement and flipped to 1 when the mutation's final write lands — a - // replay of a completed key returns the recorded result; an incomplete one - // resumes the (idempotent) choreography. The pre-movement claim also marks - // a reservation's key as cart-originated, which scopes the sweep's - // dangling-hold fallback to cart holds (raw reserves are never reaped). - await db.schema - .createTable("cart_mutations") - .addColumn("idempotency_key", "text", (col) => col.primaryKey()) - .addColumn("cart_id", "text", (col) => col.notNull()) - .addColumn("line_id", "text") - .addColumn("kind", "text", (col) => col.notNull()) - .addColumn("resulting_qty", "integer") - .addColumn("completed", "integer", (col) => col.notNull().defaultTo(0)) - .addColumn("created_at", "text", (col) => col.notNull()) - .execute(); - - // Per-mutation claim ledger for `InventoryStore.adjust` (exactly-once): - // the claim INSERT and the delta's inventory movement commit in ONE short - // transaction, so only the claim winner moves stock; a replay — even a - // stale one after later same-reservation adjusts — returns the recorded - // outcome instead of recomputing (and re-applying) a delta. - await db.schema - .createTable("inventory_adjustments") - .addColumn("idempotency_key", "text", (col) => col.primaryKey()) - .addColumn("reservation_id", "text", (col) => col.notNull().references("reservations.id")) - .addColumn("to_qty", "integer", (col) => col.notNull().check(sql`to_qty > 0`)) - .addColumn("outcome", "text", (col) => col.notNull()) - .addColumn("created_at", "text", (col) => col.notNull()) - .execute(); - }, -}; diff --git a/packages/store-postgres/src/migrations/0004_product_commerce_active_updated_at.ts b/packages/store-postgres/src/migrations/0004_product_commerce_active_updated_at.ts deleted file mode 100644 index c60740d0..00000000 --- a/packages/store-postgres/src/migrations/0004_product_commerce_active_updated_at.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { Kysely } from "kysely"; -import type { Migration } from "kysely/migration"; - -/** - * Forward-only correction (§ forward-only migrations): add the publish-gate - * ordering watermark `active_updated_at` to `product_commerce`. Written with the - * Kysely schema builder so identical, portable DDL emits for better-sqlite3 and - * pg. Never edit a shipped migration — correct forward. - * - * This column is NOT part of `0002_product_commerce`, which shipped in an earlier - * release without it. Kysely's `Migrator` tracks applied migrations BY NAME and - * skips ones already run regardless of body changes, so amending 0002 in place - * would never reach a database that already ran the original 0002 — the column - * would silently never be added there. This migration adds it forward instead. - * - * `active_updated_at` is the PUBLISH-GATE ordering watermark: the CMS content's - * own `updatedAt` last applied by a winning `activate`/`deactivate`. It is - * DELIBERATELY separate from `content_updated_at` — `activate`/`deactivate` are - * opposing transitions on the same `active` flag delivered by independent - * fire-and-forget hook POSTs, so a stale out-of-order publish/unpublish must be - * gated by a watermark; but a plain `content:afterSave` advances - * `content_updated_at` WITHOUT being a lifecycle event, so reusing that column - * would let a save poison the gate. Nullable, no backfill — NULL is treated as - * `-infinity` by the store guard, so the first lifecycle transition always wins. - * ISO-8601 text (lexicographic = chronological). - */ -export const migration0004ProductCommerceActiveUpdatedAt: Migration = { - async up(db: Kysely): Promise { - await db.schema.alterTable("product_commerce").addColumn("active_updated_at", "text").execute(); - }, - async down(db: Kysely): Promise { - await db.schema.alterTable("product_commerce").dropColumn("active_updated_at").execute(); - }, -}; diff --git a/packages/store-postgres/src/migrations/0005_orders.ts b/packages/store-postgres/src/migrations/0005_orders.ts deleted file mode 100644 index 99f3557d..00000000 --- a/packages/store-postgres/src/migrations/0005_orders.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { type Kysely, sql } from "kysely"; -import type { Migration } from "kysely/migration"; - -/** - * Phase-4 forward-only migration (§4/§10): the order / payment / entitlement - * schema, plus the additive `reservations.order_id` + `adopted` state and the - * additive `product_commerce.title` (the order-line snapshot source). Written - * with the Kysely schema builder so identical portable DDL emits for - * better-sqlite3 and pg. Never edit a shipped migration — correct forward. - * - * Money convention (§4): every amount is `*_cents` (integer minor units) + an - * explicit `currency`. **`orders` carries no money column** — totals live only in - * `order_totals`. - */ -export const migration0005Orders: Migration = { - async up(db: Kysely): Promise { - // Additive: the owning order on a reservation (nullable). `adopted` is a new - // reservation-state VALUE — the state column is plain text (no enum type to - // alter), so no DDL is needed for the value itself; it is invisible to the - // Phase-3 `held`-scoped sweep by construction. - await db.schema.alterTable("reservations").addColumn("order_id", "text").execute(); - - // Additive: the product title the order line snapshots (Phase 4 §4). - await db.schema.alterTable("product_commerce").addColumn("title", "text").execute(); - - // orders — NO money column; keys / state / TTL / identity only (§4). - await db.schema - .createTable("orders") - .addColumn("id", "text", (col) => col.primaryKey()) - .addColumn("cart_id", "text") - .addColumn("currency", "text", (col) => col.notNull()) - .addColumn("state", "text", (col) => col.notNull().defaultTo("pending")) - // Order-creation dedupe (§4): a replay returns the existing order. - .addColumn("idempotency_key", "text", (col) => col.notNull().unique()) - .addColumn("hold_expires_at", "text", (col) => col.notNull()) - .addColumn("payment_method", "text") - .addColumn("buyer_ref", "text", (col) => col.notNull()) - // Phase-5 hook; nullable, populated by Phase 5 (lands exactly once, §4). - .addColumn("customer_id", "text") - .addColumn("reconciliation_flag", "text") - .addColumn("created_at", "text", (col) => col.notNull()) - .addColumn("updated_at", "text", (col) => col.notNull()) - .execute(); - - // order_items — insert-once; price/title/currency are snapshots (§4). - await db.schema - .createTable("order_items") - .addColumn("id", "text", (col) => col.primaryKey()) - .addColumn("order_id", "text", (col) => col.notNull().references("orders.id")) - .addColumn("product_id", "text", (col) => col.notNull()) - .addColumn("sku", "text", (col) => col.notNull()) - .addColumn("title", "text", (col) => col.notNull()) - .addColumn("unit_price_cents", "integer", (col) => - col.notNull().check(sql`unit_price_cents >= 0`), - ) - .addColumn("currency", "text", (col) => col.notNull()) - .addColumn("quantity", "integer", (col) => col.notNull().check(sql`quantity > 0`)) - .addColumn("fulfillment_kind", "text", (col) => col.notNull()) - // Physical only; NULL for digital (digital never reserves, §6). A plain - // nullable link — NOT an FK: the reservation lifecycle is owned by the - // inventory authority, and settle resolves it via the inventory port - // (commit/release), never a join, so a hard FK adds no invariant here and - // only complicates order retention. - .addColumn("reservation_id", "text") - .execute(); - - // order_totals — 1:1 with orders; the authoritative totals home (§4). - await db.schema - .createTable("order_totals") - .addColumn("order_id", "text", (col) => col.primaryKey().references("orders.id")) - .addColumn("currency", "text", (col) => col.notNull()) - .addColumn("subtotal_cents", "integer", (col) => col.notNull()) - .addColumn("discount_cents", "integer", (col) => col.notNull().defaultTo(0)) - .addColumn("shipping_cents", "integer", (col) => col.notNull().defaultTo(0)) - .addColumn("tax_cents", "integer", (col) => col.notNull().defaultTo(0)) - .addColumn("total_cents", "integer", (col) => col.notNull()) - .addColumn("applied_coupon_code", "text") - .addColumn("shipping_method_snapshot", "text") - .addColumn("tax_breakdown", "text") - .execute(); - - // payments — one recorded per settled order (§4). UNIQUE provider_ref makes - // the record idempotent (INSERT … ON CONFLICT DO NOTHING). - await db.schema - .createTable("payments") - .addColumn("id", "text", (col) => col.primaryKey()) - .addColumn("order_id", "text", (col) => col.notNull().references("orders.id")) - .addColumn("gateway", "text", (col) => col.notNull()) - .addColumn("provider_ref", "text", (col) => col.notNull().unique()) - .addColumn("amount_cents", "integer", (col) => col.notNull()) - .addColumn("currency", "text", (col) => col.notNull()) - .addColumn("status", "text", (col) => col.notNull()) - .addColumn("created_at", "text", (col) => col.notNull()) - .execute(); - - // payment_events — dedupe (UNIQUE dedupe_key; NULL for anomaly rows) + the - // anomaly log (§5). A nullable UNIQUE column allows many anomaly rows (all - // NULL dedupe_key) while making a real dedupe_key collide → redelivery no-op. - await db.schema - .createTable("payment_events") - .addColumn("id", "text", (col) => col.primaryKey()) - .addColumn("dedupe_key", "text", (col) => col.unique()) - .addColumn("order_id", "text", (col) => col.notNull()) - .addColumn("gateway", "text", (col) => col.notNull()) - .addColumn("kind", "text") - .addColumn("detail", "text") - .addColumn("received_at", "text", (col) => col.notNull()) - .execute(); - - // entitlements — digital delivery authorization (§6). UNIQUE - // grant_idempotency_key makes the grant idempotent under webhook/proof replay. - await db.schema - .createTable("entitlements") - .addColumn("id", "text", (col) => col.primaryKey()) - // order_id + buyer_ref are the claim keys (§6/§7), not a hard FK. - .addColumn("order_id", "text", (col) => col.notNull()) - .addColumn("product_id", "text") - .addColumn("sku", "text", (col) => col.notNull()) - .addColumn("buyer_ref", "text", (col) => col.notNull()) - .addColumn("state", "text", (col) => col.notNull().defaultTo("active")) - .addColumn("source", "text", (col) => col.notNull()) - .addColumn("granted_at", "text", (col) => col.notNull()) - .addColumn("grant_idempotency_key", "text", (col) => col.notNull().unique()) - .execute(); - }, -}; diff --git a/packages/store-postgres/src/migrations/0006_customers_sessions_outbox.ts b/packages/store-postgres/src/migrations/0006_customers_sessions_outbox.ts deleted file mode 100644 index 1ad52be5..00000000 --- a/packages/store-postgres/src/migrations/0006_customers_sessions_outbox.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { type Kysely, sql } from "kysely"; -import type { Migration } from "kysely/migration"; - -/** - * Phase-5 forward-only migration (§4/§5/§10): storefront customer identity - * (customers, addresses, customer_sessions, login_challenges) and the - * order-status email outbox. Written with the Kysely schema builder so identical - * portable DDL emits for better-sqlite3 and pg. Never edit a shipped migration — - * correct forward. - * - * `orders.customer_id` is **not** migrated here — Phase 4 (0005) already added it - * forward-only; Phase 5 only populates it (on login/claim). No `orders`/ - * `order_items`/`order_totals` column is added or renamed. - */ -export const migration0006CustomersSessionsOutbox: Migration = { - async up(db: Kysely): Promise { - // customers — storefront identity, separate from EmDash ctx.users (§4). - // email is UNIQUE + lower-normalized (the domain Email brand normalizes). - await db.schema - .createTable("customers") - .addColumn("id", "text", (col) => col.primaryKey()) - .addColumn("email", "text", (col) => col.notNull().unique()) - .addColumn("display_name", "text") - .addColumn("email_verified_at", "text") - .addColumn("created_at", "text", (col) => col.notNull()) - .execute(); - - // customer_sessions — opaque DB-backed tokens; token_hash only (§4). No hard - // FK to customers (mirrors order_items/entitlements, plan §4): ownership is a - // scoped lookup, not a join, so a hard FK adds no invariant here. - await db.schema - .createTable("customer_sessions") - .addColumn("id", "text", (col) => col.primaryKey()) - .addColumn("customer_id", "text", (col) => col.notNull()) - .addColumn("token_hash", "text", (col) => col.notNull().unique()) - .addColumn("created_at", "text", (col) => col.notNull()) - .addColumn("expires_at", "text", (col) => col.notNull()) - .addColumn("revoked_at", "text") - .execute(); - - // login_challenges — one-time magic-link tokens; token_hash only, single-use - // via consumed_at (§4). No customer FK: a challenge can precede the account - // (first login creates it on verify). - await db.schema - .createTable("login_challenges") - .addColumn("id", "text", (col) => col.primaryKey()) - .addColumn("email", "text", (col) => col.notNull()) - .addColumn("token_hash", "text", (col) => col.notNull()) - .addColumn("created_at", "text", (col) => col.notNull()) - .addColumn("expires_at", "text", (col) => col.notNull()) - .addColumn("consumed_at", "text") - .execute(); - - // addresses — customer-scoped address book (§4). No hard FK (same rationale - // as customer_sessions): every port method filters by customer_id, so the - // scoping — not a referential constraint — is the isolation guarantee. - // is_default is portable 0/1. - await db.schema - .createTable("addresses") - .addColumn("id", "text", (col) => col.primaryKey()) - .addColumn("customer_id", "text", (col) => col.notNull()) - .addColumn("kind", "text", (col) => col.notNull()) - .addColumn("name", "text", (col) => col.notNull()) - .addColumn("line1", "text", (col) => col.notNull()) - .addColumn("line2", "text") - .addColumn("city", "text", (col) => col.notNull()) - .addColumn("region", "text") - .addColumn("postal_code", "text", (col) => col.notNull()) - .addColumn("country", "text", (col) => col.notNull()) - .addColumn("is_default", "integer", (col) => col.notNull().defaultTo(0)) - .addColumn("created_at", "text", (col) => col.notNull()) - .execute(); - - // order_emails_outbox — exactly-once email enqueue + claim (§5). The guarded - // state UPDATE and this INSERT commit in one transaction; UNIQUE(order_id, - // to_state) makes the enqueue idempotent under retry/redelivery. - await db.schema - .createTable("order_emails_outbox") - .addColumn("id", "text", (col) => col.primaryKey()) - .addColumn("order_id", "text", (col) => col.notNull().references("orders.id")) - .addColumn("to_state", "text", (col) => col.notNull()) - .addColumn("status", "text", (col) => col.notNull().defaultTo("pending")) - .addColumn("attempts", "integer", (col) => col.notNull().defaultTo(0)) - .addColumn("lease_until", "text") - .addColumn("sent_at", "text") - .addColumn("created_at", "text", (col) => col.notNull()) - .addUniqueConstraint("order_emails_outbox_order_state_uk", ["order_id", "to_state"]) - .execute(); - - // Index the claim predicate's hot path (pending rows in creation order). - await sql`CREATE INDEX order_emails_outbox_dispatch_idx ON order_emails_outbox (status, created_at)`.execute( - db, - ); - }, -}; diff --git a/packages/store-postgres/src/migrations/0007_shipping_tax_coupons.ts b/packages/store-postgres/src/migrations/0007_shipping_tax_coupons.ts deleted file mode 100644 index 0df8f143..00000000 --- a/packages/store-postgres/src/migrations/0007_shipping_tax_coupons.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { type Kysely, sql } from "kysely"; -import type { Migration } from "kysely/migration"; - -/** - * Phase-6 forward-only migration (§5): shipping zones/methods/rates, tax - * classes/rates, and coupons + coupon_redemptions. Written with the Kysely schema - * builder so identical portable DDL emits for better-sqlite3 and pg. Never edit a - * shipped migration — correct forward. - * - * Money convention (§4): every amount is `*_cents` (integer minor units); every - * rate is `*_bps` (integer basis points). `order_totals` is NOT touched here — - * per the Phase-4 canonical schema it already exists; Phase 6 only writes richer - * values into its existing columns. - */ -export const migration0007ShippingTaxCoupons: Migration = { - async up(db: Kysely): Promise { - // -- Shipping -------------------------------------------------------------- - await db.schema - .createTable("shipping_zones") - .addColumn("id", "text", (col) => col.primaryKey()) - .addColumn("name", "text", (col) => col.notNull()) - .addColumn("regions", "text") // opaque JSON-as-text match list - .execute(); - - await db.schema - .createTable("shipping_methods") - .addColumn("id", "text", (col) => col.primaryKey()) - .addColumn("zone_id", "text", (col) => col.notNull().references("shipping_zones.id")) - .addColumn("name", "text", (col) => col.notNull()) - .addColumn("type", "text", (col) => col.notNull()) - .execute(); - - await db.schema - .createTable("shipping_rates") - .addColumn("method_id", "text", (col) => col.notNull().references("shipping_methods.id")) - .addColumn("currency", "text", (col) => col.notNull()) - .addColumn("amount_cents", "integer", (col) => col.notNull().check(sql`amount_cents >= 0`)) - .addColumn("min_subtotal_cents", "integer", (col) => col.check(sql`min_subtotal_cents >= 0`)) - // One rate per (method, currency). - .addPrimaryKeyConstraint("shipping_rates_pk", ["method_id", "currency"]) - .execute(); - - // -- Tax ------------------------------------------------------------------- - await db.schema - .createTable("tax_classes") - .addColumn("id", "text", (col) => col.primaryKey()) - .addColumn("name", "text", (col) => col.notNull()) - .execute(); - - await db.schema - .createTable("tax_rates") - .addColumn("id", "text", (col) => col.primaryKey()) - .addColumn("tax_class_id", "text", (col) => col.notNull()) - .addColumn("zone_id", "text", (col) => col.notNull()) - .addColumn("rate_bps", "integer", (col) => col.notNull().check(sql`rate_bps >= 0`)) - // Portable 0/1 — better-sqlite3 cannot bind a JS boolean. - .addColumn("applies_to_shipping", "integer", (col) => col.notNull().defaultTo(0)) - .execute(); - - // -- Coupons --------------------------------------------------------------- - await db.schema - .createTable("coupons") - .addColumn("id", "text", (col) => col.primaryKey()) - .addColumn("code", "text", (col) => col.notNull().unique()) - .addColumn("type", "text", (col) => col.notNull()) - .addColumn("amount_cents", "integer", (col) => col.check(sql`amount_cents >= 0`)) - .addColumn("rate_bps", "integer", (col) => col.check(sql`rate_bps >= 0`)) - .addColumn("cap_cents", "integer", (col) => col.check(sql`cap_cents >= 0`)) - .addColumn("currency", "text") - .addColumn("min_subtotal_cents", "integer", (col) => col.check(sql`min_subtotal_cents >= 0`)) - .addColumn("starts_at", "text") - .addColumn("expires_at", "text") - .addColumn("max_uses", "integer", (col) => col.check(sql`max_uses >= 0`)) - .addColumn("max_uses_per_customer", "integer", (col) => - col.check(sql`max_uses_per_customer >= 0`), - ) - // The atomic redemption guard reads/writes this; CHECK keeps it non-negative. - .addColumn("uses_count", "integer", (col) => - col - .notNull() - .defaultTo(0) - .check(sql`uses_count >= 0`), - ) - .execute(); - - await db.schema - .createTable("coupon_redemptions") - .addColumn("id", "text", (col) => col.primaryKey()) - .addColumn("coupon_id", "text", (col) => col.notNull().references("coupons.id")) - .addColumn("order_id", "text", (col) => col.notNull()) - .addColumn("customer_id", "text") - .addColumn("idempotency_key", "text", (col) => col.notNull()) - .addColumn("created_at", "text", (col) => col.notNull()) - // Replay of the same checkout is a no-op re-read (mirrors reservations). - .addUniqueConstraint("coupon_redemptions_coupon_key", ["coupon_id", "idempotency_key"]) - .execute(); - }, -}; diff --git a/packages/store-postgres/src/migrations/0008_settings_and_reporting_indices.ts b/packages/store-postgres/src/migrations/0008_settings_and_reporting_indices.ts deleted file mode 100644 index 63faf772..00000000 --- a/packages/store-postgres/src/migrations/0008_settings_and_reporting_indices.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { Kysely } from "kysely"; -import type { Migration } from "kysely/migration"; - -/** - * Phase-7 forward-only migration (§7 Step 3). Adds the service-DB `settings` - * tier (single typed row + an idempotency ledger) and the reporting indices from - * §4.2. Written with the Kysely schema builder so identical portable DDL emits - * for better-sqlite3 and pg. Never edit a shipped migration — correct forward. - * - * Reporting itself is pure read-side over existing tables (orders / order_totals - * / order_items / inventory) and needs no schema — only the indices below, cheap - * insurance against full scans as volume grows (not a performance target). - * `order_totals` needs no extra index: its PK (`order_id`) is already the join key. - */ -export const migration0008SettingsAndReportingIndices: Migration = { - async up(db: Kysely): Promise { - // -- settings (single row) -------------------------------------------------- - await db.schema - .createTable("settings") - .addColumn("id", "text", (col) => col.primaryKey()) - .addColumn("hold_ttl_minutes", "integer", (col) => col.notNull()) - .addColumn("low_stock_threshold", "integer", (col) => col.notNull()) - .addColumn("updated_at", "text", (col) => col.notNull()) - .execute(); - - // -- settings idempotency ledger -------------------------------------------- - await db.schema - .createTable("settings_mutations") - .addColumn("idempotency_key", "text", (col) => col.primaryKey()) - .addColumn("hold_ttl_minutes", "integer", (col) => col.notNull()) - .addColumn("low_stock_threshold", "integer", (col) => col.notNull()) - .addColumn("created_at", "text", (col) => col.notNull()) - .execute(); - - // -- reporting indices (§4.2) ------------------------------------------------ - await db.schema - .createIndex("idx_orders_created_state") - .ifNotExists() - .on("orders") - .columns(["created_at", "state"]) - .execute(); - - await db.schema - .createIndex("idx_order_items_order_product") - .ifNotExists() - .on("order_items") - .columns(["order_id", "product_id"]) - .execute(); - - await db.schema - .createIndex("idx_inventory_on_hand") - .ifNotExists() - .on("inventory") - .columns(["on_hand"]) - .execute(); - }, -}; diff --git a/packages/store-postgres/src/migrations/0009_orders_admin_list_indices.ts b/packages/store-postgres/src/migrations/0009_orders_admin_list_indices.ts deleted file mode 100644 index e67667f5..00000000 --- a/packages/store-postgres/src/migrations/0009_orders_admin_list_indices.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { Kysely } from "kysely"; -import type { Migration } from "kysely/migration"; - -/** - * Forward-only migration for the admin Orders console list (view-only). Adds a - * composite index on `orders(created_at, id)` — the exact keyset order the admin - * list paginates on (`ORDER BY created_at DESC, id DESC` with a - * `(created_at, id)` cursor predicate). Cheap insurance against a full scan as - * order volume grows, not a performance target. - * - * Up-only, matching every prior migration (CLAUDE.md: migrations are - * forward-only). `ifNotExists` mirrors `0008`'s builder style so a re-run is a - * no-op, and the identical portable DDL emits for better-sqlite3 and pg. - */ -export const migration0009OrdersAdminListIndices: Migration = { - async up(db: Kysely): Promise { - await db.schema - .createIndex("idx_orders_created_id") - .ifNotExists() - .on("orders") - .columns(["created_at", "id"]) - .execute(); - }, -}; diff --git a/packages/store-postgres/src/migrations/0010_order_notes.ts b/packages/store-postgres/src/migrations/0010_order_notes.ts deleted file mode 100644 index 6afff6db..00000000 --- a/packages/store-postgres/src/migrations/0010_order_notes.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { type Kysely, sql } from "kysely"; -import type { Migration } from "kysely/migration"; - -/** - * Forward-only migration for order notes (admin-UX Increment 0 — the walking - * skeleton's smallest full slice). Append-only merchant annotations on an order's - * mutable envelope: `{author, body, created_at}`, guarded by `idempotency_key` - * UNIQUE so a replayed append inserts exactly once. - * - * Written with the Kysely schema builder so identical portable DDL emits for - * better-sqlite3 and pg (CLAUDE.md: migrations are forward-only; never edit a - * shipped one). No hard FK to `orders` — same rationale as `customer_sessions`/ - * `addresses` (0006): every read is scoped by `order_id`, and the - * `appendOrderNote` use-case enforces order existence, so the scoping (not a - * referential constraint) is the integrity guarantee. The composite index on - * `(order_id, created_at, id)` is exactly the `listForOrder` order - * (`WHERE order_id = ? ORDER BY created_at ASC, id ASC`). - */ -export const migration0010OrderNotes: Migration = { - async up(db: Kysely): Promise { - await db.schema - .createTable("order_notes") - .addColumn("id", "text", (col) => col.primaryKey()) - .addColumn("order_id", "text", (col) => col.notNull()) - .addColumn("author", "text", (col) => col.notNull()) - .addColumn("body", "text", (col) => col.notNull()) - .addColumn("idempotency_key", "text", (col) => col.notNull().unique()) - .addColumn("created_at", "text", (col) => col.notNull()) - .execute(); - - await sql`CREATE INDEX order_notes_list_idx ON order_notes (order_id, created_at, id)`.execute( - db, - ); - }, -}; diff --git a/packages/store-postgres/src/migrations/0011_reconciliation_resolution.ts b/packages/store-postgres/src/migrations/0011_reconciliation_resolution.ts deleted file mode 100644 index 706e3264..00000000 --- a/packages/store-postgres/src/migrations/0011_reconciliation_resolution.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { Kysely } from "kysely"; -import type { Migration } from "kysely/migration"; - -/** - * Forward-only migration for the reconciliation-resolution slice (admin-UX - * Increment 1). The `orders.reconciliation_flag` column already marks an order - * that settle could not auto-settle (a lost hold / a paid-flip loss); it was - * WRITE-ONLY until now. This adds the four nullable columns that record an admin's - * disposition when they RESOLVE that flag — cleared atomically with the flag in a - * single guarded UPDATE (`resolveReconciliation`): - * - `reconciliation_outcome` — 'refunded' | 'fulfilled' | 'written_off' - * - `reconciliation_reason` — free-text justification - * - `reconciliation_resolved_by` — who resolved it (free text, like a note author) - * - `reconciliation_resolved_at` — ISO-8601 UTC timestamp (text, like the other - * order timestamps — lexical order == chronological) - * - * All nullable + additive: existing rows read as `null` (never flagged / not yet - * resolved), and no shipped migration is edited (CLAUDE.md: forward-only). The - * Kysely schema builder emits identical portable DDL for better-sqlite3 and pg. - * No CHECK constraint on the outcome value — the enum is enforced in the domain - * use-case + the service's zod schema (the domain, not the DB, owns legality). - */ -export const migration0011ReconciliationResolution: Migration = { - async up(db: Kysely): Promise { - // One ADD COLUMN per ALTER TABLE — SQLite rejects multiple column additions - // in a single statement (pg accepts it, but keeping them separate stays - // dialect-identical, CLAUDE.md portable-DDL discipline). - for (const column of [ - "reconciliation_outcome", - "reconciliation_reason", - "reconciliation_resolved_by", - "reconciliation_resolved_at", - ] as const) { - await db.schema.alterTable("orders").addColumn(column, "text").execute(); - } - }, -}; diff --git a/packages/store-postgres/src/migrations/0012_order_fulfillment.ts b/packages/store-postgres/src/migrations/0012_order_fulfillment.ts deleted file mode 100644 index f0708111..00000000 --- a/packages/store-postgres/src/migrations/0012_order_fulfillment.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { Kysely } from "kysely"; -import type { Migration } from "kysely/migration"; - -/** - * Forward-only migration for the order-fulfillment slice (admin-UX Increment 1). - * Adds the six nullable columns that record an order's shipping fulfillment — - * written atomically with the `processing → shipped` transition by - * `recordFulfillment` (which makes the shipped-notification email carry tracking - * instead of being empty): - * - `fulfillment_carrier` — shipping carrier (free text) - * - `fulfillment_tracking_number` — carrier tracking number (free text) - * - `fulfillment_tracking_url` — optional carrier tracking URL - * - `fulfillment_shipped_at` — ISO-8601 UTC ship time (admin or store clock) - * - `fulfillment_recorded_by` — who recorded it (free text, like a note author) - * - `fulfillment_recorded_at` — ISO-8601 UTC record timestamp (presence witness) - * - * All nullable + additive: existing rows read as `null` (never fulfilled), and no - * shipped migration is edited (CLAUDE.md: forward-only). The Kysely schema builder - * emits identical portable DDL for better-sqlite3 and pg. Timestamps are text - * (like the other order timestamps — lexical order == chronological). Single-slot: - * this domain ships an order once, so one set of columns, not a child table. - */ -export const migration0012OrderFulfillment: Migration = { - async up(db: Kysely): Promise { - // One ADD COLUMN per ALTER TABLE — SQLite rejects multiple column additions - // in a single statement (pg accepts it, but keeping them separate stays - // dialect-identical, CLAUDE.md portable-DDL discipline). - for (const column of [ - "fulfillment_carrier", - "fulfillment_tracking_number", - "fulfillment_tracking_url", - "fulfillment_shipped_at", - "fulfillment_recorded_by", - "fulfillment_recorded_at", - ] as const) { - await db.schema.alterTable("orders").addColumn(column, "text").execute(); - } - }, -}; diff --git a/packages/store-postgres/src/migrations/0013_order_cancellation.ts b/packages/store-postgres/src/migrations/0013_order_cancellation.ts deleted file mode 100644 index 6a5a3a77..00000000 --- a/packages/store-postgres/src/migrations/0013_order_cancellation.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { Kysely } from "kysely"; -import type { Migration } from "kysely/migration"; - -/** - * Forward-only migration for the order-cancellation slice (admin-UX Increment - * 1, "cancel with reason"). Adds the four nullable columns that record an - * order's structured cancellation — written atomically with the - * `{pending,paid,processing} → cancelled` transition by `cancelOrder` (which - * makes the cancelled-notification email carry WHY instead of a reason-free - * notice): - * - `cancellation_reason` — the structured reason enum (free text) - * - `cancellation_detail` — optional free-text elaboration - * - `cancellation_cancelled_by` — who cancelled it (free text, like a note author) - * - `cancellation_cancelled_at` — ISO-8601 UTC record timestamp (presence witness) - * - * All nullable + additive: existing rows read as `null` (no reason recorded — - * including every order already cancelled via the bare `transition` before this - * slice, an honest back-compat state), and no shipped migration is edited - * (CLAUDE.md: forward-only). The Kysely schema builder emits identical portable - * DDL for better-sqlite3 and pg. Single-slot: this domain cancels an order once - * (terminal state), so one set of columns, not a child table. - */ -export const migration0013OrderCancellation: Migration = { - async up(db: Kysely): Promise { - // One ADD COLUMN per ALTER TABLE — SQLite rejects multiple column additions - // in a single statement (pg accepts it, but keeping them separate stays - // dialect-identical, CLAUDE.md portable-DDL discipline). - for (const column of [ - "cancellation_reason", - "cancellation_detail", - "cancellation_cancelled_by", - "cancellation_cancelled_at", - ] as const) { - await db.schema.alterTable("orders").addColumn(column, "text").execute(); - } - }, -}; diff --git a/packages/store-postgres/src/migrations/0014_order_events.ts b/packages/store-postgres/src/migrations/0014_order_events.ts deleted file mode 100644 index 16e58432..00000000 --- a/packages/store-postgres/src/migrations/0014_order_events.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { type Kysely, sql } from "kysely"; -import type { Migration } from "kysely/migration"; - -/** - * Forward-only migration for the order timeline / audit slice (admin-UX - * Increment 1, timeline slice). Adds the append-only `order_events` table — one - * row per durable state change, INSERTed inside the SAME guarded-flip - * transaction that moves the order (the `#flipAndEnqueue` choke point in - * `KyselyOrderStore`), so an event exists iff the flip won (a replay / lost race - * writes none). Columns: - * - `id` — event id (store idGen) - * - `order_id` — the order the event belongs to - * - `at` — ISO-8601 UTC record timestamp (store clock at the flip) - * - `kind` — currently always `'state_change'` (text ⇒ new kinds need no DDL) - * - `from_state` — the state left (nullable) - * - `to_state` — the state entered (nullable) - * - `actor` — who triggered it when known (recorder/canceller); nullable - * - * Written with the Kysely schema builder so identical portable DDL emits for - * better-sqlite3 and pg (CLAUDE.md: migrations are forward-only; never edit a - * shipped one). No hard FK to `orders` — same rationale as `order_notes` (0010): - * every read is scoped by `order_id`, so the scoping (not a referential - * constraint) is the integrity guarantee. The composite index on `(order_id, at, - * id)` is exactly the `listEventsForOrder` order (`WHERE order_id = ? ORDER BY at - * ASC, id ASC`). Orders that transitioned BEFORE this migration have no events — - * the timeline read-model degrades gracefully (it merges the order's derived - * artifacts for those). - */ -export const migration0014OrderEvents: Migration = { - async up(db: Kysely): Promise { - await db.schema - .createTable("order_events") - .addColumn("id", "text", (col) => col.primaryKey()) - .addColumn("order_id", "text", (col) => col.notNull()) - .addColumn("at", "text", (col) => col.notNull()) - .addColumn("kind", "text", (col) => col.notNull()) - .addColumn("from_state", "text") - .addColumn("to_state", "text") - .addColumn("actor", "text") - .execute(); - - await sql`CREATE INDEX order_events_list_idx ON order_events (order_id, at, id)`.execute(db); - }, -}; diff --git a/packages/store-postgres/src/migrations/0015_product_commerce_admin_list_indices.ts b/packages/store-postgres/src/migrations/0015_product_commerce_admin_list_indices.ts deleted file mode 100644 index 1bff2ac4..00000000 --- a/packages/store-postgres/src/migrations/0015_product_commerce_admin_list_indices.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { Kysely } from "kysely"; -import type { Migration } from "kysely/migration"; - -/** - * Forward-only migration for the admin Products console list (view-only, - * admin-UX Increment 2). Adds a composite index on - * `product_commerce(created_at, product_id)` — the exact keyset order the - * admin list paginates on (`ORDER BY created_at DESC, product_id DESC` with a - * `(created_at, product_id)` cursor predicate), mirroring `0009`'s - * `orders(created_at, id)` index for the identical reason. Cheap insurance - * against a full scan as catalog size grows, not a performance target. - * - * Up-only, matching every prior migration (CLAUDE.md: migrations are - * forward-only). `ifNotExists` mirrors `0009`'s builder style so a re-run is a - * no-op, and the identical portable DDL emits for better-sqlite3 and pg. - */ -export const migration0015ProductCommerceAdminListIndices: Migration = { - async up(db: Kysely): Promise { - await db.schema - .createIndex("idx_product_commerce_created_id") - .ifNotExists() - .on("product_commerce") - .columns(["created_at", "product_id"]) - .execute(); - }, -}; diff --git a/packages/store-postgres/src/migrations/0016_inventory_stock_movements.ts b/packages/store-postgres/src/migrations/0016_inventory_stock_movements.ts deleted file mode 100644 index 71559ccc..00000000 --- a/packages/store-postgres/src/migrations/0016_inventory_stock_movements.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { type Kysely, sql } from "kysely"; -import type { Migration } from "kysely/migration"; - -/** - * Forward-only migration for the merchant stock-movement ledger (admin-UX - * Increment 2 — the restock slice). Per-mutation claim ledger for - * `InventoryStore.restock`/`removeStock`, the admin analogue of - * `inventory_adjustments` (which is reservation-scoped): this one is - * bare-sku-scoped. The claim INSERT and the guarded inventory movement commit in - * ONE short transaction, so exactly one caller per key moves stock and a replay - * returns the recorded outcome instead of re-applying a delta. - * - * Portable Kysely DDL (identical for better-sqlite3 and pg; CLAUDE.md: migrations - * are forward-only, never edit a shipped one). No hard FK to `inventory.sku` — - * an UNKNOWN_SKU movement is handled in-app (the guarded UPDATE matches 0 rows - * and the claim rolls back, mirroring `reserve`'s unknown-sku parity), so an FK - * abort is neither needed nor wanted. `qty > 0` is checked at the column. - */ -export const migration0016InventoryStockMovements: Migration = { - async up(db: Kysely): Promise { - await db.schema - .createTable("inventory_stock_movements") - .addColumn("idempotency_key", "text", (col) => col.primaryKey()) - .addColumn("sku", "text", (col) => col.notNull()) - .addColumn("direction", "text", (col) => col.notNull()) - .addColumn("qty", "integer", (col) => col.notNull().check(sql`qty > 0`)) - .addColumn("outcome", "text", (col) => col.notNull()) - .addColumn("result_on_hand", "integer", (col) => col.notNull()) - .addColumn("created_at", "text", (col) => col.notNull()) - .execute(); - }, -}; diff --git a/packages/store-postgres/src/migrations/0017_product_commerce_data_model_adds.ts b/packages/store-postgres/src/migrations/0017_product_commerce_data_model_adds.ts deleted file mode 100644 index 1fff3b43..00000000 --- a/packages/store-postgres/src/migrations/0017_product_commerce_data_model_adds.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { type Kysely, sql } from "kysely"; -import type { Migration } from "kysely/migration"; - -/** - * Forward-only migration (product data-model adds, admin-UX Increment 2 slice - * 5): four merchant-standard commercial fields on `product_commerce`, added - * additively — every column is nullable or DEFAULTed so existing rows migrate - * with no backfill and the CMS-sync upsert (which never writes these) keeps - * working unchanged. - * - * - `compare_at_cents` / `compare_at_currency` — the optional struck-through - * "was" price. Nullable; `compare_at_cents >= 0` (a CHECK, mirroring - * `price_cents`). Shares the row's price currency (enforced in the store's - * edit guard, not by DDL — a cross-column currency rule is application-level). - * - `unit_cost_cents` / `unit_cost_currency` — the optional ADMIN-ONLY unit - * cost. Same nullable + non-negative CHECK shape. Never serialized on a - * storefront-facing read path (enforced in the service, pinned by a test). - * - `inventory_policy` — the out-of-stock policy. `text NOT NULL DEFAULT - * 'deny'` — `'deny'` is the ONLY value this slice ships (no-oversell is - * non-negotiable; backorders are a future slice). Stored as text (not an - * enum) for the same portable-types-only discipline the rest of this table - * follows (better-sqlite3 has no native enum); the value set is bounded by - * the domain `InventoryPolicy` union + the service zod enum, not the DB. - * - * Portable types only (text/integer) so identical DDL emits for better-sqlite3 - * and pg. Never edit a shipped migration — this is a new one. - */ -export const migration0017ProductCommerceDataModelAdds: Migration = { - async up(db: Kysely): Promise { - await db.schema - .alterTable("product_commerce") - .addColumn("compare_at_cents", "integer", (col) => col.check(sql`compare_at_cents >= 0`)) - .execute(); - await db.schema - .alterTable("product_commerce") - .addColumn("compare_at_currency", "text") - .execute(); - await db.schema - .alterTable("product_commerce") - .addColumn("unit_cost_cents", "integer", (col) => col.check(sql`unit_cost_cents >= 0`)) - .execute(); - await db.schema - .alterTable("product_commerce") - .addColumn("unit_cost_currency", "text") - .execute(); - await db.schema - .alterTable("product_commerce") - .addColumn("inventory_policy", "text", (col) => col.notNull().defaultTo("deny")) - .execute(); - }, -}; diff --git a/packages/store-postgres/src/migrations/0018_coupons_admin_list.ts b/packages/store-postgres/src/migrations/0018_coupons_admin_list.ts deleted file mode 100644 index 329f3c90..00000000 --- a/packages/store-postgres/src/migrations/0018_coupons_admin_list.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { Kysely } from "kysely"; -import type { Migration } from "kysely/migration"; - -/** - * Forward-only migration for the admin Coupons console list (view-only, - * admin-UX Increment 3, "coupon enumerate + coupon list"). `coupons` shipped in - * `0007_shipping_tax_coupons.ts` with NO `created_at` column — this migration - * adds it forward (mirrors `0004_product_commerce_active_updated_at.ts`'s - * additive-column precedent) and indexes it for the keyset list (mirrors - * `0015_product_commerce_admin_list_indices.ts`'s `(created_at, id)` index). - * Written with the Kysely schema builder so identical, portable DDL emits for - * better-sqlite3 and pg. Never edit a shipped migration — correct forward. - * - * `NOT NULL DEFAULT '1970-01-01T00:00:00.000Z'`, not nullable: `created_at` is - * the keyset SORT KEY (`ORDER BY created_at DESC, id DESC`), and pg (NULLS - * FIRST in DESC by default) and better-sqlite3 (NULLS treated as the smallest - * value, so NULLS LAST in DESC) order NULLs OPPOSITELY — a nullable column here - * would make `listCoupons` disagree across dialects for any pre-migration row. - * The sentinel epoch default sorts any such row to the very end, deterministic - * on both dialects, with no NULLS-LAST clause needed. `KyselyCouponStore.create` - * stamps a REAL value (the injected `Clock`) for every coupon minted from here - * on, so the sentinel is only ever hit by a row this migration finds already - * in place. - */ -export const migration0018CouponsAdminList: Migration = { - async up(db: Kysely): Promise { - await db.schema - .alterTable("coupons") - .addColumn("created_at", "text", (col) => col.notNull().defaultTo("1970-01-01T00:00:00.000Z")) - .execute(); - await db.schema - .createIndex("idx_coupons_created_id") - .ifNotExists() - .on("coupons") - .columns(["created_at", "id"]) - .execute(); - }, -}; diff --git a/packages/store-postgres/src/migrations/0019_order_shipping_address.ts b/packages/store-postgres/src/migrations/0019_order_shipping_address.ts deleted file mode 100644 index eadf8441..00000000 --- a/packages/store-postgres/src/migrations/0019_order_shipping_address.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { Kysely } from "kysely"; -import type { Migration } from "kysely/migration"; - -/** - * Forward-only migration for checkout address capture (ADR-0009). Creates the 1:1 - * `order_shipping_address` table — the immutable shipping-address snapshot frozen - * onto an order at creation, mirroring `order_totals`: - * - `order_id` — PK + FK to `orders.id` (1:1; a row exists iff a ship-to was captured) - * - `name` — recipient name (required) - * - `line1` — street line 1 (required) - * - `line2` — street line 2 (optional) - * - `city` — city (required) - * - `region` — state/province (optional) - * - `postal_code` — postal/ZIP code (required) - * - `country` — country (required; free string, no zone matching — ADR-0009 §5) - * - `email` — optional contact channel - * - `phone` — optional contact channel - * - * Additive + non-breaking: no existing order gets a row (historical orders keep an - * honest "no ship-to on file" state via the left join reading `null`), and no - * shipped migration is edited (CLAUDE.md: forward-only). The Kysely schema builder - * emits identical portable DDL for better-sqlite3 and pg. The snapshot is - * insert-once — no code path ever UPDATEs this table (immutability is structural, - * the `order_items`/`order_totals` precedent). - */ -export const migration0019OrderShippingAddress: Migration = { - async up(db: Kysely): Promise { - await db.schema - .createTable("order_shipping_address") - .addColumn("order_id", "text", (col) => col.primaryKey().references("orders.id")) - .addColumn("name", "text", (col) => col.notNull()) - .addColumn("line1", "text", (col) => col.notNull()) - .addColumn("line2", "text") - .addColumn("city", "text", (col) => col.notNull()) - .addColumn("region", "text") - .addColumn("postal_code", "text", (col) => col.notNull()) - .addColumn("country", "text", (col) => col.notNull()) - .addColumn("email", "text") - .addColumn("phone", "text") - .execute(); - }, -}; diff --git a/packages/store-postgres/src/migrations/0020_refunds.ts b/packages/store-postgres/src/migrations/0020_refunds.ts deleted file mode 100644 index cc1f2f04..00000000 --- a/packages/store-postgres/src/migrations/0020_refunds.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { type Kysely, sql } from "kysely"; -import type { Migration } from "kysely/migration"; - -/** - * Forward-only migration for the order-refunds slice (ADR-0008). Adds the - * append-only `refunds` ledger — the source of "how much came back" for an order, - * keyed to the order but NEVER touching the frozen snapshot - * (`order_items`/`order_totals`). Each row: - * - `id` — refund id (store idGen) - * - `order_id` — the order refunded - * - `amount_cents` — the refund amount (integer minor units) - * - `currency` — ISO-4217, the order's currency - * - `kind` — 'gateway' (money moved via the provider) | 'manual' - * (an out-of-band return the admin recorded — x402's path) - * - `gateway` — 'stripe' | 'x402' - * - `refund_ref` — provider refund id (gateway) or null (manual) - * - `reason` — optional free-text reason (nullable) - * - `refunded_by` — who issued/recorded it - * - `idempotency_key` — UNIQUE: the ledger dedupe AND (gateway) Stripe's native key - * - `status` — reserve-before-issue lifecycle (ADR-0008): 'recorded' - * (finalized — money moved / manual record), 'reserved' - * (slot held, gateway leg not yet confirmed), 'unverified' - * (ambiguous gateway outcome — capacity HELD pending a - * human re-check), 'voided' (gateway definitively did not - * issue — capacity RELEASED, kept as an audit row). The - * ceiling counts every non-'voided' row; the '→ refunded' - * flip counts 'recorded' only. Defaults to 'recorded' so - * the manual one-shot `recordRefund` path needs no change. - * - `created_at` — ISO-8601 UTC (store clock) - * - * The ceiling `Σ ACTIVE refunds ≤ min(Σ captured payments, order_totals.total)` - * (ACTIVE = every non-'voided' row: finalized rows AND held reservations) is - * enforced in the domain/adapter guarded write (reserve/record locks the order - * row, re-reads the sums, then inserts + — on finalize — optionally flips - * `→ refunded`), NOT by a DB CHECK — the ceiling depends on live payment sums a - * column constraint cannot see. `UNIQUE(idempotency_key)` is the structural - * once-only backstop. No hard FK - * to `orders` — same rationale as `order_notes`/`order_events`: every read is - * scoped by `order_id`. The composite index on `(order_id, created_at, id)` is - * exactly the `listRefunds` order. Portable DDL via the Kysely schema builder so - * better-sqlite3 and pg emit identically (CLAUDE.md: forward-only). - */ -export const migration0020Refunds: Migration = { - async up(db: Kysely): Promise { - await db.schema - .createTable("refunds") - .addColumn("id", "text", (col) => col.primaryKey()) - .addColumn("order_id", "text", (col) => col.notNull()) - .addColumn("amount_cents", "integer", (col) => col.notNull()) - .addColumn("currency", "text", (col) => col.notNull()) - .addColumn("kind", "text", (col) => col.notNull()) - .addColumn("gateway", "text", (col) => col.notNull()) - .addColumn("refund_ref", "text") - .addColumn("reason", "text") - .addColumn("refunded_by", "text", (col) => col.notNull()) - .addColumn("idempotency_key", "text", (col) => col.notNull().unique()) - // Reserve-before-issue lifecycle (ADR-0008). Defaults to 'recorded' so the - // manual one-shot path (which never sets it) reads as finalized. - .addColumn("status", "text", (col) => col.notNull().defaultTo("recorded")) - .addColumn("created_at", "text", (col) => col.notNull()) - .execute(); - - await sql`CREATE INDEX refunds_order_idx ON refunds (order_id, created_at, id)`.execute(db); - }, -}; diff --git a/packages/store-postgres/src/migrations/0021_cart_order_id.ts b/packages/store-postgres/src/migrations/0021_cart_order_id.ts deleted file mode 100644 index d46d0983..00000000 --- a/packages/store-postgres/src/migrations/0021_cart_order_id.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { Kysely } from "kysely"; -import type { Migration } from "kysely/migration"; - -/** - * Forward-only migration for issue #132: `carts.order_id` — the order a cart - * successfully handed off to. `CartStore.checkout` writes it in the SAME guarded - * statement that flips `state` to `checked_out`, so the two are never observable - * apart and the existing `WHERE state = 'active'` predicate is already the CAS - * that makes the stamp write-once. - * - * Deliberately just the column: - * - **No backfill.** The project is unreleased; there is no production data, - * and every existing `checked_out` cart predates the writer. - * - **No FK to `orders`.** `orders.cart_id` — the reverse edge — is itself - * unconstrained text, and `ADD COLUMN … REFERENCES` does not port to - * better-sqlite3, which runs the same DDL. - * - **No index.** Every cart read is by primary key. - * - **No CHECK constraint** tying the column to `state`. The - * "`active` ⟺ no order id" invariant is enforced by `checkout` being the - * column's single writer, NOT structurally — a raw partial UPDATE can still - * produce a `checked_out` cart with a NULL order id, and - * `cart-fence.dialects.test.ts` constructs exactly that on purpose so the - * cart-state fence stays provably independent of this column. - */ -export const migration0021CartOrderId: Migration = { - async up(db: Kysely): Promise { - await db.schema.alterTable("carts").addColumn("order_id", "text").execute(); - }, -}; diff --git a/packages/store-postgres/src/migrations/0022_order_lookup_indices.ts b/packages/store-postgres/src/migrations/0022_order_lookup_indices.ts deleted file mode 100644 index cfb43695..00000000 --- a/packages/store-postgres/src/migrations/0022_order_lookup_indices.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { type Kysely, sql } from "kysely"; -import type { Migration } from "kysely/migration"; - -/** - * Forward-only migration adding the two missing order-lookup indices. Never - * edit a shipped migration — correct forward. - * - * - `idx_orders_customer_id` backs `KyselyOrderStore#listForCustomer` - * (storefront order history), which filters `orders.customer_id = - * :customerId`, orders by `(created_at, id)`, then fans out `#loadById` per - * row — today a full scan on every lookup. It is a COMPOSITE PARTIAL index - * — `(customer_id, created_at, id) WHERE customer_id IS NOT NULL` — rather - * than a plain `(customer_id)` b-tree: `customer_id` is ~90% NULL (orders - * are born unlinked and only back-filled to a customer at a later login — - * the Phase-5 relink flow), so the partial predicate excludes the majority - * of rows the index would otherwise carry, and trailing `created_at, id` - * lets `listForCustomer`'s `ORDER BY created_at, id` come straight off the - * index with no separate sort step. Measured at 50k rows / 90% NULL: 304 kB - * vs. 464 kB for the plain form, and the plan drops a `Sort` node. - * `linkGuestOrders`' `WHERE customer_id IS NULL` half is unaffected — that - * predicate is excluded from this index by construction and is driven by - * `idx_orders_buyer_ref_lower` instead, with `customer_id IS NULL` applied - * as a heap filter. The customer-key union (`customer_id = :id OR - * lower(buyer_ref) = lower(:buyerRef)` in `orderFilterConditions`) still - * uses this index for its `customer_id = :id` half: the planner proves - * equality to a literal implies `IS NOT NULL` and picks the partial index. - * - `idx_orders_buyer_ref_lower` is a FUNCTIONAL index on `lower(buyer_ref)`. - * Its consumers are the EQUALITY predicates on `buyer_ref`, each folded at - * the compare side (`lower(buyer_ref) = lower(:buyerRef)`): - * `KyselyOrderStore#linkGuestOrders` and the `customer` key half of - * `orderFilterConditions` (`customer_id = :id OR lower(buyer_ref) = - * lower(:buyerRef)`) in `kysely-order-store.ts`. NOT the admin list's - * `search`: that is an id PREFIX, an unanchored `buyer_ref` SUBSTRING, or an - * exact-lower sku on the order's LINES (port doc) — the first two of which no - * b-tree here can serve, so the predicate deliberately scans, and the third of - * which is a different table entirely (`order_items`, reached by `EXISTS`) and - * so was never this index's business. A - * plain b-tree on `buyer_ref` would never be chosen by the planner for the - * equality queries either, so the index expression matches `lower(buyer_ref)` - * exactly — Postgres resolves an unqualified vs. `orders.`-qualified column - * reference to the same parsed expression node, so this one expression serves - * both call-site spellings. - * - * WHAT THE TEST ACTUALLY PINS. `order-lookup-indices.test.ts` EXPLAINs the - * three statements above (`listForCustomer`, `linkGuestOrders`, the customer - * key) and asserts each plan names the index it was built for. That catches a - * rewrite of THOSE predicates — a different fold, a column swap — by failing - * loudly rather than silently losing the index. It does NOT cover every - * predicate in the store, and never covered `search`; a query the test does - * not EXPLAIN can drop off an index with nothing turning red. - * - * Neither duplicates the existing `orders` indices: `idx_orders_created_state` - * (`created_at, state`, `0008`) and `idx_orders_created_id` (the admin keyset - * `created_at, id`, `0009`). - * - * No `CONCURRENTLY`: matches `0008`/`0009` precedent (plain `createIndex`, - * cheap insurance rather than a performance target) and the migration runner - * wraps each migration in a transaction, inside which `CREATE INDEX - * CONCURRENTLY` cannot run on Postgres. - * - * SQLite equivalent: no dialect fork needed. SQLite has supported expression - * indices and partial indices since 3.9.0/3.8.0, and `lower()` is standard - * SQL on both dialects (the same claim `linkGuestOrders` already relies on). - * `idx_orders_customer_id` is built with the portable Kysely column/`where` - * builder, which is genuinely dialect-agnostic. `idx_orders_buyer_ref_lower` - * instead uses a raw `sql` fragment (`column(sql\`lower(buyer_ref)\`)`) — the - * builder has no typed API for an expression column, so this one is NOT a - * portable-builder construct. It still emits byte-identical DDL on - * better-sqlite3 and pg, but only because `lower(x)` happens to be spelled - * the same on both dialects; a less portable expression here would need an - * explicit per-dialect branch. Confirmed identical by running this migration - * against both dialects (`migration-gap.test.ts` for sqlite, - * `order-lookup-indices.test.ts` for pg). - */ -export const migration0022OrderLookupIndices: Migration = { - async up(db: Kysely): Promise { - await db.schema - .createIndex("idx_orders_customer_id") - .ifNotExists() - .on("orders") - .columns(["customer_id", "created_at", "id"]) - .where("customer_id", "is not", null) - .execute(); - - await db.schema - .createIndex("idx_orders_buyer_ref_lower") - .ifNotExists() - .on("orders") - .column(sql`lower(buyer_ref)`) - .execute(); - }, -}; diff --git a/packages/store-postgres/src/migrations/0023_product_variants.ts b/packages/store-postgres/src/migrations/0023_product_variants.ts deleted file mode 100644 index a210c7d3..00000000 --- a/packages/store-postgres/src/migrations/0023_product_variants.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { type Kysely, sql } from "kysely"; -import type { Migration } from "kysely/migration"; - -/** - * Forward-only migration for `product_variants`: ONE COMMERCE ROW PER SELLABLE - * UNIT. Stock and price are sku-level facts by construction, so a size that can - * be bought on its own is a row, not a decoration on the product row. - * - * Portable Kysely DDL (identical for better-sqlite3 and pg; CLAUDE.md: - * migrations are forward-only, never edit a shipped one), portable types only - * (text/integer) exactly like `0002_product_commerce`. - * - * A SEPARATE TABLE, and this is the load-bearing shape decision. Widening - * `product_commerce` into one-row-per-unit would re-key its primary key and - * therefore rewrite `listProducts`, its keyset cursor, both fakes and every - * caller — for a catalog in which no product declares a variant. As a separate - * table it is INERT: with no rows, every existing statement is byte-identical, - * and the eventual "one row per sellable unit" list is `product_commerce LEFT - * JOIN product_variants`, which yields exactly one row per product until a - * variant exists. That list's cursor EXTENDS the existing `(created_at, - * product_id)` position with `variant_key` as a third component rather than - * replacing it, which is why the intra-product order below is the key. - * - * PRIMARY KEY `(product_id, variant_key)` — the key is the CMS repeater row's - * own stable identifier and it is IMMUTABLE, so it is the identity rather than a - * column: it appears in no `SET` clause in any adapter, and no write input - * carries a field that could change it. The PK also serves the only read shape - * this table has (`WHERE product_id = ? ORDER BY variant_key`), so no secondary - * index is added for it. - * - * NO FOREIGN KEY onto `product_commerce`. The repeater's sync POST and - * `content:afterSave`'s are independent fire-and-forget deliveries, so a variant - * can legitimately arrive before its product row — exactly as `activate` can. - * The port converges that by watermark; an FK would abort it instead. This - * mirrors `inventory_stock_movements`, which declines an FK onto `inventory.sku` - * for the same "handled in-app, never an abort" reason. - * - * `sku` and `price_*` are NULLABLE — "declare then price": the CMS declares a - * variant (key + display name, nothing commercial) and an admin prices it later, - * so a fresh row carries neither. An absent price is ABSENT, never zero. - * - * `title` is the variant's display-name CACHE, single-writer, fed only by the - * CMS sync (`adr/0016-variant-title-is-cms-owned.md`) — ADR-0013 one level down. - * It exists so an order line can snapshot the size a buyer actually bought - * without a cross-database read. - * - * `orphaned_at` is the presence tombstone: non-null once the CMS stops declaring - * the key. Deactivation, never deletion — an orphaned variant may still hold - * stock and still sit on live order lines. Live-sku uniqueness is therefore a - * PARTIAL unique index over non-orphaned rows only, exactly as - * `product_commerce_live_sku_unique` is partial over non-deleted ones: the - * tombstone keeps the history without locking the identifier forever. - * - * `content_updated_at` is the ONE ordering watermark for BOTH presence - * transitions (declare and orphan). They arrive on the SAME save event — a save - * either re-declares a key or does not — so one watermark orders both correctly, - * unlike the product's publish gate, whose opposing transitions arrive on - * separate events and needed a column of their own. - */ -export const migration0023ProductVariants: Migration = { - async up(db: Kysely): Promise { - await db.schema - .createTable("product_variants") - .addColumn("product_id", "text", (col) => col.notNull()) - .addColumn("variant_key", "text", (col) => col.notNull()) - .addColumn("sku", "text") - .addColumn("price_cents", "integer", (col) => col.check(sql`price_cents >= 0`)) - .addColumn("price_currency", "text") - .addColumn("title", "text") - .addColumn("orphaned_at", "text") - .addColumn("idempotency_key", "text", (col) => col.notNull()) - .addColumn("content_updated_at", "text") - .addColumn("created_at", "text", (col) => col.notNull()) - .addColumn("updated_at", "text", (col) => col.notNull()) - .addPrimaryKeyConstraint("product_variants_pkey", ["product_id", "variant_key"]) - .execute(); - - // Live-rows-only sku uniqueness at variant grain (see the header). Raw - // predicate for the same reason 0002 uses one: Kysely's index builder only - // offers indexed columns to `where`'s typed overload, and the partial-index - // predicate is over `orphaned_at`. - await db.schema - .createIndex("product_variants_live_sku_unique") - .on("product_variants") - .column("sku") - .unique() - .where(sql`orphaned_at is null`) - .execute(); - }, -}; diff --git a/packages/store-postgres/src/migrations/0024_entitlement_lookup_indices.ts b/packages/store-postgres/src/migrations/0024_entitlement_lookup_indices.ts deleted file mode 100644 index 22eada8f..00000000 --- a/packages/store-postgres/src/migrations/0024_entitlement_lookup_indices.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { type Kysely, sql } from "kysely"; -import type { Migration } from "kysely/migration"; - -/** - * Forward-only migration adding the two missing entitlement-lookup indices. - * Never edit a shipped migration — correct forward. - * - * `KyselyEntitlementStore#check` is the delivery gate (no active row ⇒ the file - * is not served) and compiles one predicate shape: `state = ? AND sku = ?`, - * plus AT LEAST ONE of `order_id = ?` (the download capability carried by an - * unguessable order id) and `lower(buyer_ref) = ?` (the session scope, folded - * because a lower-normalized session email must match a mixed-case checkout - * ref). A query with neither scope is refused before it reaches SQL. Until now - * `entitlements` carried only two implicit indexes — the primary key and the - * UNIQUE on `grant_idempotency_key` — neither of which touches any axis of that - * predicate, so every check was a full scan. - * - * - `idx_entitlements_buyer_ref_lower` — `(lower(buyer_ref), sku, state)`. The - * leading term is FUNCTIONAL: the compare side is folded at every call site, - * so a plain b-tree on `buyer_ref` would never be chosen by the planner. The - * expression is spelled exactly as `check` spells it, so a rewrite to a - * different fold stops matching, which `entitlement-lookup-indices.test.ts` - * catches by explaining the store's own compiled statement. - * - `idx_entitlements_order_id` — `(order_id, sku, state)`, the same three - * equality terms with the other scope leading. - * - * Why the SCOPE column leads, not `sku`: all four terms are equalities, so a - * b-tree turns each into a boundary condition regardless of position — position - * only decides which queries can use the index at all, and how much of it they - * must walk. `sku` is the least selective axis (every buyer of one product - * shares it, and that set grows with the product's popularity, unboundedly), - * while entries per order and per buyer are bounded by cart size and by how - * much one person has bought. Leading with the scope makes each check a point - * lookup. - * - * Why TWO indices rather than one composite: a b-tree can only be probed from - * its leading column, and neither scope query mentions the other's column — - * the order-scope check has no `buyer_ref` term at all. A single - * `(sku, state, lower(buyer_ref), order_id)` therefore serves one shape by - * point lookup and the other by walking the whole `(sku, state)` range with the - * scope applied as a non-boundary qual. Measured at 60k rows over 50 skus, the - * order-scope check touched 14 buffers on the single composite versus 4 on this - * pair, and that gap widens linearly with entitlements granted per sku. - * - * Why `state` is a COLUMN and not a partial `WHERE state = 'active'` predicate - * — insurance, not a fix for anything observed today: `check` binds the state as - * a PARAMETER (`state = $1`), and Postgres can only prove a partial index's - * predicate from a parameter once that parameter has been folded to a constant, - * which happens under a custom plan but not under a generic one. The current - * driver path never plans generically — node-postgres sends unnamed - * extended-protocol statements, and a generic plan requires a NAMED prepared - * statement the server can reuse — so a partial form would work as things - * stand. It would stop working the day a driver, a pooler or a - * `plan_cache_mode` setting introduces named statements, and that failure is - * invisible to an EXPLAIN test (which always plans custom): confirmed by - * building the partial form, which planned as `Bitmap Index Scan` normally and - * fell back to `Seq Scan` under `plan_cache_mode = force_generic_plan`. - * Carrying the state as an ordinary column makes it a boundary condition in - * every plan mode, for a few hundred kB. It is trailing rather than leading - * because two distinct values narrow almost nothing on their own. Contrast - * `0022`'s `idx_orders_customer_id`, whose partial `WHERE customer_id IS NOT - * NULL` is provable from `customer_id = $1` structurally, independent of the - * parameter's value. - * - * Safe on a populated table: both are additive `CREATE INDEX … IF NOT EXISTS` - * statements — no rewrite, no constraint, no data change. No `CONCURRENTLY`, - * matching the `0008`/`0009`/`0022` precedent: the migration runner wraps each - * migration in a transaction, inside which `CREATE INDEX CONCURRENTLY` cannot - * run on Postgres. The cost of that choice is a write stall: a plain `CREATE - * INDEX` holds a lock that blocks grant inserts (and on SQLite, the whole file) - * for the duration of the build. Negligible at the row counts this table - * carries; if `entitlements` ever grows to where it is not, the fix is a - * separate migration issued outside the transaction, not an edit to this one. - * - * SQLite: no dialect fork. Expression indices (3.9.0) and the portable column - * form both apply, `lower()` is spelled identically on both dialects — the same - * claim `check` itself already relies on — and SQLite resolves the same two - * `SEARCH … USING INDEX` plans for the three real predicate shapes. The - * functional leading term uses a raw `sql` fragment because the builder has no - * typed API for an expression column, so it is NOT a portable-builder construct; - * it emits identical DDL on both dialects only because this particular - * expression is spelled the same on both. - */ -export const migration0024EntitlementLookupIndices: Migration = { - async up(db: Kysely): Promise { - await db.schema - .createIndex("idx_entitlements_buyer_ref_lower") - .ifNotExists() - .on("entitlements") - // Chained `.column()` rather than one `.columns([…])`: the array - // overload infers its column-name generic from the literals it is - // given, so mixing an `Expression` in with them fails to type. - .column(sql`lower(buyer_ref)`) - .column("sku") - .column("state") - .execute(); - - await db.schema - .createIndex("idx_entitlements_order_id") - .ifNotExists() - .on("entitlements") - .columns(["order_id", "sku", "state"]) - .execute(); - }, -}; diff --git a/packages/store-postgres/src/migrations/index.ts b/packages/store-postgres/src/migrations/index.ts deleted file mode 100644 index f22f51ee..00000000 --- a/packages/store-postgres/src/migrations/index.ts +++ /dev/null @@ -1,95 +0,0 @@ -import type { Kysely } from "kysely"; -import { type Migration, type MigrationProvider, Migrator } from "kysely/migration"; -import { migration0001PhaseInventory } from "./0001_phase0_inventory.js"; -import { migration0002ProductCommerce } from "./0002_product_commerce.js"; -import { migration0003Cart } from "./0003_cart.js"; -import { migration0004ProductCommerceActiveUpdatedAt } from "./0004_product_commerce_active_updated_at.js"; -import { migration0005Orders } from "./0005_orders.js"; -import { migration0006CustomersSessionsOutbox } from "./0006_customers_sessions_outbox.js"; -import { migration0007ShippingTaxCoupons } from "./0007_shipping_tax_coupons.js"; -import { migration0008SettingsAndReportingIndices } from "./0008_settings_and_reporting_indices.js"; -import { migration0009OrdersAdminListIndices } from "./0009_orders_admin_list_indices.js"; -import { migration0010OrderNotes } from "./0010_order_notes.js"; -import { migration0011ReconciliationResolution } from "./0011_reconciliation_resolution.js"; -import { migration0012OrderFulfillment } from "./0012_order_fulfillment.js"; -import { migration0013OrderCancellation } from "./0013_order_cancellation.js"; -import { migration0014OrderEvents } from "./0014_order_events.js"; -import { migration0015ProductCommerceAdminListIndices } from "./0015_product_commerce_admin_list_indices.js"; -import { migration0016InventoryStockMovements } from "./0016_inventory_stock_movements.js"; -import { migration0017ProductCommerceDataModelAdds } from "./0017_product_commerce_data_model_adds.js"; -import { migration0018CouponsAdminList } from "./0018_coupons_admin_list.js"; -import { migration0019OrderShippingAddress } from "./0019_order_shipping_address.js"; -import { migration0020Refunds } from "./0020_refunds.js"; -import { migration0021CartOrderId } from "./0021_cart_order_id.js"; -import { migration0022OrderLookupIndices } from "./0022_order_lookup_indices.js"; -import { migration0023ProductVariants } from "./0023_product_variants.js"; -import { migration0024EntitlementLookupIndices } from "./0024_entitlement_lookup_indices.js"; - -/** Ordered, append-only migration list (forward-only). */ -const migrations: Record = { - "0001_phase0_inventory": migration0001PhaseInventory, - "0002_product_commerce": migration0002ProductCommerce, - "0003_cart": migration0003Cart, - "0004_product_commerce_active_updated_at": migration0004ProductCommerceActiveUpdatedAt, - "0005_orders": migration0005Orders, - "0006_customers_sessions_outbox": migration0006CustomersSessionsOutbox, - "0007_shipping_tax_coupons": migration0007ShippingTaxCoupons, - "0008_settings_and_reporting_indices": migration0008SettingsAndReportingIndices, - "0009_orders_admin_list_indices": migration0009OrdersAdminListIndices, - "0010_order_notes": migration0010OrderNotes, - "0011_reconciliation_resolution": migration0011ReconciliationResolution, - "0012_order_fulfillment": migration0012OrderFulfillment, - "0013_order_cancellation": migration0013OrderCancellation, - "0014_order_events": migration0014OrderEvents, - "0015_product_commerce_admin_list_indices": migration0015ProductCommerceAdminListIndices, - "0016_inventory_stock_movements": migration0016InventoryStockMovements, - "0017_product_commerce_data_model_adds": migration0017ProductCommerceDataModelAdds, - "0018_coupons_admin_list": migration0018CouponsAdminList, - "0019_order_shipping_address": migration0019OrderShippingAddress, - "0020_refunds": migration0020Refunds, - "0021_cart_order_id": migration0021CartOrderId, - "0022_order_lookup_indices": migration0022OrderLookupIndices, - "0023_product_variants": migration0023ProductVariants, - "0024_entitlement_lookup_indices": migration0024EntitlementLookupIndices, -}; - -export const migrationProvider: MigrationProvider = { - getMigrations(): Promise> { - return Promise.resolve(migrations); - }, -}; - -export interface MigrateToLatestOptions { - /** - * Pin kysely's own `kysely_migration`/`kysely_migration_lock` tables to a - * named schema. REQUIRED for schema-isolated Postgres tests - * (`createIsolatedPgSchema` passes its schema): without it, kysely's - * `Migrator.#doesTableExist` matches the migration tables BY NAME ACROSS - * ALL SCHEMAS (`PostgresIntrospector.getTables` is not search_path- - * scoped), so whenever any other isolated schema is alive — a parallel - * test file, or leftovers from a killed run — the migrator skips creating - * the lock table in the new schema and its subsequent unqualified - * `SELECT … FROM kysely_migration_lock` fails with "relation does not - * exist". Leave unset for single-schema use (the service bin; sqlite has - * no schemas). - */ - migrationTableSchema?: string; -} - -/** Run every pending migration to latest; throws on the first failure. */ -export async function migrateToLatest( - db: Kysely, - options: MigrateToLatestOptions = {}, -): Promise { - const migrator = new Migrator({ - db, - provider: migrationProvider, - ...(options.migrationTableSchema !== undefined - ? { migrationTableSchema: options.migrationTableSchema } - : {}), - }); - const { error } = await migrator.migrateToLatest(); - if (error !== undefined) { - throw error instanceof Error ? error : new Error(String(error)); - } -} diff --git a/packages/store-postgres/src/pg.ts b/packages/store-postgres/src/pg.ts deleted file mode 100644 index 217fff7f..00000000 --- a/packages/store-postgres/src/pg.ts +++ /dev/null @@ -1,90 +0,0 @@ -// Sqlite-free entry (`@otta-sh/store-postgres/pg`) for bundler targets — the -// Cloudflare Worker imports ONLY from here so esbuild/wrangler never see the -// `better-sqlite3` native addon (unbundleable; tree-shaking cannot safely drop -// a CJS import). Everything re-exported below transitively touches only -// `@otta-sh/domain`, `kysely`, and `pg`. -export { makePostgresDb, makePostgresPool } from "./dialects-pg.js"; -export { uuidIdGen } from "./id-gen.js"; -export { - KyselyInventoryStore, - type KyselyInventoryStoreOptions, -} from "./kysely-inventory-store.js"; -export { - KyselyProductCommerceStore, - type KyselyProductCommerceStoreOptions, -} from "./kysely-product-commerce-store.js"; -export { KyselyCartStore, type KyselyCartStoreOptions } from "./kysely-cart-store.js"; -export { KyselyOrderStore, type KyselyOrderStoreOptions } from "./kysely-order-store.js"; -export { - KyselyOrderNotesStore, - type KyselyOrderNotesStoreOptions, -} from "./kysely-order-notes-store.js"; -export { - KyselyEntitlementStore, - type KyselyEntitlementStoreOptions, -} from "./kysely-entitlement-store.js"; -export { - KyselyPaymentEventStore, - type KyselyPaymentEventStoreOptions, -} from "./kysely-payment-event-store.js"; -export { KyselyCustomerStore, type KyselyCustomerStoreOptions } from "./kysely-customer-store.js"; -export { KyselyAddressStore, type KyselyAddressStoreOptions } from "./kysely-address-store.js"; -export { - DEFAULT_SESSION_TTL_MS, - hashToken, - KyselySessionStore, - type KyselySessionStoreOptions, -} from "./kysely-session-store.js"; -export { - DEFAULT_CHALLENGE_TTL_MS, - DEFAULT_MAX_ACTIVE_CHALLENGES, - KyselyCredentialVerifier, - type KyselyCredentialVerifierOptions, -} from "./kysely-credential-verifier.js"; -export { KyselyShippingRulesStore } from "./kysely-shipping-rules-store.js"; -export { KyselyTaxRulesStore } from "./kysely-tax-rules-store.js"; -export { KyselyCouponStore, type KyselyCouponStoreOptions } from "./kysely-coupon-store.js"; -export { - KyselyReportingStore, - type KyselyReportingStoreOptions, - type ReportingDialect, -} from "./kysely-reporting-store.js"; -export { KyselySettingsStore, type KyselySettingsStoreOptions } from "./kysely-settings-store.js"; -export { - type MigrateToLatestOptions, - migrateToLatest, - migrationProvider, -} from "./migrations/index.js"; -export type { - AddressesTable, - CartLinesTable, - CartMutationKind, - CartMutationsTable, - CartState, - CartsTable, - CouponRedemptionsTable, - CouponsTable, - CustomerSessionsTable, - CustomersTable, - Database, - EntitlementsTable, - InventoryTable, - LoginChallengesTable, - OrderEmailsOutboxTable, - OrderItemsTable, - OrdersTable, - OrderStateColumn, - OrderTotalsTable, - PaymentEventsTable, - PaymentsTable, - ProductCommerceTable, - ReservationsTable, - ReservationState, - SettingsMutationsTable, - SettingsTable, - ShippingMethodsTable, - ShippingRatesTable, - ShippingZonesTable, - TaxClassesTable, - TaxRatesTable, -} from "./schema.js"; diff --git a/packages/store-postgres/src/schema.ts b/packages/store-postgres/src/schema.ts deleted file mode 100644 index b25063b4..00000000 --- a/packages/store-postgres/src/schema.ts +++ /dev/null @@ -1,623 +0,0 @@ -// Kysely table typings for the Phase-0 inventory schema (§6) plus the Phase-3 -// cart schema. Portable types only (text/integer) so the same DDL and queries -// serve better-sqlite3 and pg. - -import type { ColumnType } from "kysely"; - -export type ReservationState = "pending" | "held" | "committed" | "released" | "failed" | "adopted"; - -export interface InventoryTable { - sku: string; - on_hand: number; -} - -export interface ReservationsTable { - id: string; - sku: string; - qty: number; - state: ReservationState; - idempotency_key: string; - created_at: string; - // Phase 3: the cart stamps the hold deadline here (nullable — a reservation - // created by a raw `reserve` before any cart write carries none). Omittable on - // insert so Phase-0's `reserve` is left byte-for-byte. - expires_at: ColumnType; - // Phase 4: the owning order once the hold is adopted (nullable; omittable on - // insert so Phase-0/3 writes are byte-for-byte). - order_id: ColumnType; -} - -export type CartState = "active" | "checked_out"; - -export interface CartsTable { - id: string; - customer_id: string | null; - state: CartState; - // Issue #132: the order this cart handed off to, written by `checkout` in the - // SAME statement as `state` (nullable; omittable on insert so `create()`'s - // `insertInto("carts")` stays byte-for-byte). Mirrors - // `ReservationsTable.order_id` above — same column name, same table family, - // same reason for the `ColumnType` form. - order_id: ColumnType; - currency: string; - created_at: string; - updated_at: string; -} - -export interface CartLinesTable { - id: string; - cart_id: string; - product_id: string | null; - sku: string; - qty: number; - reservation_id: string | null; - expires_at: string | null; - created_at: string; - updated_at: string; -} - -export type CartMutationKind = "add" | "adjust" | "remove"; - -export interface CartMutationsTable { - idempotency_key: string; - cart_id: string; - line_id: string | null; - kind: CartMutationKind; - resulting_qty: number | null; - /** 0 = claimed (pre-movement), 1 = completed. Claim-first: the row exists - * BEFORE any inventory movement; a replay of an incomplete claim resumes. */ - completed: number; - created_at: string; -} - -export type AdjustOutcome = "ok" | "out_of_stock"; - -/** - * Per-mutation claim ledger for `InventoryStore.adjust` — the adjust analogue of - * `reservations.idempotency_key` (which is already consumed by the original - * reserve and cannot guard the many adjusts over a hold's life). The claim - * INSERT and the inventory movement commit in one short transaction, so exactly - * one caller per key moves stock and a replay returns the recorded outcome. - */ -export interface InventoryAdjustmentsTable { - idempotency_key: string; - reservation_id: string; - to_qty: number; - outcome: AdjustOutcome; - created_at: string; -} - -export type StockMovementDirection = "restock" | "removal" | "rename_out" | "rename_in"; -export type StockMovementOutcome = "ok" | "insufficient_stock"; - -/** - * Per-mutation claim ledger for `InventoryStore.restock`/`removeStock` (admin-UX - * Increment 2) — the admin stock-movement analogue of `inventory_adjustments` - * (that ledger is reservation-scoped; this one is bare-sku-scoped). The claim - * INSERT and the guarded inventory movement commit in ONE short transaction, so - * exactly one caller per key moves stock and a replay returns the recorded - * outcome. `direction`/`qty` are recorded so a key reused for a DIFFERENT - * movement is rejected. `result_on_hand` is the on_hand recorded with the - * outcome (after the movement for `ok`; the current count for - * `insufficient_stock`) so a replay echoes the original result. An UNKNOWN_SKU - * is NOT recorded here (the claim rolls back — key not consumed). - * - * KEY SCOPING: keys are unique PER LEDGER — this table's `idempotency_key` PK is - * independent of `reservations.idempotency_key` and - * `inventory_adjustments.idempotency_key`. The same key value in different - * ledgers is NOT a collision; only a reuse WITHIN this ledger for a different - * (sku, direction, qty) is rejected (`StockMovementMismatchError`). - */ -export interface InventoryStockMovementsTable { - idempotency_key: string; - sku: string; - direction: StockMovementDirection; - qty: number; - outcome: StockMovementOutcome; - result_on_hand: number; - created_at: string; -} - -/** - * Phase 1 (§4/§6 step 4): one row per product, keyed by the CMS content id. - * `sku`/`price_*` are nullable — "create then price" (a bare afterSave sync - * upsert may create the row before any commercial data exists). - */ -export interface ProductCommerceTable { - product_id: string; - sku: string | null; - price_cents: number | null; - price_currency: string | null; - /** Phase 4 §4: the title an order line snapshots (nullable; added additively). */ - title: string | null; - tax_class: string | null; - /** Increment 2 slice 5: optional compare-at / was-price (nullable; shares the - * row's price currency, enforced by the edit guard not DDL). */ - compare_at_cents: number | null; - compare_at_currency: string | null; - /** Increment 2 slice 5: optional ADMIN-ONLY unit cost (nullable; shares the - * price currency). Never on a storefront-facing wire. */ - unit_cost_cents: number | null; - unit_cost_currency: string | null; - /** Increment 2 slice 5: out-of-stock policy. `NOT NULL DEFAULT 'deny'`; - * `'deny'` is the only value this slice (bounded by the domain union + zod, - * not the DB). */ - inventory_policy: string; - weight_grams: number | null; - length_mm: number | null; - width_mm: number | null; - height_mm: number | null; - product_kind: string; - /** Portable 0/1 (not SQL boolean — better-sqlite3 cannot bind a JS boolean). */ - active: number; - deleted_at: string | null; - idempotency_key: string; - /** Sync-ordering watermark: last CMS `content.updatedAt` applied by a sync - * upsert (ISO-8601 text; lexicographic = chronological). Null until a - * sync ever carries one. */ - content_updated_at: string | null; - /** Publish-GATE ordering watermark: the last CMS `content.updatedAt` a - * winning `activate`/`deactivate` applied. DELIBERATELY separate from - * `content_updated_at` — a plain `content:afterSave` advances that one - * without being a lifecycle event, so sharing it would let a save poison - * the gate and let a stale out-of-order publish/unpublish POST win. Null - * until the first lifecycle transition; NULL is treated as `-infinity` so - * the first transition always wins. ISO-8601 text (lexicographic = - * chronological). */ - active_updated_at: string | null; - created_at: string; - updated_at: string; -} - -/** - * ONE COMMERCE ROW PER SELLABLE UNIT (`0023_product_variants`): a size that can - * be bought on its own is a row, keyed `(product_id, variant_key)`. - * - * A SEPARATE TABLE so the model lands inert — with no rows, `product_commerce`'s - * every statement, projection and keyset cursor is byte-identical, and the - * eventual one-row-per-unit list is a LEFT JOIN whose cursor EXTENDS the - * existing position with `variant_key` rather than replacing it. - */ -export interface ProductVariantsTable { - product_id: string; - /** The CMS repeater row's stable key — IMMUTABLE, and half the primary key, - * so it appears in no `SET` clause anywhere. */ - variant_key: string; - /** Nullable: "declare then price" — the CMS declares a variant carrying - * nothing commercial, and an admin sets the sku later. */ - sku: string | null; - price_cents: number | null; - price_currency: string | null; - /** The variant's display-name CACHE, single-writer (the CMS sync) — ADR-0016, - * which is ADR-0013 one level down. */ - title: string | null; - /** Presence tombstone: non-null once the CMS stops declaring the key. - * Deactivation, never deletion — the row keeps its sku, price and stock. */ - orphaned_at: string | null; - idempotency_key: string; - /** The ONE ordering watermark for BOTH presence transitions (declare and - * orphan): they arrive on the same save event, so one column orders both. */ - content_updated_at: string | null; - created_at: string; - updated_at: string; -} - -/** Phase 4 §4 + Phase 5 §5: orders carry NO money column — keys / state / TTL / - * identity only. Phase 5 widens the `state` value set (a text column — no DDL - * change for the new values). */ -export type OrderStateColumn = - | "pending" - | "paid" - | "failed" - | "expired" - | "processing" - | "shipped" - | "delivered" - | "completed" - | "cancelled" - | "refunded"; - -export interface OrdersTable { - id: string; - cart_id: string | null; - currency: string; - state: OrderStateColumn; - idempotency_key: string; - hold_expires_at: string; - payment_method: string | null; - buyer_ref: string; - /** Phase-5 hook (added here forward-only, populated by Phase 5). */ - customer_id: ColumnType; - /** Set when settle loses an adopted hold → manual reconciliation (§5); CLEARED - * back to null when an admin resolves it (admin-UX Increment 1). */ - reconciliation_flag: ColumnType; - /** The admin's disposition recorded on resolve (admin-UX Increment 1) — - * 'refunded' | 'fulfilled' | 'written_off'. Null while unflagged/unresolved. */ - reconciliation_outcome: ColumnType; - /** Free-text justification recorded on resolve; null while unresolved. */ - reconciliation_reason: ColumnType; - /** Who resolved the flag (free text); null while unresolved. */ - reconciliation_resolved_by: ColumnType; - /** ISO-8601 UTC resolve timestamp; null while unresolved. */ - reconciliation_resolved_at: ColumnType; - /** Shipping fulfillment (admin-UX Increment 1) — single-slot, written atomically - * with the `processing → shipped` flip by `recordFulfillment`. All nullable + - * omittable on insert (Phase-4/5 order creation carries none); `fulfillment_ - * recorded_at` is the presence witness. */ - fulfillment_carrier: ColumnType; - fulfillment_tracking_number: ColumnType; - fulfillment_tracking_url: ColumnType; - /** ISO-8601 UTC ship time (admin-supplied or the store clock). */ - fulfillment_shipped_at: ColumnType; - fulfillment_recorded_by: ColumnType; - /** ISO-8601 UTC record timestamp (store clock) — the presence witness. */ - fulfillment_recorded_at: ColumnType; - /** Structured cancellation (admin-UX Increment 1, "cancel with reason") — - * single-slot, written atomically with the `{pending,paid,processing} → - * cancelled` flip by `cancelOrder`. All nullable + omittable on insert; - * `cancellation_cancelled_at` is the presence witness — a bare-transition - * cancellation (back-compat) leaves these null even though `state = - * 'cancelled'`. */ - cancellation_reason: ColumnType; - cancellation_detail: ColumnType; - cancellation_cancelled_by: ColumnType; - /** ISO-8601 UTC record timestamp (store clock) — the presence witness. */ - cancellation_cancelled_at: ColumnType; - created_at: string; - updated_at: string; -} - -/** Insert-once (§4): price/title/currency are snapshots, never updated. */ -export interface OrderItemsTable { - id: string; - order_id: string; - product_id: string; - sku: string; - title: string; - unit_price_cents: number; - currency: string; - quantity: number; - fulfillment_kind: string; - reservation_id: string | null; -} - -/** 1:1 with orders — the authoritative totals home (§4). Phase 4 writes the stub. */ -export interface OrderTotalsTable { - order_id: string; - currency: string; - subtotal_cents: number; - discount_cents: number; - shipping_cents: number; - tax_cents: number; - total_cents: number; - applied_coupon_code: string | null; - /** jsonb in pg / text in sqlite — stored as a JSON string, null in Phase 4. */ - shipping_method_snapshot: string | null; - tax_breakdown: string | null; -} - -/** - * 1:1 with orders — the immutable shipping-address snapshot captured at checkout - * (ADR-0009). Mirrors `order_totals`: written ONCE by `createFromCart` in the same - * guarded transaction, never rewritten (a later profile-address edit can't reach - * it — there is no code path that updates this table). A row is present iff a - * ship-to was captured; a historical/digital-only order simply has no row (the - * left join reads `null`). Required fields are `NOT NULL`; the profile concerns - * (`id`/`customer_id`/`is_default`/`kind`) are deliberately absent — a frozen copy, - * not a pointer into the mutable `addresses` book. - */ -export interface OrderShippingAddressTable { - order_id: string; - name: string; - line1: string; - line2: string | null; - city: string; - region: string | null; - postal_code: string; - country: string; - email: string | null; - phone: string | null; -} - -export interface PaymentsTable { - id: string; - order_id: string; - gateway: string; - provider_ref: string; - amount_cents: number; - currency: string; - status: string; - created_at: string; -} - -/** - * Webhook/settlement dedupe + anomaly log (§5). A DEDUPE row carries a non-null - * `dedupe_key` (UNIQUE — redelivery is a no-op) and null `kind`; an ANOMALY row - * carries a null `dedupe_key` (multiple nulls allowed under UNIQUE) and a set - * `kind`/`detail`. - */ -/** - * Append-only refunds ledger (ADR-0008). One row per refund; the ceiling - * `Σ refunds ≤ min(Σ captured payments, order_totals.total)` is enforced by the - * guarded `recordRefund` write (row lock + re-read sums), not a DB constraint. - * `UNIQUE(idempotency_key)` is the once-only backstop. NEVER touches the frozen - * order snapshot — a refunded order's `order_totals`/`order_items` are intact and - * this table, not the order row, is the source of "how much came back". - */ -export interface RefundsTable { - id: string; - order_id: string; - amount_cents: number; - currency: string; - /** 'gateway' (money moved via the provider) | 'manual' (out-of-band record). */ - kind: string; - /** 'stripe' | 'x402'. */ - gateway: string; - /** Provider refund id for a gateway refund; null for a manual record. */ - refund_ref: string | null; - reason: string | null; - refunded_by: string; - idempotency_key: string; - /** Reserve-before-issue lifecycle (ADR-0008): 'recorded' | 'reserved' | - * 'unverified' | 'voided'. Ceiling counts non-'voided'; the flip counts - * 'recorded'. */ - status: string; - created_at: string; -} - -export interface PaymentEventsTable { - id: string; - dedupe_key: string | null; - order_id: string; - gateway: string; - kind: string | null; - detail: string | null; - received_at: string; -} - -export interface EntitlementsTable { - id: string; - order_id: string; - product_id: string | null; - sku: string; - buyer_ref: string; - state: string; - source: string; - granted_at: string; - grant_idempotency_key: string; -} - -// -- Phase 5 (§4/§5): customers, addresses, sessions, login challenges, outbox -- - -/** Storefront customer identity — separate from EmDash `ctx.users` (§4). */ -export interface CustomersTable { - id: string; - /** Unique, lower-normalized (the domain `Email` brand normalizes). */ - email: string; - display_name: string | null; - email_verified_at: string | null; - created_at: string; -} - -/** Opaque DB-backed sessions (§4/§9 decision 5). Only `token_hash` is stored — - * never the plaintext token. */ -export interface CustomerSessionsTable { - id: string; - customer_id: string; - token_hash: string; - created_at: string; - expires_at: string; - revoked_at: string | null; -} - -/** One-time magic-link challenges (§4). Token stored as a hash; single-use via - * `consumed_at`. */ -export interface LoginChallengesTable { - id: string; - email: string; - token_hash: string; - created_at: string; - expires_at: string; - consumed_at: string | null; -} - -export interface AddressesTable { - id: string; - customer_id: string; - kind: string; - name: string; - line1: string; - line2: string | null; - city: string; - region: string | null; - postal_code: string; - country: string; - /** Portable 0/1 (not SQL boolean — better-sqlite3 cannot bind a JS boolean). */ - is_default: number; - created_at: string; -} - -/** - * Order-status email outbox (§5). The guarded state `UPDATE` and the outbox - * `INSERT` commit in one transaction; `UNIQUE(order_id, to_state)` makes the - * enqueue exactly-once, and the conditional claim (`status`/`lease_until`) makes - * the CLAIM exactly-once (no two dispatchers hold the same row's lease at once). - * Actual delivery is at-least-once: a crash between `EmailSender.send()` and - * marking the row sent lets the lease expire and the row be re-claimed and - * re-sent. Dedup to effectively-once relies on the provider's `Idempotency-Key` - * (see `HttpEmailSender`). - */ -export interface OrderEmailsOutboxTable { - id: string; - order_id: string; - to_state: string; - /** pending | sending | sent | failed. */ - status: string; - attempts: number; - /** Claim lease deadline (nullable — set on claim, cleared on reschedule). */ - lease_until: string | null; - sent_at: string | null; - created_at: string; -} - -/** Append-only merchant notes on an order (admin-UX Increment 0). Guarded by - * `idempotency_key` UNIQUE; listed `order_id` + `created_at ASC, id ASC`. */ -export interface OrderNotesTable { - id: string; - order_id: string; - author: string; - body: string; - idempotency_key: string; - created_at: string; -} - -/** - * Append-only state-change audit (admin-UX Increment 1, timeline slice). One row - * is INSERTed inside the SAME guarded-flip transaction that moves an order (the - * `#flipAndEnqueue` choke point), so a row exists iff the flip won — no row for a - * replayed/lost-race flip. Listed `order_id` + `at ASC, id ASC` (both fixed-width - * text, so lexical order IS chronological — dialect-identical). `kind` is - * currently always `'state_change'` (a text column, so new kinds need no DDL). - */ -export interface OrderEventsTable { - id: string; - order_id: string; - /** ISO-8601 UTC — the store clock at the flip. */ - at: string; - kind: string; - from_state: string | null; - to_state: string | null; - actor: string | null; -} - -// -- Phase 6 (§5): shipping / tax / coupons ----------------------------------- - -/** Country/state/postal match list is opaque JSON-as-text config. */ -export interface ShippingZonesTable { - id: string; - name: string; - regions: string | null; -} - -export interface ShippingMethodsTable { - id: string; - zone_id: string; - name: string; - /** 'flat_rate' | 'free_shipping'. */ - type: string; -} - -export interface ShippingRatesTable { - method_id: string; - currency: string; - amount_cents: number; - /** Free-shipping threshold; null = none. */ - min_subtotal_cents: number | null; -} - -export interface TaxClassesTable { - id: string; - name: string; -} - -export interface TaxRatesTable { - id: string; - tax_class_id: string; - zone_id: string; - rate_bps: number; - /** Portable 0/1 (not SQL boolean — better-sqlite3 cannot bind a JS boolean). */ - applies_to_shipping: number; -} - -export interface CouponsTable { - id: string; - code: string; - type: string; - amount_cents: number | null; - rate_bps: number | null; - cap_cents: number | null; - currency: string | null; - min_subtotal_cents: number | null; - starts_at: string | null; - expires_at: string | null; - max_uses: number | null; - max_uses_per_customer: number | null; - uses_count: number; - /** Admin-UX Increment 3 (`listCoupons`'s keyset order): added additively by - * `0018_coupons_admin_list.ts`, `NOT NULL DEFAULT '1970-01-01T00:00:00.000Z'` - * — a pre-migration row (if any) sorts to the very end of the DESC list - * deterministically on BOTH dialects, avoiding the pg/sqlite NULL-ordering - * divergence a nullable column would introduce for the sort key. `create` - * stamps a real value from the injected `Clock` for every new coupon. */ - created_at: string; -} - -/** One row per redemption; `UNIQUE(coupon_id, idempotency_key)` makes a replay of - * the same checkout a no-op re-read (mirrors `reservations.idempotency_key`). */ -export interface CouponRedemptionsTable { - id: string; - coupon_id: string; - order_id: string; - customer_id: string | null; - idempotency_key: string; - created_at: string; -} - -// -- Phase 7 (§5): operational settings (service-DB tier) --------------------- - -/** Single-row typed operational settings (§7 Step 3). `id` is a fixed sentinel - * ('singleton') so there is at most one row; `get` returns defaults when absent. */ -export interface SettingsTable { - id: string; - hold_ttl_minutes: number; - low_stock_threshold: number; - updated_at: string; -} - -/** Idempotency ledger for `SettingsStore.update` — records the RESULTING settings - * per key so a replay returns the recorded snapshot without re-applying (a stale - * replay never clobbers a newer write). Mirrors `coupon_redemptions`'s claim. */ -export interface SettingsMutationsTable { - idempotency_key: string; - hold_ttl_minutes: number; - low_stock_threshold: number; - created_at: string; -} - -export interface Database { - inventory: InventoryTable; - reservations: ReservationsTable; - product_commerce: ProductCommerceTable; - product_variants: ProductVariantsTable; - carts: CartsTable; - cart_lines: CartLinesTable; - cart_mutations: CartMutationsTable; - inventory_adjustments: InventoryAdjustmentsTable; - inventory_stock_movements: InventoryStockMovementsTable; - orders: OrdersTable; - order_items: OrderItemsTable; - order_totals: OrderTotalsTable; - order_shipping_address: OrderShippingAddressTable; - payments: PaymentsTable; - refunds: RefundsTable; - payment_events: PaymentEventsTable; - entitlements: EntitlementsTable; - customers: CustomersTable; - customer_sessions: CustomerSessionsTable; - login_challenges: LoginChallengesTable; - addresses: AddressesTable; - order_emails_outbox: OrderEmailsOutboxTable; - order_notes: OrderNotesTable; - order_events: OrderEventsTable; - // Phase 6: - shipping_zones: ShippingZonesTable; - shipping_methods: ShippingMethodsTable; - shipping_rates: ShippingRatesTable; - tax_classes: TaxClassesTable; - tax_rates: TaxRatesTable; - coupons: CouponsTable; - coupon_redemptions: CouponRedemptionsTable; - // Phase 7: - settings: SettingsTable; - settings_mutations: SettingsMutationsTable; -} diff --git a/packages/store-postgres/src/testing.ts b/packages/store-postgres/src/testing.ts deleted file mode 100644 index fe6d585c..00000000 --- a/packages/store-postgres/src/testing.ts +++ /dev/null @@ -1,83 +0,0 @@ -import type { Kysely } from "kysely"; -import { makePostgresDb, makePostgresPool } from "./dialects.js"; -import { migrateToLatest } from "./migrations/index.js"; -import type { Database } from "./schema.js"; - -export interface IsolatedPgSchema { - db: Kysely; - schema: string; - /** Close the pools and drop the schema. */ - teardown(): Promise; -} - -export interface IsolatedPgSchemaOptions { - /** Pool size — must be ≥ N for N concurrent reserves on independent conns. */ - poolMax?: number; -} - -/** - * Per-test Postgres isolation (§8 R7), shared by every pg-backed test - * (no-oversell, the dialect contract harness, the live-server helper): - * `CREATE SCHEMA test_` + a pool whose every connection is pinned to it - * via `search_path`, migrated to latest; the schema is dropped and the pools - * closed on `teardown`. - */ -export async function createIsolatedPgSchema( - connectionString: string, - options: IsolatedPgSchemaOptions = {}, -): Promise { - const schema = `test_${crypto.randomUUID().replace(/-/g, "").slice(0, 16)}`; - - const admin = makePostgresPool({ connectionString, max: 1 }); - try { - await admin.query(`CREATE SCHEMA "${schema}"`); - } catch (err) { - await admin.end().catch(() => {}); - throw err; - } - - const pool = makePostgresPool({ - connectionString, - max: options.poolMax ?? 8, - options: `-c search_path=${schema}`, - }); - const db = makePostgresDb(pool); - try { - // Scoped to THIS schema: without `migrationTableSchema` the Migrator's - // existence check can match another live test schema's tables and fail — - // the concurrent-schema flake (see MigrateToLatestOptions). Retried: the Migrator's - // existence check introspects EVERY table in the database, so it can trip - // over a peer test's `DROP SCHEMA … CASCADE` mid-scan (a transient pg - // catalog race). The schema is brand new and empty, so re-running the - // migration is safe. - let lastError: unknown; - for (let attempt = 0; attempt < 3; attempt++) { - try { - await migrateToLatest(db, { migrationTableSchema: schema }); - lastError = undefined; - break; - } catch (err) { - lastError = err; - await new Promise((resolve) => setTimeout(resolve, 100 * (attempt + 1))); - } - } - if (lastError !== undefined) throw lastError; - } catch (err) { - // Setup failure must not leak the pool (connection exhaustion for every - // later test) or the schema (test_* litter in the shared test database). - await db.destroy().catch(() => {}); - await admin.query(`DROP SCHEMA "${schema}" CASCADE`).catch(() => {}); - await admin.end().catch(() => {}); - throw err; - } - - return { - db, - schema, - async teardown() { - await db.destroy(); - await admin.query(`DROP SCHEMA "${schema}" CASCADE`); - await admin.end(); - }, - }; -} diff --git a/packages/store-postgres/test/address-book-contract.dialects.test.ts b/packages/store-postgres/test/address-book-contract.dialects.test.ts deleted file mode 100644 index eb522eed..00000000 --- a/packages/store-postgres/test/address-book-contract.dialects.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { addressBookContract } from "@otta-sh/domain/testing"; -import { afterEach, describe } from "vitest"; -import { PG_ENABLED } from "./describe-each-dialect.js"; -import { - makePgAddressHarness, - makeSqliteAddressHarness, - teardownCustomers, -} from "./customer-harness.js"; - -afterEach(teardownCustomers); - -addressBookContract(makeSqliteAddressHarness, { dialect: "sqlite" }); - -describe.skipIf(!PG_ENABLED)("[postgres]", () => { - addressBookContract(makePgAddressHarness, { dialect: "pg" }); -}); diff --git a/packages/store-postgres/test/adjust-concurrency.pg.test.ts b/packages/store-postgres/test/adjust-concurrency.pg.test.ts deleted file mode 100644 index f78ae302..00000000 --- a/packages/store-postgres/test/adjust-concurrency.pg.test.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { - addLine, - type CartDeps, - createCart, - currency, - idempotencyKey, - sku, - updateLine, -} from "@otta-sh/domain"; -import { FixedClock } from "@otta-sh/domain/testing"; -import type { Kysely } from "kysely"; -import { afterEach, describe, expect, test } from "vitest"; -import { KyselyCartStore, KyselyInventoryStore, uuidIdGen } from "../src/index.js"; -import type { Database } from "../src/schema.js"; -import { createIsolatedPgSchema } from "../src/testing.js"; - -// B2 — adjust must be exactly-once under REAL concurrency (Postgres-required, -// independent connections; better-sqlite3 serializes on one connection and -// cannot race). The claim (`INSERT … ON CONFLICT`) + guarded CAS + movement -// commit in one tx, so a double-clicked PATCH moves stock once and racing -// different-key adjusts settle with no lost update and no over-return. - -const PG = process.env.PG_CONNECTION_STRING; -const USD = currency("USD"); - -interface Fixture { - deps: CartDeps; - store: KyselyInventoryStore; - db: Kysely; - seed(sku: string, qty: number): Promise; - onHand(sku: string): Promise; - reservationQty(id: string): Promise; -} - -const cleanups: Array<() => Promise> = []; -afterEach(async () => { - for (const fn of cleanups.splice(0)) await fn(); -}); - -async function freshFixture(poolMax: number): Promise { - if (PG === undefined) throw new Error("PG_CONNECTION_STRING is not set"); - const iso = await createIsolatedPgSchema(PG, { poolMax }); - cleanups.push(() => iso.teardown()); - const db = iso.db; - const clock = new FixedClock(new Date("2026-07-10T00:00:00.000Z")); - const store = new KyselyInventoryStore({ db, idGen: uuidIdGen, clock }); - const cartStore = new KyselyCartStore({ db, idGen: uuidIdGen, clock }); - return { - deps: { cartStore, inventoryStore: store, clock }, - store, - db, - async seed(s, qty) { - await db - .insertInto("inventory") - .values({ sku: s, on_hand: qty }) - .onConflict((oc) => oc.column("sku").doUpdateSet({ on_hand: qty })) - .execute(); - }, - async onHand(s) { - const row = await db - .selectFrom("inventory") - .select("on_hand") - .where("sku", "=", s) - .executeTakeFirst(); - return row?.on_hand ?? 0; - }, - async reservationQty(id) { - const row = await db - .selectFrom("reservations") - .select("qty") - .where("id", "=", id) - .executeTakeFirstOrThrow(); - return row.qty; - }, - }; -} - -describe.skipIf(PG === undefined)("adjust concurrency [postgres]", () => { - test("concurrent same-key adjusts move inventory exactly once and leave reservation and line consistent", async () => { - const RACERS = 12; - const LOOPS = 10; - const h = await freshFixture(RACERS + 4); - - for (let loop = 0; loop < LOOPS; loop++) { - const skuName = `SKU-${loop}`; - await h.seed(skuName, 100); - const cartId = await createCart(h.deps, USD); - const add = await addLine( - h.deps, - cartId, - sku(skuName), - null, - 2, - idempotencyKey(`add-${loop}`), - ); - if (!add.ok) throw new Error("seed add must succeed"); - const lineId = add.line.lineId; - const reservationId = add.line.reservationId ?? ""; - - // A double-(×12)-clicked "set qty to 7": every racer shares ONE key. - const key = idempotencyKey(`same-${loop}`); - const results = await Promise.all( - Array.from({ length: RACERS }, () => updateLine(h.deps, cartId, lineId, 7, key)), - ); - - for (const r of results) expect(r.ok, `loop ${loop}: all racers ok`).toBe(true); - // The delta (7−2=5) applied EXACTLY once: 100 − 2 − 5 = 93. - expect(await h.onHand(skuName), `loop ${loop}: on_hand`).toBe(93); - expect(await h.reservationQty(reservationId), `loop ${loop}: reservation qty`).toBe(7); - const line = await h.db - .selectFrom("cart_lines") - .select("qty") - .where("id", "=", lineId) - .executeTakeFirstOrThrow(); - expect(line.qty, `loop ${loop}: line qty`).toBe(7); - } - }, 120_000); - - test("concurrent different-key adjusts on one line settle consistently: no lost update, no over-return", async () => { - const LOOPS = 10; - const M = 100; - const h = await freshFixture(12); - - for (let loop = 0; loop < LOOPS; loop++) { - const skuName = `SKU-${loop}`; - await h.seed(skuName, M); - const cartId = await createCart(h.deps, USD); - const add = await addLine( - h.deps, - cartId, - sku(skuName), - null, - 5, - idempotencyKey(`add-${loop}`), - ); - if (!add.ok) throw new Error("seed add must succeed"); - const lineId = add.line.lineId; - const reservationId = add.line.reservationId ?? ""; - - // Distinct user intents racing on one line: →2, →9, →4, →7. - const targets = [2, 9, 4, 7]; - const results = await Promise.all( - targets.map((t, i) => - updateLine(h.deps, cartId, lineId, t, idempotencyKey(`k-${loop}-${i}`)), - ), - ); - for (const r of results) expect(r.ok, `loop ${loop}: all adjusts settle ok`).toBe(true); - - // CONSERVATION — the invariant that forbids both a lost update (stock - // leaked to the shelf) and an over-return: whatever the serialization - // order, held + on-hand must equal the seeded total, and the reservation - // must have landed on one of the requested targets with the line synced. - const resQty = await h.reservationQty(reservationId); - expect(targets, `loop ${loop}: final qty is a requested target`).toContain(resQty); - expect(await h.onHand(skuName), `loop ${loop}: conservation`).toBe(M - resQty); - const line = await h.db - .selectFrom("cart_lines") - .select("qty") - .where("id", "=", lineId) - .executeTakeFirstOrThrow(); - expect(line.qty, `loop ${loop}: line mirrors the reservation`).toBe(resQty); - } - }, 120_000); -}); diff --git a/packages/store-postgres/test/cart-fence.dialects.test.ts b/packages/store-postgres/test/cart-fence.dialects.test.ts deleted file mode 100644 index a601db47..00000000 --- a/packages/store-postgres/test/cart-fence.dialects.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { - addLine, - createCart, - currency, - getCart, - idempotencyKey, - removeLine, - sku, - updateLine, -} from "@otta-sh/domain"; -import { afterEach, describe, expect, test } from "vitest"; -import { - type CartDialectHarness, - makePgCartHarness, - makeSqliteCartHarness, - PG_ENABLED, - teardownDialects, -} from "./describe-each-dialect.js"; - -// C5 (required, §4/§7) — cart-mutation fences against real stores: a cart-initiated -// release/adjust on a non-`held` (adopted) hold matches 0 rows → LINE_CHECKED_OUT -// with no stock moved; a mutation on a `checked_out` cart is CART_CHECKED_OUT. -afterEach(teardownDialects); - -const USD = currency("USD"); - -function runFence(make: () => Promise, dialect: string): void { - describe(`cart-mutation fences [${dialect}]`, () => { - // Adoption is Phase 4; here we flip the hold to `committed` to take it out - // of the cart's `held`-only ownership. - async function cartWithAdoptedLine(h: CartDialectHarness) { - await h.seedStock("SKU-1", 5); - const cartId = await createCart(h.deps, USD); - const add = await addLine(h.deps, cartId, sku("SKU-1"), null, 2, idempotencyKey("k1")); - if (!add.ok) throw new Error("add must succeed"); - await h.db - .updateTable("reservations") - .set({ state: "committed" }) - .where("id", "=", add.line.reservationId ?? "") - .execute(); - return { cartId, lineId: add.line.lineId }; - } - - test("adjust on an adopted hold is LINE_CHECKED_OUT and moves no stock", async () => { - const h = await make(); - const { cartId, lineId } = await cartWithAdoptedLine(h); - expect(await h.onHand("SKU-1")).toBe(3); - const res = await updateLine(h.deps, cartId, lineId, 4, idempotencyKey("k2")); - expect(res).toEqual({ ok: false, reason: "LINE_CHECKED_OUT" }); - expect(await h.onHand("SKU-1")).toBe(3); - expect((await getCart(h.deps, cartId))?.lines[0]?.qty).toBe(2); - }); - - test("remove on an adopted hold is LINE_CHECKED_OUT and releases nothing", async () => { - const h = await make(); - const { cartId, lineId } = await cartWithAdoptedLine(h); - const res = await removeLine(h.deps, cartId, lineId, idempotencyKey("k2")); - expect(res).toEqual({ ok: false, reason: "LINE_CHECKED_OUT" }); - expect(await h.onHand("SKU-1")).toBe(3); - expect((await getCart(h.deps, cartId))?.lines).toHaveLength(1); - }); - - test("any mutation on a checked_out cart is CART_CHECKED_OUT", async () => { - const h = await make(); - await h.seedStock("SKU-1", 5); - const cartId = await createCart(h.deps, USD); - const add = await addLine(h.deps, cartId, sku("SKU-1"), null, 2, idempotencyKey("k1")); - if (!add.ok) throw new Error("add must succeed"); - await h.db - .updateTable("carts") - .set({ state: "checked_out" }) - .where("id", "=", cartId) - .execute(); - - const up = await updateLine(h.deps, cartId, add.line.lineId, 3, idempotencyKey("k2")); - const rm = await removeLine(h.deps, cartId, add.line.lineId, idempotencyKey("k3")); - expect(up).toEqual({ ok: false, reason: "CART_CHECKED_OUT" }); - expect(rm).toEqual({ ok: false, reason: "CART_CHECKED_OUT" }); - expect(await h.onHand("SKU-1")).toBe(3); // nothing moved - }); - }); -} - -runFence(makeSqliteCartHarness, "sqlite"); -describe.skipIf(!PG_ENABLED)("[postgres]", () => { - runFence(makePgCartHarness, "pg"); -}); diff --git a/packages/store-postgres/test/cart-store-contract.dialects.test.ts b/packages/store-postgres/test/cart-store-contract.dialects.test.ts deleted file mode 100644 index a547636d..00000000 --- a/packages/store-postgres/test/cart-store-contract.dialects.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { cartStoreContract } from "@otta-sh/domain/testing"; -import { afterEach, describe } from "vitest"; -import { - makePgCartHarness, - makeSqliteCartHarness, - PG_ENABLED, - teardownDialects, -} from "./describe-each-dialect.js"; - -// The SAME reusable cart contract (§1 cases 1–8) runs against every DB dialect: -// SQLite always, Postgres only when PG_CONNECTION_STRING is set. The Kysely -// CartStore + inventory adjust/partial-release + guarded-flip expiry are exercised -// end-to-end through the use-cases on real DBs. -afterEach(teardownDialects); - -cartStoreContract(makeSqliteCartHarness, { dialect: "sqlite" }); - -describe.skipIf(!PG_ENABLED)("[postgres]", () => { - cartStoreContract(makePgCartHarness, { dialect: "pg" }); -}); diff --git a/packages/store-postgres/test/coupon-lifecycle.dialects.test.ts b/packages/store-postgres/test/coupon-lifecycle.dialects.test.ts deleted file mode 100644 index 18d5c067..00000000 --- a/packages/store-postgres/test/coupon-lifecycle.dialects.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { - cents, - createOrderFromCart, - currency, - expireOrders, - idempotencyKey, - settleOrder, -} from "@otta-sh/domain"; -import { afterEach, describe, expect, test } from "vitest"; -import { - makePgOrderFlow, - makeSqliteOrderFlow, - type OrderFlowHarness, - teardownOrderFlow, -} from "./order-harness.js"; - -const PG = process.env.PG_CONNECTION_STRING; -const USD = currency("USD"); - -afterEach(teardownOrderFlow); - -async function seedCoupon(h: OrderFlowHarness): Promise { - await h.couponStore.create({ - id: "cpn", - code: "SAVE5", - type: "fixed_amount", - amountCents: cents(500), - rateBps: null, - capCents: null, - currency: USD, - minSubtotalCents: null, - startsAt: null, - expiresAt: null, - maxUses: 5, - maxUsesPerCustomer: null, - }); -} - -function cmd(cartId: string) { - return { - cartId, - idempotencyKey: idempotencyKey("k-checkout"), - buyerRef: "buyer@example.com", - paymentMethod: "stripe" as const, - couponCode: "SAVE5", - }; -} - -// Review I2: the coupon lifecycle must stay symmetric with inventory — a durable -// pending order that EXPIRES or has payment FAIL frees its coupon; a PAID order -// keeps it consumed. -function suite(makeHarness: () => Promise, dialect: string): void { - describe(`coupon lifecycle [${dialect}]`, () => { - async function checkout(h: OrderFlowHarness) { - await h.seedPhysical({ - productId: "p1", - sku: "SKU-1", - priceCents: 1500, - title: "Widget", - onHand: 5, - }); - await seedCoupon(h); - const cartId = await h.cartWith([ - { sku: "SKU-1", productId: "p1", qty: 1, kind: "physical" }, - ]); - const res = await createOrderFromCart(h.createDeps, cmd(cartId)); - if (!res.ok) throw new Error(res.reason); - expect((await h.couponStore.findById("cpn"))?.usesCount).toBe(1); - return res.order; - } - - test("after a durable order EXPIRES, uses_count returns to its pre-redemption value", async () => { - const h = await makeHarness(); - const order = await checkout(h); - // Advance past the checkout TTL and run the expiry sweep. - h.clock.advance(16 * 60 * 1000); - const expired = await expireOrders(h.expireDeps); - expect(expired).toBe(1); - expect((await h.orderStore.getById(order.id))?.state).toBe("expired"); - // Symmetric with the inventory release: the coupon is freed. - expect((await h.couponStore.findById("cpn"))?.usesCount).toBe(0); - }); - - test("after a durable order's payment FAILS, uses_count returns to its pre-redemption value", async () => { - const h = await makeHarness(); - const order = await checkout(h); - const raw = h.stripeGw.webhook({ - outcome: "failed", - orderId: order.id, - providerRef: `pi_${order.id}`, - amount: order.totals.total, - currency: "USD", - dedupeKey: `evt-fail-${order.id}`, - }); - const settled = await settleOrder(h.settleDeps, h.stripeGw, raw); - expect(settled.ok).toBe(true); - expect((await h.orderStore.getById(order.id))?.state).toBe("failed"); - expect((await h.couponStore.findById("cpn"))?.usesCount).toBe(0); - }); - - test("a PAID order does NOT release its coupon — the use stays consumed", async () => { - const h = await makeHarness(); - const order = await checkout(h); - const raw = h.stripeGw.webhook({ - outcome: "succeeded", - orderId: order.id, - providerRef: `pi_${order.id}`, - amount: order.totals.total, - currency: "USD", - dedupeKey: `evt-ok-${order.id}`, - }); - const settled = await settleOrder(h.settleDeps, h.stripeGw, raw); - expect(settled.ok).toBe(true); - expect((await h.orderStore.getById(order.id))?.state).toBe("paid"); - // A completed/paid order keeps its coupon consumed. - expect((await h.couponStore.findById("cpn"))?.usesCount).toBe(1); - }); - }); -} - -suite(makeSqliteOrderFlow, "sqlite"); -if (PG !== undefined) suite(makePgOrderFlow, "postgres"); diff --git a/packages/store-postgres/test/coupon-no-over-redeem.pg.test.ts b/packages/store-postgres/test/coupon-no-over-redeem.pg.test.ts deleted file mode 100644 index 382a7802..00000000 --- a/packages/store-postgres/test/coupon-no-over-redeem.pg.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { customerId, idempotencyKey, orderId } from "@otta-sh/domain"; -import type { Kysely } from "kysely"; -import { afterEach, describe, expect, test } from "vitest"; -import { KyselyCouponStore, uuidIdGen } from "../src/index.js"; -import type { Database } from "../src/schema.js"; -import { createIsolatedPgSchema } from "../src/testing.js"; - -const PG = process.env.PG_CONNECTION_STRING; - -interface Fixture { - store: KyselyCouponStore; - db: Kysely; -} - -const cleanups: Array<() => Promise> = []; -afterEach(async () => { - for (const fn of cleanups.splice(0)) await fn(); -}); - -async function freshStore(poolMax: number): Promise { - if (PG === undefined) throw new Error("PG_CONNECTION_STRING is not set"); - const iso = await createIsolatedPgSchema(PG, { poolMax }); - cleanups.push(() => iso.teardown()); - return { - store: new KyselyCouponStore({ - db: iso.db, - idGen: uuidIdGen, - clock: { now: () => new Date() }, - }), - db: iso.db, - }; -} - -async function seedCoupon( - db: Kysely, - maxUses: number, - maxUsesPerCustomer: number | null = null, -): Promise { - await db - .insertInto("coupons") - .values({ - id: "c1", - code: "RACE", - type: "fixed_amount", - amount_cents: 500, - rate_bps: null, - cap_cents: null, - currency: "USD", - min_subtotal_cents: null, - starts_at: null, - expires_at: null, - max_uses: maxUses, - max_uses_per_customer: maxUsesPerCustomer, - uses_count: 0, - created_at: "2026-07-10T00:00:00.000Z", - }) - .execute(); -} - -// This is Phase 6's analogue of the Phase-0.5 no-oversell gate. better-sqlite3 -// serializes writes in-process and cannot exercise the race, so it is -// Postgres-required (DEVELOPMENT.md §2). -describe.skipIf(PG === undefined)("coupon no-over-redeem [postgres]", () => { - test("fires N concurrent redeem() at maxUses M (M { - const M = 5; - const N = 50; - const LOOPS = 20; - const h = await freshStore(N + 4); - - for (let loop = 0; loop < LOOPS; loop++) { - await h.db.deleteFrom("coupon_redemptions").execute(); - await h.db.deleteFrom("coupons").execute(); - await seedCoupon(h.db, M); - - const results = await Promise.all( - Array.from({ length: N }, (_v, i) => - h.store.redeem({ - couponId: "c1", - orderId: orderId(`o-${loop}-${i}`), - idempotencyKey: idempotencyKey(`k-${loop}-${i}`), - createdAt: "2026-07-10T00:00:00.000Z", - }), - ), - ); - - const ok = results.filter((r) => r.ok).length; - const exhausted = results.filter((r) => !r.ok).length; - expect(ok, `loop ${loop}: ok count`).toBe(M); - expect(exhausted, `loop ${loop}: exhausted count`).toBe(N - M); - - const coupon = await h.store.findById("c1"); - expect(coupon?.usesCount, `loop ${loop}: uses_count`).toBe(M); - // Exactly M durable redemption rows — the exhausted attempts rolled back. - const rows = await h.db.selectFrom("coupon_redemptions").selectAll().execute(); - expect(rows, `loop ${loop}: redemption rows`).toHaveLength(M); - } - }, 120_000); - - test("I3: two same-customer concurrent redeems at maxUsesPerCustomer=1 (different keys) → exactly one succeeds — Postgres", async () => { - const LOOPS = 15; - const h = await freshStore(8); - const cust = customerId("cust-1"); - for (let loop = 0; loop < LOOPS; loop++) { - await h.db.deleteFrom("coupon_redemptions").execute(); - await h.db.deleteFrom("coupons").execute(); - await seedCoupon(h.db, 100, 1); // ample global, per-customer cap of 1 - - const results = await Promise.all([ - h.store.redeem({ - couponId: "c1", - orderId: orderId(`o-${loop}-a`), - idempotencyKey: idempotencyKey(`k-${loop}-a`), - customerId: cust, - createdAt: "2026-07-10T00:00:00.000Z", - }), - h.store.redeem({ - couponId: "c1", - orderId: orderId(`o-${loop}-b`), - idempotencyKey: idempotencyKey(`k-${loop}-b`), - customerId: cust, - createdAt: "2026-07-10T00:00:00.000Z", - }), - ]); - const ok = results.filter((r) => r.ok).length; - expect(ok, `loop ${loop}: exactly one same-customer redeem succeeds`).toBe(1); - expect((await h.store.findById("c1"))?.usesCount, `loop ${loop}: uses_count`).toBe(1); - const rows = await h.db.selectFrom("coupon_redemptions").selectAll().execute(); - expect(rows, `loop ${loop}: one redemption row`).toHaveLength(1); - } - }, 60_000); - - test("concurrent redeem() sharing the same idempotency key redeems exactly once — Postgres", async () => { - const N = 20; - const h = await freshStore(N + 4); - await seedCoupon(h.db, 10); - const key = idempotencyKey("same-key"); - - const results = await Promise.all( - Array.from({ length: N }, () => - h.store.redeem({ - couponId: "c1", - orderId: orderId("o1"), - idempotencyKey: key, - createdAt: "2026-07-10T00:00:00.000Z", - }), - ), - ); - - // Every caller resolves to the same single redemption; uses_count moves once. - const ok = results.filter((r) => r.ok); - expect(ok).toHaveLength(N); - const ids = new Set(ok.map((r) => (r.ok ? r.redemptionId : ""))); - expect(ids.size).toBe(1); - expect((await h.store.findById("c1"))?.usesCount).toBe(1); - const rows = await h.db.selectFrom("coupon_redemptions").selectAll().execute(); - expect(rows).toHaveLength(1); - }, 60_000); -}); diff --git a/packages/store-postgres/test/coupon-reconciliation.dialects.test.ts b/packages/store-postgres/test/coupon-reconciliation.dialects.test.ts deleted file mode 100644 index c0e7bd00..00000000 --- a/packages/store-postgres/test/coupon-reconciliation.dialects.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { idempotencyKey, orderId, reconcileCouponRedemptions } from "@otta-sh/domain"; -import { CountingIdGen, FixedClock } from "@otta-sh/domain/testing"; -import type { Kysely } from "kysely"; -import { afterEach, describe, expect, test } from "vitest"; -import { - KyselyCouponStore, - KyselyOrderStore, - makeSqliteDb, - migrateToLatest, - uuidIdGen, -} from "../src/index.js"; -import type { Database } from "../src/schema.js"; -import { createIsolatedPgSchema } from "../src/testing.js"; - -const PG = process.env.PG_CONNECTION_STRING; - -const cleanups: Array<() => Promise> = []; -afterEach(async () => { - for (const fn of cleanups.splice(0)) await fn(); -}); - -interface Fixture { - couponStore: KyselyCouponStore; - orderStore: KyselyOrderStore; - db: Kysely; - clock: FixedClock; -} - -function build(db: Kysely): Fixture { - const clock = new FixedClock(new Date("2026-07-10T01:00:00.000Z")); - return { - db, - clock, - couponStore: new KyselyCouponStore({ db, idGen: uuidIdGen, clock }), - orderStore: new KyselyOrderStore({ db, idGen: new CountingIdGen("oi"), clock }), - }; -} - -async function makeSqliteFixture(): Promise { - const db = makeSqliteDb(":memory:"); - await migrateToLatest(db); - cleanups.push(async () => { - await db.destroy(); - }); - return build(db); -} - -async function makePgFixture(): Promise { - if (PG === undefined) throw new Error("PG_CONNECTION_STRING is not set"); - const iso = await createIsolatedPgSchema(PG, { poolMax: 4 }); - cleanups.push(() => iso.teardown()); - return build(iso.db); -} - -async function seedCoupon(db: Kysely): Promise { - await db - .insertInto("coupons") - .values({ - id: "c1", - code: "SWEEP", - type: "fixed_amount", - amount_cents: 500, - rate_bps: null, - cap_cents: null, - currency: "USD", - min_subtotal_cents: null, - starts_at: null, - expires_at: null, - max_uses: 10, - max_uses_per_customer: null, - uses_count: 0, - created_at: "2026-07-10T00:00:00.000Z", - }) - .execute(); -} - -/** Insert a minimal durable order + order_totals row (so getById returns it). */ -async function seedDurableOrder(db: Kysely, id: string): Promise { - await db - .insertInto("orders") - .values({ - id, - cart_id: null, - currency: "USD", - state: "pending", - idempotency_key: `order-key-${id}`, - hold_expires_at: "2026-07-10T02:00:00.000Z", - payment_method: null, - buyer_ref: "b@example.com", - created_at: "2026-07-10T00:00:00.000Z", - updated_at: "2026-07-10T00:00:00.000Z", - }) - .execute(); - await db - .insertInto("order_totals") - .values({ - order_id: id, - currency: "USD", - subtotal_cents: 500, - discount_cents: 0, - shipping_cents: 0, - tax_cents: 0, - total_cents: 500, - applied_coupon_code: null, - shipping_method_snapshot: null, - tax_breakdown: null, - }) - .execute(); -} - -const GRACE = { graceMs: 15 * 60 * 1000 }; - -function suite(makeFixture: () => Promise, dialect: string): void { - describe(`coupon reconciliation sweep [${dialect}]`, () => { - test("releases a redemption whose order never became durable within the grace window, and leaves alone one whose order exists", async () => { - const fx = await makeFixture(); - await seedCoupon(fx.db); - - // Redemption A: order NEVER became durable (crash mid-request), created - // before the grace cutoff (now 01:00, grace 15m ⇒ cutoff 00:45). - const a = await fx.couponStore.redeem({ - couponId: "c1", - orderId: orderId("o-stranded"), - idempotencyKey: idempotencyKey("k-a"), - createdAt: "2026-07-10T00:00:00.000Z", - }); - // Redemption B: its order IS durable — must be left alone. - await seedDurableOrder(fx.db, "o-durable"); - const b = await fx.couponStore.redeem({ - couponId: "c1", - orderId: orderId("o-durable"), - idempotencyKey: idempotencyKey("k-b"), - createdAt: "2026-07-10T00:00:00.000Z", - }); - expect(a.ok && b.ok).toBe(true); - if (!a.ok || !b.ok) return; - expect((await fx.couponStore.findById("c1"))?.usesCount).toBe(2); - - const released = await reconcileCouponRedemptions( - { couponStore: fx.couponStore, orderStore: fx.orderStore, clock: fx.clock }, - GRACE, - ); - expect(released).toBe(1); - - // A was released (row gone, uses decremented); B untouched. - const remaining = await fx.couponStore.listRedemptionsCreatedBefore( - "9999-12-31T00:00:00.000Z", - ); - expect(remaining.map((r) => r.id)).toEqual([b.redemptionId]); - expect((await fx.couponStore.findById("c1"))?.usesCount).toBe(1); - }); - - test("does not release a stranded redemption still inside the grace window", async () => { - const fx = await makeFixture(); - await seedCoupon(fx.db); - // Created at 00:50, cutoff is 00:45 ⇒ not yet eligible. - await fx.couponStore.redeem({ - couponId: "c1", - orderId: orderId("o-recent"), - idempotencyKey: idempotencyKey("k-recent"), - createdAt: "2026-07-10T00:50:00.000Z", - }); - const released = await reconcileCouponRedemptions( - { couponStore: fx.couponStore, orderStore: fx.orderStore, clock: fx.clock }, - GRACE, - ); - expect(released).toBe(0); - expect((await fx.couponStore.findById("c1"))?.usesCount).toBe(1); - }); - }); -} - -suite(makeSqliteFixture, "sqlite"); -if (PG !== undefined) suite(makePgFixture, "postgres"); diff --git a/packages/store-postgres/test/credential-verifier-contract.dialects.test.ts b/packages/store-postgres/test/credential-verifier-contract.dialects.test.ts deleted file mode 100644 index f1fd9726..00000000 --- a/packages/store-postgres/test/credential-verifier-contract.dialects.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { credentialVerifierContract } from "@otta-sh/domain/testing"; -import { afterEach, describe } from "vitest"; -import { PG_ENABLED } from "./describe-each-dialect.js"; -import { - makePgVerifierHarness, - makeSqliteVerifierHarness, - teardownCustomers, -} from "./customer-harness.js"; - -afterEach(teardownCustomers); - -credentialVerifierContract(makeSqliteVerifierHarness, { dialect: "sqlite" }); - -describe.skipIf(!PG_ENABLED)("[postgres]", () => { - credentialVerifierContract(makePgVerifierHarness, { dialect: "pg" }); -}); diff --git a/packages/store-postgres/test/customer-harness.ts b/packages/store-postgres/test/customer-harness.ts deleted file mode 100644 index 9be08156..00000000 --- a/packages/store-postgres/test/customer-harness.ts +++ /dev/null @@ -1,129 +0,0 @@ -import type { - AddressBookHarness, - CredentialVerifierHarness, - CustomerStoreHarness, - SessionHarness, -} from "@otta-sh/domain/testing"; -import { CountingIdGen, FixedClock } from "@otta-sh/domain/testing"; -import type { Kysely } from "kysely"; -import { - KyselyAddressStore, - KyselyCredentialVerifier, - KyselyCustomerStore, - KyselySessionStore, - makeSqliteDb, - migrateToLatest, -} from "../src/index.js"; -import type { Database } from "../src/schema.js"; -import { createIsolatedPgSchema } from "../src/testing.js"; - -/** Short TTLs so the session/challenge expiry cases can cross them by advancing. */ -const SESSION_TTL_MS = 1000; -const CHALLENGE_TTL_MS = 1000; -/** The per-email active-challenge cap under test (review round H1). */ -const MAX_ACTIVE_CHALLENGES = 3; - -const cleanups: Array<() => Promise> = []; - -export async function teardownCustomers(): Promise { - const fns = cleanups.splice(0); - for (const fn of fns) await fn(); -} - -async function makeSqliteDbMigrated(): Promise> { - const db = makeSqliteDb(":memory:"); - await migrateToLatest(db); - cleanups.push(async () => { - await db.destroy(); - }); - return db; -} - -async function makePgDb(): Promise> { - const connectionString = process.env.PG_CONNECTION_STRING; - if (connectionString === undefined) throw new Error("PG_CONNECTION_STRING is not set"); - const iso = await createIsolatedPgSchema(connectionString, { poolMax: 4 }); - cleanups.push(() => iso.teardown()); - return iso.db; -} - -// -- CustomerStore ----------------------------------------------------------- - -function buildCustomerHarness(db: Kysely): CustomerStoreHarness { - const clock = new FixedClock(new Date("2026-07-10T00:00:00.000Z")); - return { store: new KyselyCustomerStore({ db, idGen: new CountingIdGen("cust"), clock }) }; -} - -export async function makeSqliteCustomerHarness(): Promise { - return buildCustomerHarness(await makeSqliteDbMigrated()); -} -export async function makePgCustomerHarness(): Promise { - return buildCustomerHarness(await makePgDb()); -} - -// -- AddressStore ------------------------------------------------------------ - -function buildAddressHarness(db: Kysely): AddressBookHarness { - const clock = new FixedClock(new Date("2026-07-10T00:00:00.000Z")); - return { store: new KyselyAddressStore({ db, idGen: new CountingIdGen("addr"), clock }) }; -} - -export async function makeSqliteAddressHarness(): Promise { - return buildAddressHarness(await makeSqliteDbMigrated()); -} -export async function makePgAddressHarness(): Promise { - return buildAddressHarness(await makePgDb()); -} - -// -- SessionStore ------------------------------------------------------------ - -function buildSessionHarness(db: Kysely): SessionHarness { - const clock = new FixedClock(new Date("2026-07-10T00:00:00.000Z")); - return { - store: new KyselySessionStore({ - db, - idGen: new CountingIdGen("sess"), - clock, - ttlMs: SESSION_TTL_MS, - }), - advance: (ms) => clock.advance(ms), - ttlMs: SESSION_TTL_MS, - }; -} - -export async function makeSqliteSessionHarness(): Promise { - return buildSessionHarness(await makeSqliteDbMigrated()); -} -export async function makePgSessionHarness(): Promise { - return buildSessionHarness(await makePgDb()); -} - -// -- CustomerCredentialVerifier ---------------------------------------------- - -function buildVerifierHarness(db: Kysely): CredentialVerifierHarness { - const clock = new FixedClock(new Date("2026-07-10T00:00:00.000Z")); - const customerStore = new KyselyCustomerStore({ db, idGen: new CountingIdGen("cust"), clock }); - const verifier = new KyselyCredentialVerifier({ - db, - customerStore, - idGen: new CountingIdGen("chal"), - clock, - ttlMs: CHALLENGE_TTL_MS, - maxActiveChallenges: MAX_ACTIVE_CHALLENGES, - }); - return { - verifier, - customerStore, - advance: (ms) => clock.advance(ms), - now: () => clock.now().toISOString(), - challengeTtlMs: CHALLENGE_TTL_MS, - maxActiveChallenges: MAX_ACTIVE_CHALLENGES, - }; -} - -export async function makeSqliteVerifierHarness(): Promise { - return buildVerifierHarness(await makeSqliteDbMigrated()); -} -export async function makePgVerifierHarness(): Promise { - return buildVerifierHarness(await makePgDb()); -} diff --git a/packages/store-postgres/test/customer-store-contract.dialects.test.ts b/packages/store-postgres/test/customer-store-contract.dialects.test.ts deleted file mode 100644 index e09e866c..00000000 --- a/packages/store-postgres/test/customer-store-contract.dialects.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { customerStoreContract } from "@otta-sh/domain/testing"; -import { afterEach, describe } from "vitest"; -import { PG_ENABLED } from "./describe-each-dialect.js"; -import { - makePgCustomerHarness, - makeSqliteCustomerHarness, - teardownCustomers, -} from "./customer-harness.js"; - -afterEach(teardownCustomers); - -customerStoreContract(makeSqliteCustomerHarness, { dialect: "sqlite" }); - -describe.skipIf(!PG_ENABLED)("[postgres]", () => { - customerStoreContract(makePgCustomerHarness, { dialect: "pg" }); -}); diff --git a/packages/store-postgres/test/describe-each-dialect.ts b/packages/store-postgres/test/describe-each-dialect.ts deleted file mode 100644 index b0b5ecd3..00000000 --- a/packages/store-postgres/test/describe-each-dialect.ts +++ /dev/null @@ -1,489 +0,0 @@ -import type { - CartStoreHarness, - CouponStoreHarness, - InventoryStoreHarness, - ProductCommerceStoreHarness, - ReportingStoreHarness, - SettingsStoreHarness, - ShippingRulesStoreHarness, - TaxRulesStoreHarness, -} from "@otta-sh/domain/testing"; -import { idempotencyKey } from "@otta-sh/domain"; -import { CountingIdGen, FixedClock } from "@otta-sh/domain/testing"; -import type { Kysely } from "kysely"; -import { - KyselyCartStore, - KyselyCouponStore, - KyselyInventoryStore, - KyselyProductCommerceStore, - KyselyReportingStore, - KyselySettingsStore, - KyselyShippingRulesStore, - KyselyTaxRulesStore, - makeSqliteDb, - migrateToLatest, - uuidIdGen, -} from "../src/index.js"; -import type { Database } from "../src/schema.js"; -import { createIsolatedPgSchema } from "../src/testing.js"; - -/** Postgres runs only when the connection string is present (§7 / DEVELOPMENT.md §2). */ -export const PG_ENABLED = Boolean(process.env.PG_CONNECTION_STRING); - -/** The dialect harness adds the W1 abandon-pending hook the contract case needs. */ -export interface DialectHarness extends InventoryStoreHarness { - abandonPending(sku: string, qty: number, key: string): Promise; -} - -// Resources created per test; torn down by `teardownDialects` (an afterEach). -const cleanups: Array<() => Promise> = []; - -export async function teardownDialects(): Promise { - const fns = cleanups.splice(0); - for (const fn of fns) await fn(); -} - -function buildHarness(db: Kysely): DialectHarness { - const idGen = new CountingIdGen("res"); - const clock = new FixedClock(new Date("2026-07-10T00:00:00.000Z")); - const store = new KyselyInventoryStore({ db, idGen, clock }); - return { - store, - async seed(sku, qty) { - await db - .insertInto("inventory") - .values({ sku, on_hand: qty }) - .onConflict((oc) => oc.column("sku").doUpdateSet({ on_hand: qty })) - .execute(); - }, - async onHand(sku) { - const row = await db - .selectFrom("inventory") - .select("on_hand") - .where("sku", "=", sku) - .executeTakeFirst(); - return row?.on_hand ?? 0; - }, - async abandonPending(sku, qty, key) { - // Crash window W1: a `pending` row with the finalize never run. - await db - .insertInto("reservations") - .values({ - id: idGen.newId(), - sku, - qty, - state: "pending", - idempotency_key: key, - created_at: clock.now().toISOString(), - }) - .execute(); - }, - async holdWithExpiry(sku, qty, key, expiresAt) { - // A held reservation with the cart's hold deadline stamped on it — the - // precondition adoptMany's `expires_at > :now` guard needs (a bare - // `reserve` leaves `expires_at` NULL). Mirrors the cart store's stamp. - const r = await store.reserve(sku, qty, idempotencyKey(key)); - if (!r.ok) throw new Error(`holdWithExpiry reserve failed for ${sku}`); - await db - .updateTable("reservations") - .set({ expires_at: expiresAt }) - .where("id", "=", r.reservationId) - .execute(); - return r.reservationId; - }, - }; -} - -/** Fresh, isolated in-memory SQLite db, migrated to latest. */ -export async function makeSqliteHarness(): Promise { - const db = makeSqliteDb(":memory:"); - await migrateToLatest(db); - cleanups.push(async () => { - await db.destroy(); - }); - return buildHarness(db); -} - -/** Fresh, isolated Postgres schema per test (§8 R7) via the shared helper. */ -export async function makePgHarness(): Promise { - const connectionString = process.env.PG_CONNECTION_STRING; - if (connectionString === undefined) throw new Error("PG_CONNECTION_STRING is not set"); - const iso = await createIsolatedPgSchema(connectionString, { poolMax: 4 }); - cleanups.push(() => iso.teardown()); - return buildHarness(iso.db); -} - -// -- Phase 1: ProductCommerceStore harness ---------------------------------- - -function buildProductCommerceHarness(db: Kysely): ProductCommerceStoreHarness { - const clock = new FixedClock(new Date("2026-07-10T00:00:00.000Z")); - return { - store: new KyselyProductCommerceStore({ db, clock }), - // A real `held` reservation row — step 0 of THE SKU-RENAME RULE reads - // `reservations` directly, so the contract's refusal cases need the - // genuine row rather than a flag (its `sku` FK onto `inventory` is why - // those cases seed the sku's stock first). - async seedHold(sku, qty) { - const seq = holdSeedSeq++; - await db - .insertInto("reservations") - .values({ - id: `hold-${sku}-${String(seq)}`, - sku, - qty, - state: "held", - idempotency_key: `hold-key-${sku}-${String(seq)}`, - created_at: "2026-07-10T00:00:00.000Z", - }) - .execute(); - }, - // Phase 2 (`listCommerceByIds`): seed the REAL inventory table the - // store's single-statement inStock join reads. - async seedStock(sku, qty) { - await db - .insertInto("inventory") - .values({ sku, on_hand: qty }) - .onConflict((oc) => oc.column("sku").doUpdateSet({ on_hand: qty })) - .execute(); - }, - // Admin-UX Increment 2: a direct `product_commerce` insert (mirrors - // `buildOrderStoreHarness.seedOrder`) so the admin-list contract can pin - // an EXACT `created_at` per row — the fake, sqlite, and pg then exercise - // the identical `listProducts` spec. - async seedProduct(row) { - await db - .insertInto("product_commerce") - .values({ - product_id: row.id, - sku: row.sku ?? null, - price_cents: row.priceCents ?? null, - price_currency: - row.priceCents !== undefined && row.priceCents !== null - ? (row.currency ?? "USD") - : null, - title: row.title ?? null, - tax_class: null, - inventory_policy: "deny", - weight_grams: null, - length_mm: null, - width_mm: null, - height_mm: null, - product_kind: row.productKind ?? "physical", - active: (row.active ?? false) ? 1 : 0, - deleted_at: row.deletedAt ?? null, - idempotency_key: `seed-${row.id}`, - content_updated_at: null, - active_updated_at: null, - created_at: row.createdAt, - updated_at: row.createdAt, - }) - .execute(); - }, - }; -} - -/** Monotonic id/idempotency-key source for `buildProductCommerceHarness. - * seedHold` — `reservations.idempotency_key` is UNIQUE, and one sku may carry - * several holds. */ -let holdSeedSeq = 0; - -/** Fresh, isolated in-memory SQLite db, migrated to latest. */ -export async function makeSqliteProductCommerceHarness(): Promise { - const db = makeSqliteDb(":memory:"); - await migrateToLatest(db); - cleanups.push(async () => { - await db.destroy(); - }); - return buildProductCommerceHarness(db); -} - -/** Fresh, isolated Postgres schema per test (§8 R7) via the shared helper. */ -export async function makePgProductCommerceHarness(): Promise { - const connectionString = process.env.PG_CONNECTION_STRING; - if (connectionString === undefined) throw new Error("PG_CONNECTION_STRING is not set"); - const iso = await createIsolatedPgSchema(connectionString, { poolMax: 4 }); - cleanups.push(() => iso.teardown()); - return buildProductCommerceHarness(iso.db); -} - -// -- cart harness ------------------------------------------------------------ - -export interface CartDialectHarness extends CartStoreHarness { - db: Kysely; - clock: FixedClock; -} - -function buildCartHarness(db: Kysely): CartDialectHarness { - const clock = new FixedClock(new Date("2026-07-10T00:00:00.000Z")); - const inventory = new KyselyInventoryStore({ db, idGen: uuidIdGen, clock }); - const cartStore = new KyselyCartStore({ db, idGen: uuidIdGen, clock }); - return { - deps: { cartStore, inventoryStore: inventory, clock }, - db, - clock, - async seedStock(sku, qty) { - await db - .insertInto("inventory") - .values({ sku, on_hand: qty }) - .onConflict((oc) => oc.column("sku").doUpdateSet({ on_hand: qty })) - .execute(); - }, - async onHand(sku) { - const row = await db - .selectFrom("inventory") - .select("on_hand") - .where("sku", "=", sku) - .executeTakeFirst(); - return row?.on_hand ?? 0; - }, - advance(ms) { - clock.advance(ms); - }, - }; -} - -export async function makeSqliteCartHarness(): Promise { - const db = makeSqliteDb(":memory:"); - await migrateToLatest(db); - cleanups.push(async () => { - await db.destroy(); - }); - return buildCartHarness(db); -} - -export async function makePgCartHarness(): Promise { - const connectionString = process.env.PG_CONNECTION_STRING; - if (connectionString === undefined) throw new Error("PG_CONNECTION_STRING is not set"); - const iso = await createIsolatedPgSchema(connectionString, { poolMax: 4 }); - cleanups.push(() => iso.teardown()); - return buildCartHarness(iso.db); -} - -// -- Phase 6: shipping / tax / coupon harnesses ------------------------------ - -async function makeSqliteDbMigrated(): Promise> { - const db = makeSqliteDb(":memory:"); - await migrateToLatest(db); - cleanups.push(async () => { - await db.destroy(); - }); - return db; -} - -async function makePgDbMigrated(poolMax = 4): Promise> { - const connectionString = process.env.PG_CONNECTION_STRING; - if (connectionString === undefined) throw new Error("PG_CONNECTION_STRING is not set"); - const iso = await createIsolatedPgSchema(connectionString, { poolMax }); - cleanups.push(() => iso.teardown()); - return iso.db; -} - -export async function makeSqliteShippingHarness(): Promise { - return { store: new KyselyShippingRulesStore({ db: await makeSqliteDbMigrated() }) }; -} -export async function makePgShippingHarness(): Promise { - return { store: new KyselyShippingRulesStore({ db: await makePgDbMigrated() }) }; -} - -export async function makeSqliteTaxHarness(): Promise { - return { store: new KyselyTaxRulesStore({ db: await makeSqliteDbMigrated() }) }; -} -export async function makePgTaxHarness(): Promise { - return { store: new KyselyTaxRulesStore({ db: await makePgDbMigrated() }) }; -} - -// -- Phase 6 (§6, admin-UX Increment 3): CouponStore harness ---------------- - -function buildCouponHarness(db: Kysely, idGen = uuidIdGen): CouponStoreHarness { - const clock = new FixedClock(new Date("2026-07-10T00:00:00.000Z")); - return { - store: new KyselyCouponStore({ db, idGen, clock }), - // Admin-UX Increment 3: a direct `coupons` insert (mirrors - // `buildProductCommerceHarness.seedProduct`) so the admin-list contract - // can pin an EXACT `created_at` per row — the fake, sqlite, and pg then - // exercise the identical `listCoupons` spec. - async seedCoupon(row) { - await db - .insertInto("coupons") - .values({ - id: row.id, - code: row.code, - type: row.type ?? "fixed_amount", - amount_cents: row.amountCents ?? null, - rate_bps: row.rateBps ?? null, - cap_cents: row.capCents ?? null, - currency: row.currency ?? null, - min_subtotal_cents: row.minSubtotalCents ?? null, - starts_at: row.startsAt ?? null, - expires_at: row.expiresAt ?? null, - max_uses: row.maxUses ?? null, - max_uses_per_customer: row.maxUsesPerCustomer ?? null, - uses_count: row.usesCount ?? 0, - created_at: row.createdAt, - }) - .execute(); - }, - }; -} - -export async function makeSqliteCouponHarness(): Promise { - return buildCouponHarness(await makeSqliteDbMigrated(), new CountingIdGen("red")); -} -export async function makePgCouponHarness(): Promise { - return buildCouponHarness(await makePgDbMigrated()); -} - -// -- Phase 7: reporting + settings harnesses --------------------------------- - -function buildReportingHarness( - db: Kysely, - dialect: "sqlite" | "postgres", -): ReportingStoreHarness { - return { - store: new KyselyReportingStore({ db, dialect }), - async seedOrder(row) { - await db - .insertInto("orders") - .values({ - id: row.id, - cart_id: null, - currency: row.currency, - state: row.state as Database["orders"]["state"], - idempotency_key: `seed-${row.id}`, - hold_expires_at: row.createdAt, - payment_method: null, - buyer_ref: "seed", - created_at: row.createdAt, - updated_at: row.createdAt, - }) - .execute(); - await db - .insertInto("order_totals") - .values({ - order_id: row.id, - currency: row.currency, - subtotal_cents: row.totalCents, - discount_cents: 0, - shipping_cents: 0, - tax_cents: 0, - total_cents: row.totalCents, - applied_coupon_code: null, - shipping_method_snapshot: null, - tax_breakdown: null, - }) - .execute(); - }, - async seedOrderItem(row) { - await db - .insertInto("order_items") - .values({ - id: `${row.orderId}-${row.productId}`, - order_id: row.orderId, - product_id: row.productId, - sku: `sku-${row.productId}`, - title: row.title, - unit_price_cents: row.unitPriceCents, - currency: "USD", - quantity: row.quantity, - fulfillment_kind: "physical", - reservation_id: null, - }) - .execute(); - }, - async seedInventory(row) { - await db - .insertInto("inventory") - .values({ sku: row.sku, on_hand: row.onHand }) - .onConflict((oc) => oc.column("sku").doUpdateSet({ on_hand: row.onHand })) - .execute(); - }, - // A real `refunds` ledger row. Its `created_at` is deliberately set to a - // date OUTSIDE every contract window: the bucket must come from the ORDER's - // `created_at`, so a report that accidentally grouped on the refund's own - // timestamp would return nothing here instead of quietly agreeing. - async seedRefund(row) { - const id = `seed-refund-${String(refundSeedSeq++)}`; - await db - .insertInto("refunds") - .values({ - id, - order_id: row.orderId, - amount_cents: row.amountCents, - currency: row.currency, - kind: "manual", - gateway: "stripe", - refund_ref: null, - reason: null, - refunded_by: "seed", - idempotency_key: id, - status: row.status ?? "recorded", - created_at: "2030-01-01T00:00:00.000Z", - }) - .execute(); - }, - // A real `product_commerce` row behind a sku, for `lowStock`'s title - // join. `product_id` comes from a counter, NOT the sku, precisely so the - // contract can seed a live row and a tombstone sharing ONE sku — legal, - // because live-sku uniqueness is a PARTIAL index (`WHERE deleted_at IS - // NULL`), and exactly the case the join's tombstone predicate survives. - async seedProduct(row) { - const id = `seed-prod-${String(productSeedSeq++)}`; - await db - .insertInto("product_commerce") - .values({ - product_id: id, - sku: row.sku, - price_cents: null, - price_currency: null, - title: row.title, - tax_class: null, - inventory_policy: "deny", - weight_grams: null, - length_mm: null, - width_mm: null, - height_mm: null, - product_kind: "physical", - active: 1, - deleted_at: row.deletedAt ?? null, - idempotency_key: id, - content_updated_at: null, - active_updated_at: null, - created_at: "2026-07-10T00:00:00.000Z", - updated_at: "2026-07-10T00:00:00.000Z", - }) - .execute(); - }, - }; -} - -/** Monotonic `product_id` source for `buildReportingHarness.seedProduct` — the - * sku cannot serve as the id, since several rows may legally share one sku. */ -let productSeedSeq = 0; - -/** Monotonic id/idempotency-key source for `buildReportingHarness.seedRefund` — - * `refunds.idempotency_key` is UNIQUE, and one order may carry several rows. */ -let refundSeedSeq = 0; - -export async function makeSqliteReportingHarness(): Promise { - return buildReportingHarness(await makeSqliteDbMigrated(), "sqlite"); -} -export async function makePgReportingHarness(): Promise { - return buildReportingHarness(await makePgDbMigrated(), "postgres"); -} - -export async function makeSqliteSettingsHarness(): Promise { - return { - store: new KyselySettingsStore({ - db: await makeSqliteDbMigrated(), - clock: new FixedClock(new Date("2026-07-10T00:00:00.000Z")), - }), - }; -} -export async function makePgSettingsHarness(): Promise { - return { - store: new KyselySettingsStore({ - db: await makePgDbMigrated(), - clock: new FixedClock(new Date("2026-07-10T00:00:00.000Z")), - }), - }; -} diff --git a/packages/store-postgres/test/entitlement-lookup-indices.test.ts b/packages/store-postgres/test/entitlement-lookup-indices.test.ts deleted file mode 100644 index d0e27831..00000000 --- a/packages/store-postgres/test/entitlement-lookup-indices.test.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { orderId as toOrderId, sku as toSku } from "@otta-sh/domain"; -import { FixedClock } from "@otta-sh/domain/testing"; -import BetterSqlite3 from "better-sqlite3"; -import { CompiledQuery, Kysely, PostgresDialect, SqliteDialect, sql } from "kysely"; -import { afterEach, describe, expect, test } from "vitest"; -import { - KyselyEntitlementStore, - makePostgresPool, - migrateToLatest, - uuidIdGen, -} from "../src/index.js"; -import type { Database } from "../src/schema.js"; -import { createIsolatedPgSchema } from "../src/testing.js"; - -// Pins the two `entitlements` lookup indices (0024_entitlement_lookup_indices) -// at the DDL level AND ties them to the predicate `KyselyEntitlementStore#check` -// actually compiles — never a hand-restated copy of it. `check` is the delivery -// gate (no active row ⇒ the file is not served), and its predicate is -// `state = ? AND sku = ? AND (order_id = ?)? AND (lower(buyer_ref) = ?)?`. The -// buyer half is served by a FUNCTIONAL index, which works for exactly the fold -// it was built for: if `check` is ever rewritten to `upper(buyer_ref)`, a -// different normalization, or a `LIKE`, the index silently stops being used. A -// test that EXPLAINed its own hand-written SQL literal would keep matching the -// unchanged index and stay green through that rewrite, so both halves below -// capture the ACTUAL compiled SQL the store sends (via `Kysely`'s `log` hook) -// and explain THAT. -// -// Node types, not plan text: EXPLAIN output wording varies across server -// versions, so the assertions are "the named index appears" + "no sequential -// scan node", never a byte-exact plan. - -const BUYER_INDEX = "idx_entitlements_buyer_ref_lower"; -const ORDER_INDEX = "idx_entitlements_order_id"; - -const PG = process.env.PG_CONNECTION_STRING; - -const SKU = "DIG-1"; -const TARGET_ORDER_ID = "ord_entitlement_target"; -const TARGET_BUYER_REF = "Mixed.Case.Buyer@Example.com"; - -interface EntitlementRow { - id: string; - order_id: string; - product_id: string | null; - sku: string; - buyer_ref: string; - state: string; - source: string; - granted_at: string; - grant_idempotency_key: string; -} - -const GRANTED_AT = "2026-08-01T00:00:00.000Z"; - -/** Noise + the one row both scopes resolve to, so a plan has rows to reason about. */ -function seedRows(): EntitlementRow[] { - const noise: EntitlementRow[] = Array.from({ length: 20 }, (_, i) => ({ - id: `ent_noise_${i}`, - order_id: `ord_noise_${i}`, - product_id: null, - sku: `NOISE-${i}`, - buyer_ref: `noise_${i}@example.com`, - state: "active", - source: "order_paid", - granted_at: GRANTED_AT, - grant_idempotency_key: `idem_noise_${i}`, - })); - return [ - ...noise, - { - id: "ent_target", - order_id: TARGET_ORDER_ID, - product_id: null, - sku: SKU, - buyer_ref: TARGET_BUYER_REF, - state: "active", - source: "order_paid", - granted_at: GRANTED_AT, - grant_idempotency_key: "idem_target", - }, - ]; -} - -function makeStore(db: Kysely): KyselyEntitlementStore { - return new KyselyEntitlementStore({ - db, - idGen: uuidIdGen, - clock: new FixedClock(new Date(GRANTED_AT)), - }); -} - -/** - * Runs the three real `check` shapes against `store`, handing each one's - * compiled statement to `explain`. Shared by both dialect halves so neither can - * drift onto a different predicate than the other. - */ -async function explainEachCheckShape( - store: KyselyEntitlementStore, - captured: CompiledQuery[], - explain: (query: CompiledQuery) => Promise, -): Promise<{ orderScope: string; buyerScope: string; bothScopes: string }> { - async function run(query: Parameters[0]): Promise { - captured.length = 0; - expect(await store.check(query)).toBe(true); - const compiled = captured[0]; - expect(compiled, "no query was captured — did `check` short-circuit?").toBeDefined(); - return explain(compiled as CompiledQuery); - } - - // (1) order scope — the storefront download capability (unguessable order id). - const orderScope = await run({ orderId: toOrderId(TARGET_ORDER_ID), sku: toSku(SKU) }); - expect(captured[0]?.sql).toContain("order_id"); - // (2) buyer scope — the session path; a lower-normalized session email must - // match the mixed-case checkout ref, so the compare side is folded. - // This fold assertion is load-bearing, not decoration: a functional - // index still gets NAMED in the plan of a differently-folded predicate, - // because its trailing `sku, state` columns remain usable while the - // rewritten expression degrades to a heap filter. "The named index - // appears and no `Seq Scan` does" therefore survives a switch to - // `upper()` on its own; this does not, and neither does the pg half's - // `Index Cond` assertion. - const buyerScope = await run({ buyerRef: TARGET_BUYER_REF.toUpperCase(), sku: toSku(SKU) }); - expect(captured[0]?.sql).toContain("lower(buyer_ref)"); - // (3) both — the operator-authenticated shape, which ANDs the two. - const bothScopes = await run({ - orderId: toOrderId(TARGET_ORDER_ID), - buyerRef: TARGET_BUYER_REF.toUpperCase(), - sku: toSku(SKU), - }); - - return { orderScope, buyerScope, bothScopes }; -} - -const cleanups: Array<() => Promise> = []; -afterEach(async () => { - for (const fn of cleanups.splice(0)) await fn(); -}); - -test("sqlite: both indices exist and serve the real compiled `check` predicates", async () => { - const database = new BetterSqlite3(":memory:"); - database.pragma("foreign_keys = ON"); - const captured: CompiledQuery[] = []; - const db = new Kysely({ - dialect: new SqliteDialect({ database }), - log: (event) => { - if (event.level === "query") captured.push(event.query); - }, - }); - cleanups.push(() => db.destroy()); - await migrateToLatest(db); - - // -- 1. the definitions ------------------------------------------------ - const rows = await sql<{ name: string; sql: string | null }>` - select name, sql from sqlite_master - where type = 'index' and name in (${BUYER_INDEX}, ${ORDER_INDEX}) - order by name - `.execute(db); - const defs = Object.fromEntries(rows.rows.map((r) => [r.name, r.sql ?? ""])); - - // Isolate the parenthesised column list before checking order — the index - // NAME also contains "buyer_ref"/"order_id", so a bare indexOf over the whole - // statement would happily accept a permuted column list. - function columnList(name: string): string { - const stmt = defs[name] ?? ""; - const list = /\(((?:[^()]|\([^()]*\))*)\)\s*$/.exec(stmt)?.[1] ?? ""; - expect(list, `no column list found for ${name}`).not.toBe(""); - return list; - } - - const buyerColumns = columnList(BUYER_INDEX); - expect(buyerColumns).toContain("lower(buyer_ref)"); - expect(buyerColumns.indexOf("lower(buyer_ref)")).toBeLessThan(buyerColumns.indexOf("sku")); - expect(buyerColumns.indexOf("sku")).toBeLessThan(buyerColumns.indexOf("state")); - - const orderColumns = columnList(ORDER_INDEX); - expect(orderColumns.indexOf("order_id")).toBeLessThan(orderColumns.indexOf("sku")); - expect(orderColumns.indexOf("sku")).toBeLessThan(orderColumns.indexOf("state")); - - await db.insertInto("entitlements").values(seedRows()).execute(); - await sql`analyze`.execute(db); - - // -- 2. the plans ------------------------------------------------------ - const store = makeStore(db); - const plans = await explainEachCheckShape(store, captured, async (query) => { - const explained = await db.executeQuery<{ detail: string }>( - CompiledQuery.raw(`explain query plan ${query.sql}`, [...query.parameters]), - ); - return explained.rows.map((r) => r.detail).join("\n"); - }); - - // SQLite's planner reports `SEARCH … USING INDEX ` for an index-served - // predicate and `SCAN ` for a full table scan. - expect(plans.orderScope).toContain(ORDER_INDEX); - expect(plans.orderScope).not.toContain("SCAN entitlements"); - expect(plans.buyerScope).toContain(BUYER_INDEX); - expect(plans.buyerScope).not.toContain("SCAN entitlements"); - expect(plans.bothScopes).toMatch(new RegExp(`${BUYER_INDEX}|${ORDER_INDEX}`)); - expect(plans.bothScopes).not.toContain("SCAN entitlements"); -}); - -describe.skipIf(PG === undefined)("postgres: entitlement lookup indices [pg]", () => { - test("both indices exist with the expected definitions, and the REAL compiled predicates use them", async () => { - if (PG === undefined) throw new Error("PG_CONNECTION_STRING is not set"); - const iso = await createIsolatedPgSchema(PG, { poolMax: 1 }); - cleanups.push(() => iso.teardown()); - - // -- 1. pin the definitions ----------------------------------------- - const idxRows = await sql<{ indexname: string; indexdef: string }>` - select indexname, indexdef from pg_indexes - where schemaname = ${iso.schema} and tablename = 'entitlements' - and indexname in (${BUYER_INDEX}, ${ORDER_INDEX}) - order by indexname - `.execute(iso.db); - const defs = Object.fromEntries(idxRows.rows.map((r) => [r.indexname, r.indexdef])); - - expect(defs[BUYER_INDEX]).toBe( - `CREATE INDEX ${BUYER_INDEX} ON ${iso.schema}.entitlements USING btree (lower(buyer_ref), sku, state)`, - ); - expect(defs[ORDER_INDEX]).toBe( - `CREATE INDEX ${ORDER_INDEX} ON ${iso.schema}.entitlements USING btree (order_id, sku, state)`, - ); - - // -- 2. a separate, logged, single-connection pool on the same schema. - // `max: 1` means the seeds, the real store calls and the EXPLAINs all - // share one physical connection, so a session-level `enable_seqscan = - // off` (the cheap, low-row-count way to force the index path without - // seeding thousands of rows) holds for all of them without a - // transaction wrapper — which `KyselyEntitlementStore` cannot be - // constructed over anyway (it is typed `Kysely`). - const captured: CompiledQuery[] = []; - const pool = makePostgresPool({ - connectionString: PG, - max: 1, - options: `-c search_path=${iso.schema}`, - }); - const loggedDb = new Kysely({ - dialect: new PostgresDialect({ pool }), - log: (event) => { - if (event.level === "query") captured.push(event.query); - }, - }); - cleanups.push(() => loggedDb.destroy()); - - await sql`set enable_seqscan = off`.execute(loggedDb); - await loggedDb.insertInto("entitlements").values(seedRows()).execute(); - await sql`analyze entitlements`.execute(loggedDb); - - const store = makeStore(loggedDb); - const plans = await explainEachCheckShape(store, captured, async (query) => { - const result = await pool.query(`explain ${query.sql}`, [...query.parameters]); - return (result.rows as Array<{ "QUERY PLAN": string }>) - .map((r) => r["QUERY PLAN"]) - .join("\n"); - }); - - expect(plans.orderScope).toContain(ORDER_INDEX); - expect(plans.orderScope).not.toContain("Seq Scan"); - expect(plans.buyerScope).toContain(BUYER_INDEX); - expect(plans.buyerScope).not.toContain("Seq Scan"); - // Naming the index is not enough on the buyer path: a predicate that no - // longer matches the index EXPRESSION can still scan this index for its - // trailing `sku, state` columns and recheck the expression on the heap — - // same index name, same absence of a `Seq Scan` node, whole point lost. - // `Index Cond` is where the planner records what it resolved INSIDE the - // index, so requiring the fold to appear there is the node-level form of - // "the functional term is doing the work". Matched loosely (a substring - // of the cond line) so it survives EXPLAIN's wording differences across - // server versions. - expect(plans.buyerScope).toMatch(/Index Cond:[^\n]*lower\(buyer_ref\)/); - // Both scopes together: either index resolves it, the other axis is a - // filter. The invariant is only that neither axis falls back to a scan. - expect(plans.bothScopes).toMatch(new RegExp(`${BUYER_INDEX}|${ORDER_INDEX}`)); - expect(plans.bothScopes).not.toContain("Seq Scan"); - }, 30_000); -}); diff --git a/packages/store-postgres/test/entitlement-store-contract.dialects.test.ts b/packages/store-postgres/test/entitlement-store-contract.dialects.test.ts deleted file mode 100644 index d02388a8..00000000 --- a/packages/store-postgres/test/entitlement-store-contract.dialects.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { entitlementStoreContract } from "@otta-sh/domain/testing"; -import { afterEach, describe } from "vitest"; -import { PG_ENABLED } from "./describe-each-dialect.js"; -import { - makePgEntitlementHarness, - makeSqliteEntitlementHarness, - teardownOrderFlow, -} from "./order-harness.js"; - -afterEach(teardownOrderFlow); - -entitlementStoreContract(makeSqliteEntitlementHarness, { dialect: "sqlite" }); - -describe.skipIf(!PG_ENABLED)("[postgres]", () => { - entitlementStoreContract(makePgEntitlementHarness, { dialect: "pg" }); -}); diff --git a/packages/store-postgres/test/hold-expiry.dialects.test.ts b/packages/store-postgres/test/hold-expiry.dialects.test.ts deleted file mode 100644 index 30b7f3c6..00000000 --- a/packages/store-postgres/test/hold-expiry.dialects.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { - addLine, - createCart, - currency, - expireHolds, - getCart, - idempotencyKey, - sku, - updateLine, -} from "@otta-sh/domain"; -import { afterEach, describe, expect, test } from "vitest"; -import { - type CartDialectHarness, - makePgCartHarness, - makeSqliteCartHarness, - PG_ENABLED, - teardownDialects, -} from "./describe-each-dialect.js"; - -// C3 — hold expiry against real DBs via an injected Clock: released, stock -// returns, no double-release under a simulated lazy+sweep race. -afterEach(teardownDialects); - -const USD = currency("USD"); -const PAST_TTL_MS = 16 * 60 * 1000; - -function runHoldExpiry(make: () => Promise, dialect: string): void { - describe(`hold expiry [${dialect}]`, () => { - test("an expired hold is released, its stock returns, and the reservation is 'released'", async () => { - const h = await make(); - await h.seedStock("SKU-1", 5); - const cartId = await createCart(h.deps, USD); - const add = await addLine(h.deps, cartId, sku("SKU-1"), null, 2, idempotencyKey("k1")); - if (!add.ok) throw new Error("add must succeed"); - const reservationId = add.line.reservationId ?? ""; - expect(await h.onHand("SKU-1")).toBe(3); - - h.advance(PAST_TTL_MS); - expect(await expireHolds(h.deps)).toBe(1); - expect(await h.onHand("SKU-1")).toBe(5); - - const res = await h.db - .selectFrom("reservations") - .select("state") - .where("id", "=", reservationId) - .executeTakeFirst(); - expect(res?.state).toBe("released"); - expect((await getCart(h.deps, cartId))?.lines).toHaveLength(0); - }); - - test("a lazy read racing the sweep returns stock exactly once", async () => { - const h = await make(); - await h.seedStock("SKU-1", 5); - const cartId = await createCart(h.deps, USD); - const add = await addLine(h.deps, cartId, sku("SKU-1"), null, 2, idempotencyKey("k1")); - if (!add.ok) throw new Error("add must succeed"); - - h.advance(PAST_TTL_MS); - const lazy = await getCart(h.deps, cartId); // lazy-on-read reclaims - const swept = await expireHolds(h.deps); // sweep sees nothing left - expect(lazy?.lines).toHaveLength(0); - expect(swept).toBe(0); - expect(await h.onHand("SKU-1")).toBe(5); // returned once, not 7 - }); - - test("expiry flip re-checks expires_at: a hold TTL-reset between listing and release is not reaped", async () => { - const h = await make(); - await h.seedStock("SKU-1", 5); - const cartId = await createCart(h.deps, USD); - const add = await addLine(h.deps, cartId, sku("SKU-1"), null, 2, idempotencyKey("k1")); - if (!add.ok) throw new Error("add must succeed"); - const reservationId = add.line.reservationId ?? ""; - - // Script the sweep's list→release window by hand: list at a `now` where - // the hold looks expired… - h.advance(PAST_TTL_MS); - const staleNow = h.clock.now().toISOString(); - const listed = await h.deps.cartStore.listExpired(staleNow, staleNow); - expect(listed).toEqual([{ reservationId }]); - - // …then an active shopper's mutation resets the hold before the release - // lands. The guarded flip re-checks the deadline in the same conditional - // statement: 0 rows, NOT reaped, no stock moved. - const up = await updateLine(h.deps, cartId, add.line.lineId, 3, idempotencyKey("k2")); - if (!up.ok) throw new Error("adjust must succeed"); - const won = await h.deps.cartStore.expireHold(reservationId, staleNow, staleNow); - expect(won).toBe(false); - expect(await h.onHand("SKU-1")).toBe(2); // 5 − 3: the hold is intact - const res = await h.db - .selectFrom("reservations") - .select("state") - .where("id", "=", reservationId) - .executeTakeFirst(); - expect(res?.state).toBe("held"); - expect((await getCart(h.deps, cartId))?.lines).toHaveLength(1); - }); - - test("a raw non-cart hold older than the TTL is not reaped by the cart sweep", async () => { - const h = await make(); - await h.seedStock("SKU-1", 5); - // A direct Phase-0 reserve: held, never stamped with expires_at, no - // cart_mutations claim — an admin/API hold awaiting explicit - // commit/release. The sweep's NULL-expires fallback is scoped to - // cart-originated keys and must leave it alone forever. - const raw = await h.deps.inventoryStore.reserve("SKU-1", 2, idempotencyKey("raw-1")); - if (!raw.ok) throw new Error("raw reserve must succeed"); - expect(await h.onHand("SKU-1")).toBe(3); - - h.advance(PAST_TTL_MS * 10); - expect(await expireHolds(h.deps)).toBe(0); - expect(await h.onHand("SKU-1")).toBe(3); // still held - const res = await h.db - .selectFrom("reservations") - .select("state") - .where("id", "=", raw.reservationId) - .executeTakeFirst(); - expect(res?.state).toBe("held"); - }); - }); -} - -runHoldExpiry(makeSqliteCartHarness, "sqlite"); -describe.skipIf(!PG_ENABLED)("[postgres]", () => { - runHoldExpiry(makePgCartHarness, "pg"); -}); diff --git a/packages/store-postgres/test/inventory-store-contract.dialects.test.ts b/packages/store-postgres/test/inventory-store-contract.dialects.test.ts deleted file mode 100644 index 60ba6254..00000000 --- a/packages/store-postgres/test/inventory-store-contract.dialects.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { inventoryStoreContract } from "@otta-sh/domain/testing"; -import { afterEach, describe } from "vitest"; -import { - makePgHarness, - makeSqliteHarness, - PG_ENABLED, - teardownDialects, -} from "./describe-each-dialect.js"; - -// The SAME reusable contract suite (§0.3) runs against every DB dialect (§0.4): -// SQLite always, Postgres only when PG_CONNECTION_STRING is set. -afterEach(teardownDialects); - -inventoryStoreContract(makeSqliteHarness, { dialect: "sqlite" }); - -describe.skipIf(!PG_ENABLED)("[postgres]", () => { - inventoryStoreContract(makePgHarness, { dialect: "pg" }); -}); diff --git a/packages/store-postgres/test/migration-gap.test.ts b/packages/store-postgres/test/migration-gap.test.ts deleted file mode 100644 index a16f28c7..00000000 --- a/packages/store-postgres/test/migration-gap.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -import BetterSqlite3 from "better-sqlite3"; -import { Kysely, SqliteDialect, sql } from "kysely"; -import { type Migration, Migrator } from "kysely/migration"; -import { expect, test } from "vitest"; -import { makeSqliteDb, migrateToLatest, migrationProvider } from "../src/index.js"; - -// N2 — Phase 3 was developed in parallel with Phase 1 under a numbering -// contract (0002 reserved for product_commerce, 0003 for cart), relying on the -// Migrator running pending migrations in NAME ORDER without requiring -// contiguity. Post-merge the real list is contiguous again, but the tolerance -// property the parallel workflow depends on stays pinned here with a synthetic -// gapped provider — if a Kysely upgrade ever starts rejecting gaps, the next -// parallel-phase pair must know before they branch. - -test("the real provider lists 0001…0024 in order and migrates cleanly", async () => { - const provided = Object.keys(await migrationProvider.getMigrations()); - expect(provided).toEqual([ - "0001_phase0_inventory", - "0002_product_commerce", - "0003_cart", - "0004_product_commerce_active_updated_at", - "0005_orders", - "0006_customers_sessions_outbox", - "0007_shipping_tax_coupons", - "0008_settings_and_reporting_indices", - "0009_orders_admin_list_indices", - "0010_order_notes", - "0011_reconciliation_resolution", - "0012_order_fulfillment", - "0013_order_cancellation", - "0014_order_events", - "0015_product_commerce_admin_list_indices", - "0016_inventory_stock_movements", - "0017_product_commerce_data_model_adds", - "0018_coupons_admin_list", - "0019_order_shipping_address", - "0020_refunds", - "0021_cart_order_id", - "0022_order_lookup_indices", - "0023_product_variants", - "0024_entitlement_lookup_indices", - ]); - - const db = makeSqliteDb(":memory:"); - try { - await migrateToLatest(db); - const ran = await sql<{ - name: string; - }>`SELECT name FROM kysely_migration ORDER BY name`.execute(db); - expect(ran.rows.map((r) => r.name)).toEqual([ - "0001_phase0_inventory", - "0002_product_commerce", - "0003_cart", - "0004_product_commerce_active_updated_at", - "0005_orders", - "0006_customers_sessions_outbox", - "0007_shipping_tax_coupons", - "0008_settings_and_reporting_indices", - "0009_orders_admin_list_indices", - "0010_order_notes", - "0011_reconciliation_resolution", - "0012_order_fulfillment", - "0013_order_cancellation", - "0014_order_events", - "0015_product_commerce_admin_list_indices", - "0016_inventory_stock_movements", - "0017_product_commerce_data_model_adds", - "0018_coupons_admin_list", - "0019_order_shipping_address", - "0020_refunds", - "0021_cart_order_id", - "0022_order_lookup_indices", - "0023_product_variants", - "0024_entitlement_lookup_indices", - ]); - - // And the 0003 tables exist and accept rows (spot check the ledger). - await db - .insertInto("cart_mutations") - .values({ - idempotency_key: "k1", - cart_id: "cart-1", - line_id: null, - kind: "add", - resulting_qty: null, - completed: 0, - created_at: "2026-07-10T00:00:00.000Z", - }) - .execute(); - const row = await db - .selectFrom("cart_mutations") - .select("completed") - .where("idempotency_key", "=", "k1") - .executeTakeFirst(); - expect(row?.completed).toBe(0); - } finally { - await db.destroy(); - } -}); - -test("the Migrator tolerates a numbering gap in an ordered migration list", async () => { - // Synthetic gapped list {0001, 0003}: the shape both parallel phases relied - // on while 0002 lived in a sibling worktree. - const gapped: Record = { - "0001_first": { - async up(db: Kysely): Promise { - await db.schema - .createTable("gap_a") - .addColumn("id", "text", (col) => col.primaryKey()) - .execute(); - }, - }, - "0003_third": { - async up(db: Kysely): Promise { - await db.schema - .createTable("gap_b") - .addColumn("id", "text", (col) => col.primaryKey()) - .execute(); - }, - }, - }; - - const db = new Kysely>>({ - dialect: new SqliteDialect({ database: new BetterSqlite3(":memory:") }), - }); - try { - const migrator = new Migrator({ - db, - provider: { getMigrations: () => Promise.resolve(gapped) }, - }); - const { error, results } = await migrator.migrateToLatest(); - expect(error).toBeUndefined(); - expect( - results?.map( - (r: { migrationName: string; status: string }) => `${r.migrationName}:${r.status}`, - ), - ).toEqual(["0001_first:Success", "0003_third:Success"]); - } finally { - await db.destroy(); - } -}); diff --git a/packages/store-postgres/test/no-oversell-cart.pg.test.ts b/packages/store-postgres/test/no-oversell-cart.pg.test.ts deleted file mode 100644 index 0c03948a..00000000 --- a/packages/store-postgres/test/no-oversell-cart.pg.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { addLine, type CartDeps, createCart, currency, idempotencyKey, sku } from "@otta-sh/domain"; -import { FixedClock } from "@otta-sh/domain/testing"; -import type { Kysely } from "kysely"; -import { afterEach, describe, expect, test } from "vitest"; -import { KyselyCartStore, KyselyInventoryStore, uuidIdGen } from "../src/index.js"; -import type { Database } from "../src/schema.js"; -import { createIsolatedPgSchema } from "../src/testing.js"; - -// F1 — THE Phase-3 acceptance gate (Postgres-required, skipped without -// PG_CONNECTION_STRING). N concurrent add-to-cart requests across stock M (N>M), -// each on an INDEPENDENT connection, must never oversell: exactly M carts get a -// line, N−M get OUT_OF_STOCK, and final on_hand == 0. The guarantee must survive -// the cart layer, not just the raw reserve endpoint. Looped like Phase-0's gate. - -const PG = process.env.PG_CONNECTION_STRING; -const USD = currency("USD"); - -interface CartFixture { - deps: CartDeps; - db: Kysely; - seed(sku: string, qty: number): Promise; - onHand(sku: string): Promise; - reset(): Promise; -} - -const cleanups: Array<() => Promise> = []; -afterEach(async () => { - for (const fn of cleanups.splice(0)) await fn(); -}); - -async function freshCartFixture(poolMax: number): Promise { - if (PG === undefined) throw new Error("PG_CONNECTION_STRING is not set"); - const iso = await createIsolatedPgSchema(PG, { poolMax }); - cleanups.push(() => iso.teardown()); - const db = iso.db; - const clock = new FixedClock(new Date("2026-07-10T00:00:00.000Z")); - const cartStore = new KyselyCartStore({ db, idGen: uuidIdGen, clock }); - const inventoryStore = new KyselyInventoryStore({ db, idGen: uuidIdGen, clock }); - return { - deps: { cartStore, inventoryStore, clock }, - db, - async seed(s, qty) { - await db - .insertInto("inventory") - .values({ sku: s, on_hand: qty }) - .onConflict((oc) => oc.column("sku").doUpdateSet({ on_hand: qty })) - .execute(); - }, - async onHand(s) { - const row = await db - .selectFrom("inventory") - .select("on_hand") - .where("sku", "=", s) - .executeTakeFirst(); - return row?.on_hand ?? 0; - }, - async reset() { - await db.deleteFrom("cart_mutations").execute(); - await db.deleteFrom("cart_lines").execute(); - await db.deleteFrom("carts").execute(); - await db.deleteFrom("reservations").execute(); - }, - }; -} - -describe.skipIf(PG === undefined)("no oversell through a cart [postgres]", () => { - test("N concurrent add-to-carts at stock M (M { - const M = 5; - const N = 50; - const LOOPS = 15; - const h = await freshCartFixture(N + 4); - - for (let loop = 0; loop < LOOPS; loop++) { - await h.reset(); - await h.seed("SKU-1", M); - - // Each request is its own cart; the concurrent adds race the same stock. - const cartIds = await Promise.all(Array.from({ length: N }, () => createCart(h.deps, USD))); - const results = await Promise.all( - cartIds.map((cartId, i) => - addLine(h.deps, cartId, sku("SKU-1"), null, 1, idempotencyKey(`k-${loop}-${i}`)), - ), - ); - - const ok = results.filter((r) => r.ok).length; - const oos = results.filter((r) => !r.ok && r.reason === "OUT_OF_STOCK").length; - expect(ok, `loop ${loop}: carts with a line`).toBe(M); - expect(oos, `loop ${loop}: OUT_OF_STOCK count`).toBe(N - M); - expect(await h.onHand("SKU-1"), `loop ${loop}: final on_hand`).toBe(0); - - const lineCount = await h.db - .selectFrom("cart_lines") - .select((eb) => eb.fn.countAll().as("n")) - .executeTakeFirstOrThrow(); - expect(Number(lineCount.n), `loop ${loop}: cart_lines written`).toBe(M); - } - }, 180_000); -}); diff --git a/packages/store-postgres/test/no-oversell-checkout-multiline.pg.test.ts b/packages/store-postgres/test/no-oversell-checkout-multiline.pg.test.ts deleted file mode 100644 index 38cbfb2f..00000000 --- a/packages/store-postgres/test/no-oversell-checkout-multiline.pg.test.ts +++ /dev/null @@ -1,263 +0,0 @@ -import { - addLine, - type CartDeps, - cents, - createCart, - type CreateOrderDeps, - createOrderFromCart, - currency, - idempotencyKey, - money, - type Order, - productId as brandProductId, - type SettleDeps, - settleOrder, - sku as brandSku, -} from "@otta-sh/domain"; -import { FakePaymentGateway, FixedClock } from "@otta-sh/domain/testing"; -import type { Kysely } from "kysely"; -import { afterEach, describe, expect, test } from "vitest"; -import { - KyselyCartStore, - KyselyCouponStore, - KyselyEntitlementStore, - KyselyInventoryStore, - KyselyOrderStore, - KyselyPaymentEventStore, - KyselyProductCommerceStore, - KyselyShippingRulesStore, - KyselyTaxRulesStore, - uuidIdGen, -} from "../src/index.js"; -import type { Database } from "../src/schema.js"; -import { createIsolatedPgSchema } from "../src/testing.js"; - -// THE PR-B acceptance gate (Postgres-required): the no-oversell guarantee extended -// to the BATCHED checkout ADOPT (adoptMany) + settle COMMIT (commitMany) with N>1 -// ids per order. The sibling single-line gate (no-oversell-checkout.pg.test.ts) -// drives qty-1 single-line carts, so it never exercises the batch. Here each cart -// races MULTIPLE distinct-sku physical lines: a cart can win one sku and lose -// another and thus never fully check out, so `committed == M×lines` is NOT a valid -// assertion. Instead we compute the "full winners" (carts that won ALL their lines) -// and assert `committed == fullWinners × linesPerOrder`, each sku's on_hand == 0, -// and that no paid order half-commits (every paid order committed exactly its lines). - -const PG = process.env.PG_CONNECTION_STRING; -const USD = currency("USD"); - -// linesPerOrder distinct skus; each cart adds one physical line per sku. -const SKUS = ["SKU-A", "SKU-B", "SKU-C"] as const; -const PIDS = ["pA", "pB", "pC"] as const; -const LINES = SKUS.length; - -const cleanups: Array<() => Promise> = []; -afterEach(async () => { - for (const fn of cleanups.splice(0)) await fn(); -}); - -interface Fixture { - db: Kysely; - cartDeps: CartDeps; - createDeps: CreateOrderDeps; - settleDeps: SettleDeps; - gateway: FakePaymentGateway; - seedInventory(qty: number): Promise; - seedProducts(): Promise; - onHand(sku: string): Promise; - reset(): Promise; -} - -async function freshFixture(poolMax: number): Promise { - if (PG === undefined) throw new Error("PG_CONNECTION_STRING is not set"); - const iso = await createIsolatedPgSchema(PG, { poolMax }); - cleanups.push(() => iso.teardown()); - const db = iso.db; - const clock = new FixedClock(new Date("2026-07-10T00:00:00.000Z")); - const inventory = new KyselyInventoryStore({ db, idGen: uuidIdGen, clock }); - const cartStore = new KyselyCartStore({ db, idGen: uuidIdGen, clock }); - const productCommerce = new KyselyProductCommerceStore({ db, clock }); - const orderStore = new KyselyOrderStore({ db, idGen: uuidIdGen, clock }); - const entitlementStore = new KyselyEntitlementStore({ db, idGen: uuidIdGen, clock }); - const paymentEventStore = new KyselyPaymentEventStore({ db, idGen: uuidIdGen }); - const gateway = new FakePaymentGateway({ id: "stripe" }); - - return { - db, - cartDeps: { cartStore, inventoryStore: inventory, clock }, - createDeps: { - orderStore, - cartStore, - inventoryStore: inventory, - productCommerce, - shippingRules: new KyselyShippingRulesStore({ db }), - taxRules: new KyselyTaxRulesStore({ db }), - couponStore: new KyselyCouponStore({ db, idGen: uuidIdGen, clock }), - clock, - idGen: uuidIdGen, - gateways: { stripe: gateway }, - }, - settleDeps: { - orderStore, - entitlementStore, - paymentEventStore, - inventoryStore: inventory, - couponStore: new KyselyCouponStore({ db, idGen: uuidIdGen, clock }), - clock, - }, - gateway, - async seedInventory(qty) { - for (const sku of SKUS) { - await db - .insertInto("inventory") - .values({ sku, on_hand: qty }) - .onConflict((oc) => oc.column("sku").doUpdateSet({ on_hand: qty })) - .execute(); - } - }, - async seedProducts() { - for (let i = 0; i < LINES; i++) { - await productCommerce.upsert( - { - productId: brandProductId(PIDS[i]!), - sku: brandSku(SKUS[i]!), - price: money(cents(100), USD), - title: `Widget ${SKUS[i]}`, - productKind: "physical", - }, - idempotencyKey(`seed-${PIDS[i]}`), - ); - } - }, - async onHand(sku) { - const row = await db - .selectFrom("inventory") - .select("on_hand") - .where("sku", "=", sku) - .executeTakeFirst(); - return row?.on_hand ?? 0; - }, - async reset() { - await db.deleteFrom("entitlements").execute(); - await db.deleteFrom("payments").execute(); - await db.deleteFrom("payment_events").execute(); - await db.deleteFrom("order_emails_outbox").execute(); - await db.deleteFrom("order_items").execute(); - await db.deleteFrom("order_totals").execute(); - await db.deleteFrom("orders").execute(); - await db.deleteFrom("cart_mutations").execute(); - await db.deleteFrom("cart_lines").execute(); - await db.deleteFrom("carts").execute(); - await db.deleteFrom("reservations").execute(); - }, - }; -} - -describe.skipIf(PG === undefined)("no oversell through MULTI-LINE checkout [postgres]", () => { - test("racing multi-line carts: adoptMany/commitMany with N>1 ids never oversell or half-commit", async () => { - const M = 8; // per-sku stock - const N = 10; // racing carts - const LOOPS = 6; - const h = await freshFixture(N * LINES + 4); - await h.seedProducts(); - - for (let loop = 0; loop < LOOPS; loop++) { - await h.reset(); - await h.seedInventory(M); - - // N carts, each racing to reserve one physical line per sku (LINES lines). - // Within a cart the adds are sequential; across carts they race — so a - // cart can win some skus and lose others (Phase-0/3 no-oversell at reserve). - const carts = await Promise.all( - Array.from({ length: N }, async (_unused, i) => { - const cartId = await createCart(h.cartDeps, USD); - const results = await Promise.all( - SKUS.map((sku, j) => - addLine( - h.cartDeps, - cartId, - brandSku(sku), - PIDS[j]!, - 1, - idempotencyKey(`add-${loop}-${i}-${j}`), - "physical", - ), - ), - ); - return { cartId, i, wonAll: results.every((r) => r.ok) }; - }), - ); - - // Every sku is contended by all N > M carts, so each is fully drawn down. - for (const sku of SKUS) { - expect(await h.onHand(sku), `loop ${loop}: on_hand ${sku} after reserve`).toBe(0); - } - - // Only FULL winners (won every line) can check out completely. They race - // checkout (adoptMany, LINES ids) → pay → settle (commitMany, LINES ids). - const fullWinners = carts.filter((c) => c.wonAll); - expect(fullWinners.length, `loop ${loop}: full winners exist`).toBeGreaterThan(0); - - const orders = await Promise.all( - fullWinners.map(async ({ cartId, i }) => { - const created = await createOrderFromCart(h.createDeps, { - cartId, - idempotencyKey: idempotencyKey(`ord-${loop}-${i}`), - buyerRef: `b${i}@example.com`, - paymentMethod: "stripe", - }); - if (!created.ok) throw new Error(`checkout failed: ${created.reason}`); - return created.order; - }), - ); - await Promise.all( - orders.map((order: Order) => - settleOrder( - h.settleDeps, - h.gateway, - h.gateway.webhook({ - outcome: "succeeded", - orderId: order.id, - providerRef: `pi-${order.id}`, - amount: order.totals.total, - currency: "USD", - dedupeKey: `evt-${order.id}`, - }), - ), - ), - ); - - // Exactly the full winners are paid; each committed exactly LINES holds. - const paid = await h.db - .selectFrom("orders") - .select((eb) => eb.fn.countAll().as("n")) - .where("state", "=", "paid") - .executeTakeFirstOrThrow(); - expect(Number(paid.n), `loop ${loop}: paid orders`).toBe(fullWinners.length); - - const committed = await h.db - .selectFrom("reservations") - .select((eb) => eb.fn.countAll().as("n")) - .where("state", "=", "committed") - .executeTakeFirstOrThrow(); - expect(Number(committed.n), `loop ${loop}: committed reservations`).toBe( - fullWinners.length * LINES, - ); - - // No half-commit: every paid order committed EXACTLY its LINES reservations. - for (const order of orders) { - const perOrder = await h.db - .selectFrom("reservations") - .select((eb) => eb.fn.countAll().as("n")) - .where("order_id", "=", order.id) - .where("state", "=", "committed") - .executeTakeFirstOrThrow(); - expect(Number(perOrder.n), `loop ${loop}: order ${order.id} committed lines`).toBe(LINES); - } - - // Committed stock stays gone (never resold): each sku still at 0. - for (const sku of SKUS) { - expect(await h.onHand(sku), `loop ${loop}: final on_hand ${sku}`).toBe(0); - } - } - }, 180_000); -}); diff --git a/packages/store-postgres/test/no-oversell-checkout.pg.test.ts b/packages/store-postgres/test/no-oversell-checkout.pg.test.ts deleted file mode 100644 index 9d347c20..00000000 --- a/packages/store-postgres/test/no-oversell-checkout.pg.test.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { - addLine, - type CartDeps, - cents, - createCart, - type CreateOrderDeps, - createOrderFromCart, - currency, - idempotencyKey, - money, - type Order, - productId as brandProductId, - type SettleDeps, - settleOrder, - sku as brandSku, -} from "@otta-sh/domain"; -import { FakePaymentGateway, FixedClock } from "@otta-sh/domain/testing"; -import type { Kysely } from "kysely"; -import { afterEach, describe, expect, test } from "vitest"; -import { - KyselyCartStore, - KyselyCouponStore, - KyselyEntitlementStore, - KyselyInventoryStore, - KyselyOrderStore, - KyselyPaymentEventStore, - KyselyProductCommerceStore, - KyselyShippingRulesStore, - KyselyTaxRulesStore, - uuidIdGen, -} from "../src/index.js"; -import type { Database } from "../src/schema.js"; -import { createIsolatedPgSchema } from "../src/testing.js"; - -// THE Phase-4 acceptance gate (Postgres-required): N buyers race to buy the last -// M units. Only M reservations can be held (Phase-0/3 no-oversell), and the -// guarantee is extended across CHECKOUT + COMMIT — exactly M orders reach -// paid+commit, the losers never got a reservation, and final on_hand == 0 -// (committed stock stays gone, never resold). Looped like the other gates. - -const PG = process.env.PG_CONNECTION_STRING; -const USD = currency("USD"); - -const cleanups: Array<() => Promise> = []; -afterEach(async () => { - for (const fn of cleanups.splice(0)) await fn(); -}); - -interface Fixture { - db: Kysely; - cartDeps: CartDeps; - createDeps: CreateOrderDeps; - settleDeps: SettleDeps; - gateway: FakePaymentGateway; - seedInventory(qty: number): Promise; - seedProduct(): Promise; - onHand(): Promise; - reset(): Promise; -} - -async function freshFixture(poolMax: number): Promise { - if (PG === undefined) throw new Error("PG_CONNECTION_STRING is not set"); - const iso = await createIsolatedPgSchema(PG, { poolMax }); - cleanups.push(() => iso.teardown()); - const db = iso.db; - const clock = new FixedClock(new Date("2026-07-10T00:00:00.000Z")); - const inventory = new KyselyInventoryStore({ db, idGen: uuidIdGen, clock }); - const cartStore = new KyselyCartStore({ db, idGen: uuidIdGen, clock }); - const productCommerce = new KyselyProductCommerceStore({ db, clock }); - const orderStore = new KyselyOrderStore({ db, idGen: uuidIdGen, clock }); - const entitlementStore = new KyselyEntitlementStore({ db, idGen: uuidIdGen, clock }); - const paymentEventStore = new KyselyPaymentEventStore({ db, idGen: uuidIdGen }); - const gateway = new FakePaymentGateway({ id: "stripe" }); - - return { - db, - cartDeps: { cartStore, inventoryStore: inventory, clock }, - createDeps: { - orderStore, - cartStore, - inventoryStore: inventory, - productCommerce, - shippingRules: new KyselyShippingRulesStore({ db }), - taxRules: new KyselyTaxRulesStore({ db }), - couponStore: new KyselyCouponStore({ db, idGen: uuidIdGen, clock }), - clock, - idGen: uuidIdGen, - gateways: { stripe: gateway }, - }, - settleDeps: { - orderStore, - entitlementStore, - paymentEventStore, - inventoryStore: inventory, - couponStore: new KyselyCouponStore({ db, idGen: uuidIdGen, clock }), - clock, - }, - gateway, - async seedInventory(qty) { - await db - .insertInto("inventory") - .values({ sku: "SKU-1", on_hand: qty }) - .onConflict((oc) => oc.column("sku").doUpdateSet({ on_hand: qty })) - .execute(); - }, - async seedProduct() { - await productCommerce.upsert( - { - productId: brandProductId("p1"), - sku: brandSku("SKU-1"), - price: money(cents(100), USD), - title: "Widget", - productKind: "physical", - }, - idempotencyKey("seed-p1"), - ); - }, - async onHand() { - const row = await db - .selectFrom("inventory") - .select("on_hand") - .where("sku", "=", "SKU-1") - .executeTakeFirst(); - return row?.on_hand ?? 0; - }, - async reset() { - await db.deleteFrom("entitlements").execute(); - await db.deleteFrom("payments").execute(); - await db.deleteFrom("payment_events").execute(); - // Phase 5: outbox rows FK-reference orders; delete children first. - await db.deleteFrom("order_emails_outbox").execute(); - await db.deleteFrom("order_items").execute(); - await db.deleteFrom("order_totals").execute(); - await db.deleteFrom("orders").execute(); - await db.deleteFrom("cart_mutations").execute(); - await db.deleteFrom("cart_lines").execute(); - await db.deleteFrom("carts").execute(); - await db.deleteFrom("reservations").execute(); - }, - }; -} - -describe.skipIf(PG === undefined)("no oversell through checkout [postgres]", () => { - test("concurrent checkout of the last units: exactly M orders reach paid+commit", async () => { - const M = 5; - const N = 40; - const LOOPS = 8; - const h = await freshFixture(N + 4); - await h.seedProduct(); - - for (let loop = 0; loop < LOOPS; loop++) { - await h.reset(); - await h.seedInventory(M); - - // N buyers, each their own cart, race the same M units at add-to-cart. - const cartIds = await Promise.all( - Array.from({ length: N }, () => createCart(h.cartDeps, USD)), - ); - const added = await Promise.all( - cartIds.map((cartId, i) => - addLine( - h.cartDeps, - cartId, - brandSku("SKU-1"), - "p1", - 1, - idempotencyKey(`add-${loop}-${i}`), - "physical", - ).then((r) => ({ cartId, r })), - ), - ); - const winners = added.filter((x) => x.r.ok); - expect(winners).toHaveLength(M); // Phase-0/3 no-oversell at reserve - - // The winners concurrently check out → pay → commit. - const orders = await Promise.all( - winners.map(async ({ cartId }, i) => { - const created = await createOrderFromCart(h.createDeps, { - cartId, - idempotencyKey: idempotencyKey(`ord-${loop}-${i}`), - buyerRef: `b${i}@example.com`, - paymentMethod: "stripe", - }); - if (!created.ok) throw new Error(`checkout failed: ${created.reason}`); - return created.order; - }), - ); - await Promise.all( - orders.map((order: Order) => - settleOrder( - h.settleDeps, - h.gateway, - h.gateway.webhook({ - outcome: "succeeded", - orderId: order.id, - providerRef: `pi-${order.id}`, - amount: order.totals.total, - currency: "USD", - dedupeKey: `evt-${order.id}`, - }), - ), - ), - ); - - const paid = await h.db - .selectFrom("orders") - .select((eb) => eb.fn.countAll().as("n")) - .where("state", "=", "paid") - .executeTakeFirstOrThrow(); - const committed = await h.db - .selectFrom("reservations") - .select((eb) => eb.fn.countAll().as("n")) - .where("state", "=", "committed") - .executeTakeFirstOrThrow(); - expect(Number(paid.n), `loop ${loop}: paid orders`).toBe(M); - expect(Number(committed.n), `loop ${loop}: committed reservations`).toBe(M); - expect(await h.onHand(), `loop ${loop}: final on_hand`).toBe(0); - } - }, 180_000); -}); diff --git a/packages/store-postgres/test/no-oversell.pg.test.ts b/packages/store-postgres/test/no-oversell.pg.test.ts deleted file mode 100644 index b99611aa..00000000 --- a/packages/store-postgres/test/no-oversell.pg.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { idempotencyKey } from "@otta-sh/domain"; -import { FixedClock } from "@otta-sh/domain/testing"; -import type { Kysely } from "kysely"; -import { afterEach, describe, expect, test } from "vitest"; -import { KyselyInventoryStore, uuidIdGen } from "../src/index.js"; -import type { Database } from "../src/schema.js"; -import { createIsolatedPgSchema } from "../src/testing.js"; - -const PG = process.env.PG_CONNECTION_STRING; - -interface PgFixture { - store: KyselyInventoryStore; - db: Kysely; - seed(sku: string, qty: number): Promise; - onHand(sku: string): Promise; -} - -const cleanups: Array<() => Promise> = []; -afterEach(async () => { - for (const fn of cleanups.splice(0)) await fn(); -}); - -/** - * A schema-isolated pg store whose pool can hold `poolMax` connections — so N - * concurrent reserves each acquire an INDEPENDENT connection (a real race). - */ -async function freshPgStore(poolMax: number): Promise { - const connectionString = PG; - if (connectionString === undefined) throw new Error("PG_CONNECTION_STRING is not set"); - const iso = await createIsolatedPgSchema(connectionString, { poolMax }); - cleanups.push(() => iso.teardown()); - const db = iso.db; - - const store = new KyselyInventoryStore({ - db, - idGen: uuidIdGen, - clock: new FixedClock(new Date("2026-07-10T00:00:00.000Z")), - }); - return { - store, - db, - async seed(sku, qty) { - await db - .insertInto("inventory") - .values({ sku, on_hand: qty }) - .onConflict((oc) => oc.column("sku").doUpdateSet({ on_hand: qty })) - .execute(); - }, - async onHand(sku) { - const row = await db - .selectFrom("inventory") - .select("on_hand") - .where("sku", "=", sku) - .executeTakeFirst(); - return row?.on_hand ?? 0; - }, - }; -} - -describe.skipIf(PG === undefined)("no-oversell [postgres]", () => { - test("no oversell: N concurrent reserves at stock M (M { - const M = 5; - const N = 50; - const LOOPS = 20; - const h = await freshPgStore(N + 4); - - for (let loop = 0; loop < LOOPS; loop++) { - // Reset to a clean single-SKU stock of M for each independent race. - await h.db.deleteFrom("reservations").execute(); - await h.seed("SKU-1", M); - - const results = await Promise.all( - Array.from({ length: N }, (_unused, i) => - h.store.reserve("SKU-1", 1, idempotencyKey(`k-${loop}-${i}`)), - ), - ); - - const ok = results.filter((r) => r.ok).length; - const oos = results.filter((r) => !r.ok).length; - expect(ok, `loop ${loop}: ok count`).toBe(M); - expect(oos, `loop ${loop}: OUT_OF_STOCK count`).toBe(N - M); - expect(await h.onHand("SKU-1"), `loop ${loop}: final on_hand`).toBe(0); - } - }, 120_000); - - test("concurrent reserve calls sharing the same idempotency key never return ok before the reservation reaches a terminal state — Postgres", async () => { - const N = 20; - const h = await freshPgStore(N + 4); - await h.seed("SKU-1", 1); - const key = idempotencyKey("same-key"); - - const results = await Promise.all( - Array.from({ length: N }, () => h.store.reserve("SKU-1", 1, key)), - ); - - // The unique idempotency_key means exactly ONE reservation exists; every - // caller resolves to that same terminal outcome, decrementing once. - const first = results[0]; - if (first === undefined) throw new Error("no results"); - for (const r of results) expect(r).toEqual(first); - expect(first.ok).toBe(true); - expect(await h.onHand("SKU-1")).toBe(0); - - const rows = await h.db.selectFrom("reservations").selectAll().execute(); - expect(rows).toHaveLength(1); - expect(rows[0]?.state).toBe("held"); - }, 60_000); - - test("reserve finalize is all-or-nothing: a fault between the held flip and the decrement leaves 'pending' with on_hand unchanged (crash window W2)", async () => { - const h = await freshPgStore(4); - await h.seed("SKU-1", 5); - const key = idempotencyKey("w2"); - - // Inject a fault inside the finalize tx, after the `pending → held` flip - // and before the decrement. - h.store.hooks.beforeDecrement = () => { - throw new Error("injected W2 fault"); - }; - await expect(h.store.reserve("SKU-1", 2, key)).rejects.toThrow("injected W2 fault"); - - // The finalize tx rolled back atomically: no visible `held`, no partial - // decrement — the reservation is back to `pending`, on_hand unchanged. - const rows = await h.db - .selectFrom("reservations") - .selectAll() - .where("idempotency_key", "=", key) - .execute(); - expect(rows).toHaveLength(1); - expect(rows[0]?.state).toBe("pending"); - expect(await h.onHand("SKU-1")).toBe(5); - - // A subsequent same-key replay heals it (W1) to the correct terminal. - h.store.hooks.beforeDecrement = undefined; - const healed = await h.store.reserve("SKU-1", 2, key); - expect(healed.ok).toBe(true); - expect(await h.onHand("SKU-1")).toBe(3); - }, 60_000); -}); diff --git a/packages/store-postgres/test/order-cancellation-contract.dialects.test.ts b/packages/store-postgres/test/order-cancellation-contract.dialects.test.ts deleted file mode 100644 index d49b74c6..00000000 --- a/packages/store-postgres/test/order-cancellation-contract.dialects.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { - cancelOrder, - cents, - currency, - dispatchOrderEmails, - idempotencyKey, - orderId, - productId, - recordFulfillment, - reservationId, - sku, - transitionOrder, - type CreateOrderInput, - type OrderId, -} from "@otta-sh/domain"; -import { orderCancellationContract, type OrderTransitionHarness } from "@otta-sh/domain/testing"; -import { afterEach, describe, expect, test } from "vitest"; -import { PG_ENABLED } from "./describe-each-dialect.js"; -import { - makePgOrderTransitionHarness, - makeSqliteOrderTransitionHarness, - teardownOrderFlow, -} from "./order-harness.js"; - -// The order-cancellation spec on the real adapters (admin-UX Increment 1, -// "cancel with reason"). SQLite verifies the DDL + the flip/record/enqueue -// compose; Postgres additionally runs the concurrency races below (SQLite -// serializes writes, so it can't race). - -afterEach(teardownOrderFlow); - -orderCancellationContract(makeSqliteOrderTransitionHarness, { dialect: "sqlite" }); - -const USD = currency("USD"); - -function pendingInput(id: string, key: string): CreateOrderInput { - return { - orderId: orderId(id), - cartId: "cart-1", - currency: USD, - idempotencyKey: idempotencyKey(key), - holdExpiresAt: "2026-07-10T00:15:00.000Z", - buyerRef: "buyer@example.com", - paymentMethod: "stripe", - lines: [ - { - productId: productId("p1"), - sku: sku("SKU-1"), - title: "Widget", - unitPrice: cents(500), - currency: USD, - quantity: 1, - fulfillmentKind: "physical", - reservationId: reservationId("res-1"), - }, - ], - totals: { subtotal: cents(500), total: cents(500), currency: USD }, - }; -} - -/** Seed an order straight to `processing` — cancellable, and the state - * `recordFulfillment` also accepts, so the two use-cases can race on it — - * draining + resetting the pre-cancel emails so a later assertion counts only - * the cancelled one. */ -async function seedProcessing( - h: OrderTransitionHarness, - id: string, - key: string, -): Promise { - const { order } = await h.store.createFromCart(pendingInput(id, key)); - for (const to of ["paid", "processing"] as const) { - await transitionOrder( - { orderStore: h.store }, - { orderId: order.id, toState: to, idempotencyKey: idempotencyKey(`t:${order.id}:${to}`) }, - ); - } - await dispatch(h); - h.emailSender.reset(); - return order.id; -} - -describe.skipIf(!PG_ENABLED)("[postgres]", () => { - orderCancellationContract(makePgOrderTransitionHarness, { dialect: "pg" }); - - // Concurrency (Postgres-required, like the no-oversell race): N concurrent - // cancelOrder calls on the SAME cancellable order must cancel it EXACTLY - // ONCE — the guarded `WHERE state=:fromState` flip makes one caller win and - // record its reason; the rest observe the already-cancelled order. Exactly - // one cancelled email is enqueued (outbox `UNIQUE(order_id, to_state)`). - test("concurrent cancelOrder cancels exactly once (no double reason / no double email)", async () => { - const h = await makePgOrderTransitionHarness(); - const id = await seedProcessing(h, "ord-cancel-race", "key-cancel-race"); - const N = 8; - const results = await Promise.all( - Array.from({ length: N }, (_v, i) => - cancelOrder( - { orderStore: h.store }, - { - orderId: id, - reason: "customer_request", - cancelledBy: `concurrent-${i}`, - idempotencyKey: idempotencyKey(`c:${id}:${i}`), - }, - ), - ), - ); - // Exactly one caller won the guarded flip and recorded; the rest are benign - // no-ops (cancelled:false) — none is an error. - expect(results.every((r) => r.ok)).toBe(true); - expect(results.filter((r) => r.ok && r.cancelled)).toHaveLength(1); - const order = await h.store.getById(id); - expect(order?.state).toBe("cancelled"); - expect(order?.cancellation).not.toBeNull(); - // Exactly one cancelled email drains. - expect(await dispatch(h)).toBe(1); - expect(h.emailSender.countByTemplate("order-cancelled", id)).toBe(1); - }); - - // cancelOrder-vs-recordFulfillment: extends #63's record-vs-cancel race (that - // one raced recordFulfillment against the BARE transition) to the reasoned - // cancel path. The state flip is the arbiter — exactly one wins. If cancel - // wins, the order is cancelled-with-a-reason and fulfillment is a - // NOT_FULFILLABLE no-op (never shipped behind the cancel's back); if - // fulfillment wins, cancel's guarded `WHERE state='processing'` flip is a - // 0-row no-op (NOT_CANCELLABLE) — the order is never both. - test("cancelOrder racing recordFulfillment: exactly one wins, the order is never both", async () => { - const h = await makePgOrderTransitionHarness(); - const id = await seedProcessing(h, "ord-cancel-vs-ship", "key-cancel-vs-ship"); - const [cancelled, fulfilled] = await Promise.all([ - cancelOrder( - { orderStore: h.store }, - { - orderId: id, - reason: "out_of_stock", - cancelledBy: "ops", - idempotencyKey: idempotencyKey(`c:${id}`), - }, - ), - recordFulfillment( - { orderStore: h.store }, - { - orderId: id, - carrier: "UPS", - trackingNumber: "1Z-vs-cancel", - recordedBy: "shipper", - idempotencyKey: idempotencyKey(`f:${id}`), - }, - ), - ]); - const finalState = (await h.store.getById(id))?.state; - expect(["cancelled", "shipped"]).toContain(finalState); - if (finalState === "cancelled") { - // Cancel won: it recorded the reason; fulfillment found no processing row. - expect(cancelled.ok && cancelled.cancelled).toBe(true); - expect(fulfilled).toEqual({ ok: false, reason: "NOT_FULFILLABLE" }); - expect((await h.store.getById(id))?.cancellation).not.toBeNull(); - } else { - // Fulfillment won: the order shipped; cancel is a no-op. - expect(fulfilled.ok && fulfilled.recorded).toBe(true); - expect(cancelled).toEqual({ ok: false, reason: "NOT_CANCELLABLE" }); - expect((await h.store.getById(id))?.cancellation).toBeNull(); - } - }); -}); - -function dispatch(h: OrderTransitionHarness) { - return dispatchOrderEmails({ orderStore: h.store, emailSender: h.emailSender, clock: h.clock }); -} diff --git a/packages/store-postgres/test/order-flow.dialects.test.ts b/packages/store-postgres/test/order-flow.dialects.test.ts deleted file mode 100644 index 9d7ede87..00000000 --- a/packages/store-postgres/test/order-flow.dialects.test.ts +++ /dev/null @@ -1,605 +0,0 @@ -import { - createOrderFromCart, - expireOrders, - getCart, - idempotencyKey, - type Order, - type OrderStore, - removeLine, - settleOrder, - sku as brandSku, - updateLine, -} from "@otta-sh/domain"; -import { afterEach, describe, expect, test } from "vitest"; -import { PG_ENABLED } from "./describe-each-dialect.js"; -import { - makePgOrderFlow, - makeSqliteOrderFlow, - type OrderFlowHarness, - teardownOrderFlow, -} from "./order-harness.js"; - -afterEach(teardownOrderFlow); - -const FUTURE = "2026-07-10T00:15:00.000Z"; - -function cmd(cartId: string, method: "stripe" | "x402" = "stripe", key = "k-order") { - return { - cartId, - idempotencyKey: idempotencyKey(key), - buyerRef: "buyer@example.com", - paymentMethod: method, - } as const; -} - -function evt(order: Order, over: Partial<{ dedupeKey: string; amount: number }> = {}) { - return { - outcome: "succeeded" as const, - orderId: order.id, - providerRef: `pi_${order.id}`, - amount: over.amount ?? order.totals.total, - currency: "USD", - dedupeKey: over.dedupeKey ?? `evt-${order.id}`, - }; -} - -function orderFlowTests(makeHarness: () => Promise, dialect: string): void { - describe(`order flow [${dialect}]`, () => { - test("editing product_commerce leaves existing order_items unchanged (snapshot immutability)", async () => { - const h = await makeHarness(); - await h.seedPhysical({ - productId: "p1", - sku: "SKU-1", - priceCents: 500, - title: "Widget", - onHand: 10, - }); - const cartId = await h.cartWith([ - { sku: "SKU-1", productId: "p1", qty: 2, kind: "physical" }, - ]); - const res = await createOrderFromCart(h.createDeps, cmd(cartId)); - if (!res.ok) throw new Error(res.reason); - - // Edit the product through the Phase-1 sync path (price + title change). - await h.editProduct({ productId: "p1", sku: "SKU-1", priceCents: 999, title: "Renamed" }); - - const item = await h.db - .selectFrom("order_items") - .select(["title", "unit_price_cents", "currency"]) - .where("order_id", "=", res.order.id) - .executeTakeFirstOrThrow(); - expect(item.title).toBe("Widget"); - expect(item.unit_price_cents).toBe(500); - expect(item.currency).toBe("USD"); - }); - - test("held→adopted flip removes the reservation from the Phase-3 held-scoped sweep", async () => { - const h = await makeHarness(); - await h.seedPhysical({ - productId: "p1", - sku: "SKU-1", - priceCents: 500, - title: "W", - onHand: 10, - }); - const cartId = await h.cartWith([ - { sku: "SKU-1", productId: "p1", qty: 2, kind: "physical" }, - ]); - const res = await createOrderFromCart(h.createDeps, cmd(cartId)); - if (!res.ok) throw new Error(res.reason); - const reservationId = res.order.lines[0]!.reservationId!; - expect(await h.reservationState(reservationId)).toBe("adopted"); - - // Run the Phase-3 reservation sweep (held-scoped) after the TTL passes. - const reclaimed = await h.sweepHeldHolds(); - expect(reclaimed).toBe(0); // adopted hold is structurally invisible to it - expect(await h.reservationState(reservationId)).toBe("adopted"); - expect(await h.onHand("SKU-1")).toBe(8); - }); - - test("order-expiry guarded transition releases the adopted reservation exactly once under a double-sweep race", async () => { - const h = await makeHarness(); - await h.seedPhysical({ - productId: "p1", - sku: "SKU-1", - priceCents: 500, - title: "W", - onHand: 10, - }); - const cartId = await h.cartWith([ - { sku: "SKU-1", productId: "p1", qty: 2, kind: "physical" }, - ]); - const res = await createOrderFromCart(h.createDeps, cmd(cartId)); - if (!res.ok) throw new Error(res.reason); - const reservationId = res.order.lines[0]!.reservationId!; - - h.clock.advance(16 * 60 * 1000); - const [a, b] = await Promise.all([expireOrders(h.expireDeps), expireOrders(h.expireDeps)]); - expect(a + b).toBe(1); // exactly one sweep expired it - expect((await h.orderStore.getById(res.order.id))?.state).toBe("expired"); - expect(await h.reservationState(reservationId)).toBe("released"); - expect(await h.onHand("SKU-1")).toBe(10); // returned exactly once - }); - - test("a second checkout of the same cart with a DIFFERENT idempotency key is rejected CART_CHECKED_OUT at the store level", async () => { - const h = await makeHarness(); - await h.seedPhysical({ - productId: "p1", - sku: "SKU-1", - priceCents: 500, - title: "W", - onHand: 10, - }); - const cartId = await h.cartWith([ - { sku: "SKU-1", productId: "p1", qty: 1, kind: "physical" }, - ]); - const first = await createOrderFromCart(h.createDeps, cmd(cartId, "stripe", "k-tab-1")); - if (!first.ok) throw new Error(first.reason); - const reservationId = first.order.lines[0]!.reservationId!; - - // Two tabs, per-click keys (G2): distinct key on the checked-out cart. - const second = await createOrderFromCart(h.createDeps, cmd(cartId, "stripe", "k-tab-2")); - expect(second).toEqual({ ok: false, reason: "CART_CHECKED_OUT" }); - expect(await h.reservationState(reservationId)).toBe("adopted"); - // And the same-key replay is still honored (the idempotent path). - const replay = await createOrderFromCart(h.createDeps, cmd(cartId, "stripe", "k-tab-1")); - expect(replay.ok).toBe(true); - if (replay.ok) expect(replay.order.id).toBe(first.order.id); - }); - - test("expireOrders' release is order-scoped: a stale order pointing at a foreign adopted (or committed) reservation never frees it and never crashes the sweep", async () => { - const h = await makeHarness(); - await h.seedPhysical({ - productId: "p1", - sku: "SKU-1", - priceCents: 500, - title: "W", - onHand: 10, - }); - const cartId = await h.cartWith([ - { sku: "SKU-1", productId: "p1", qty: 2, kind: "physical" }, - ]); - const owner = await createOrderFromCart(h.createDeps, cmd(cartId, "stripe", "k-owner")); - if (!owner.ok) throw new Error(owner.reason); - const reservationId = owner.order.lines[0]!.reservationId!; - expect(await h.reservationState(reservationId)).toBe("adopted"); - - // A stale order (the pre-fence two-tab artifact) whose line points at the - // OWNER's reservation, already past its TTL. - await h.orderStore.createFromCart({ - orderId: `stale-${owner.order.id}` as typeof owner.order.id, - cartId: "cart-stale", - currency: owner.order.currency, - idempotencyKey: idempotencyKey("k-stale"), - holdExpiresAt: "2026-07-10T00:01:00.000Z", - buyerRef: "stale@example.com", - paymentMethod: "stripe", - lines: [ - { - productId: owner.order.lines[0]!.productId, - sku: brandSku("SKU-1"), - title: "W", - unitPrice: owner.order.lines[0]!.unitPrice, - currency: owner.order.currency, - quantity: 2, - fulfillmentKind: "physical", - reservationId, - }, - ], - totals: owner.order.totals, - }); - - h.clock.advance(2 * 60 * 1000); // stale TTL passed; owner's 15-min hold live - expect(await expireOrders(h.expireDeps)).toBe(1); // the stale order expires… - // …but the owner's adopted hold is untouched and stock did not return. - expect(await h.reservationState(reservationId)).toBe("adopted"); - expect(await h.onHand("SKU-1")).toBe(8); - - // The owner settles (commit) — and a later sweep must not throw on any - // stale row pointing at the now-COMMITTED reservation (an unscoped - // release would crash EVERY subsequent run). - const settled = await settleOrder( - h.settleDeps, - h.stripeGw, - h.stripeGw.webhook(evt(owner.order)), - ); - expect(settled.ok).toBe(true); - expect(await h.reservationState(reservationId)).toBe("committed"); - await expect(expireOrders(h.expireDeps)).resolves.toBe(0); // survives - }); - - test("a post-checkout cart removeLine/adjustLine cannot release or shrink an adopted hold — returns LINE_CHECKED_OUT, stock and reservation unchanged", async () => { - const h = await makeHarness(); - await h.seedPhysical({ - productId: "p1", - sku: "SKU-1", - priceCents: 500, - title: "W", - onHand: 10, - }); - const cartId = await h.cartWith([ - { sku: "SKU-1", productId: "p1", qty: 2, kind: "physical" }, - ]); - const cart = (await getCart(h.cartDeps, cartId))!; - const line = cart.lines[0]!; - // Adopt the reservation directly WITHOUT flipping the cart, so the - // PRIMARY reservation-state fence (not the cart-state fence) is exercised. - await h.inventory.adopt({ - reservationId: line.reservationId!, - orderId: "ord-direct", - holdExpiresAt: FUTURE, - now: "2026-07-10T00:00:00.000Z", - }); - - const rm = await removeLine(h.cartDeps, cartId, line.lineId, idempotencyKey("rm-1")); - expect(rm).toEqual({ ok: false, reason: "LINE_CHECKED_OUT" }); - const up = await updateLine(h.cartDeps, cartId, line.lineId, 1, idempotencyKey("up-1")); - expect(up).toEqual({ ok: false, reason: "LINE_CHECKED_OUT" }); - expect(await h.reservationState(line.reservationId!)).toBe("adopted"); - expect(await h.onHand("SKU-1")).toBe(8); // stock not returned or shrunk - }); - - test("createOrderFromCart flips the cart active→checked_out; a subsequent add/adjust/remove is rejected CART_CHECKED_OUT", async () => { - const h = await makeHarness(); - await h.seedPhysical({ - productId: "p1", - sku: "SKU-1", - priceCents: 500, - title: "W", - onHand: 10, - }); - const cartId = await h.cartWith([ - { sku: "SKU-1", productId: "p1", qty: 2, kind: "physical" }, - ]); - const res = await createOrderFromCart(h.createDeps, cmd(cartId)); - if (!res.ok) throw new Error(res.reason); - const cart = (await getCart(h.cartDeps, cartId))!; - expect(cart.state).toBe("checked_out"); - const rm = await removeLine( - h.cartDeps, - cartId, - cart.lines[0]!.lineId, - idempotencyKey("rm-2"), - ); - expect(rm).toEqual({ ok: false, reason: "CART_CHECKED_OUT" }); - }); - - test("Stripe webhook → paid + inventory commit exactly once; a replay settles once", async () => { - const h = await makeHarness(); - await h.seedPhysical({ - productId: "p1", - sku: "SKU-1", - priceCents: 1500, - title: "W", - onHand: 5, - }); - const cartId = await h.cartWith([ - { sku: "SKU-1", productId: "p1", qty: 1, kind: "physical" }, - ]); - const res = await createOrderFromCart(h.createDeps, cmd(cartId)); - if (!res.ok) throw new Error(res.reason); - const reservationId = res.order.lines[0]!.reservationId!; - const raw = h.stripeGw.webhook(evt(res.order)); - - const settled = await settleOrder(h.settleDeps, h.stripeGw, raw); - expect(settled.ok).toBe(true); - expect((await h.orderStore.getById(res.order.id))?.state).toBe("paid"); - expect(await h.reservationState(reservationId)).toBe("committed"); - expect(await h.onHand("SKU-1")).toBe(4); // committed, not released - - const replay = await settleOrder(h.settleDeps, h.stripeGw, raw); - expect(replay.ok && replay.noop).toBe(true); - const payments = await h.db - .selectFrom("payments") - .selectAll() - .where("order_id", "=", res.order.id) - .execute(); - expect(payments).toHaveLength(1); - }); - - test("x402 page-gate → paid + entitlement granted", async () => { - const h = await makeHarness(); - await h.seedDigital({ productId: "d1", sku: "DIG-1", priceCents: 900, title: "Ebook" }); - const cartId = await h.cartWith([{ sku: "DIG-1", productId: "d1", qty: 1, kind: "digital" }]); - const res = await createOrderFromCart(h.createDeps, cmd(cartId, "x402")); - if (!res.ok) throw new Error(res.reason); - const raw = h.x402Gw.pageGate({ - orderId: res.order.id, - transaction: `0xtx-${res.order.id}`, - network: "eip155:8453", - payer: "0xbuyer", - amount: res.order.totals.total, - currency: res.order.currency, - }); - const settled = await settleOrder(h.settleDeps, h.x402Gw, raw); - expect(settled.ok).toBe(true); - expect((await h.orderStore.getById(res.order.id))?.state).toBe("paid"); - expect( - await h.entitlementStore.check({ orderId: res.order.id, sku: brandSku("DIG-1") }), - ).toBe(true); - }); - - test("settle commit against a reservation lost to a stray release records the anomaly at SQL level (order flagged, payment_events anomaly row written)", async () => { - const h = await makeHarness(); - await h.seedPhysical({ - productId: "p1", - sku: "SKU-1", - priceCents: 1500, - title: "W", - onHand: 5, - }); - const cartId = await h.cartWith([ - { sku: "SKU-1", productId: "p1", qty: 1, kind: "physical" }, - ]); - const res = await createOrderFromCart(h.createDeps, cmd(cartId)); - if (!res.ok) throw new Error(res.reason); - const reservationId = res.order.lines[0]!.reservationId!; - // Stray release of the adopted hold (invariant violation). - await h.inventory.release(reservationId); - - const settled = await settleOrder( - h.settleDeps, - h.stripeGw, - h.stripeGw.webhook(evt(res.order)), - ); - expect(settled.ok).toBe(true); // money received; order is paid - const order = await h.orderStore.getById(res.order.id); - expect(order?.state).toBe("paid"); - expect(order?.reconciliationFlag).not.toBeNull(); - const anomalies = await h.db - .selectFrom("payment_events") - .selectAll() - .where("kind", "=", "COMMIT_LOST") - .where("order_id", "=", res.order.id) - .execute(); - expect(anomalies).toHaveLength(1); - }); - - // -- review round: mid-flight flip loss (F1) + crash-window resumption (F2) -- - - test("a settle losing the paid flip to a concurrent expiry records the PAID_FLIP_LOST anomaly and flags reconciliation (mid-flight loser is loud)", async () => { - const h = await makeHarness(); - await h.seedPhysical({ - productId: "p1", - sku: "SKU-1", - priceCents: 1500, - title: "W", - onHand: 5, - }); - const cartId = await h.cartWith([ - { sku: "SKU-1", productId: "p1", qty: 1, kind: "physical" }, - ]); - const res = await createOrderFromCart(h.createDeps, cmd(cartId)); - if (!res.ok) throw new Error(res.reason); - const reservationId = res.order.lines[0]!.reservationId!; - h.clock.advance(16 * 60 * 1000); // past the checkout TTL; sweep not yet run - - // Force the interleave on the REAL store: settle loads `pending`, then - // the expiry sweep wins between the load and the pending→paid flip. - // (Explicit delegation — a prototype proxy would break the Kysely - // store's #private-field receivers.) - const racingOrderStore: OrderStore = { - createFromCart: (i) => h.orderStore.createFromCart(i), - getById: (id) => h.orderStore.getById(id), - getByIdempotencyKey: (k) => h.orderStore.getByIdempotencyKey(k), - markPaid: async (id) => { - await expireOrders(h.expireDeps); - return h.orderStore.markPaid(id); - }, - markFailed: (id) => h.orderStore.markFailed(id), - expire: (id, at) => h.orderStore.expire(id, at), - listExpirable: (at) => h.orderStore.listExpirable(at), - recordPayment: (i) => h.orderStore.recordPayment(i), - getCapturedPayments: (id) => h.orderStore.getCapturedPayments(id), - listRefunds: (id) => h.orderStore.listRefunds(id), - getRefundByIdempotencyKey: (k) => h.orderStore.getRefundByIdempotencyKey(k), - recordRefund: (i) => h.orderStore.recordRefund(i), - reserveRefund: (i) => h.orderStore.reserveRefund(i), - finalizeRefund: (i) => h.orderStore.finalizeRefund(i), - voidRefund: (k) => h.orderStore.voidRefund(k), - markRefundUnverified: (k) => h.orderStore.markRefundUnverified(k), - flagReconciliation: (id, d) => h.orderStore.flagReconciliation(id, d), - resolveReconciliation: (i) => h.orderStore.resolveReconciliation(i), - recordFulfillment: (i) => h.orderStore.recordFulfillment(i), - cancelOrder: (i) => h.orderStore.cancelOrder(i), - transition: (i) => h.orderStore.transition(i), - listForCustomer: (c) => h.orderStore.listForCustomer(c), - listEventsForOrder: (id) => h.orderStore.listEventsForOrder(id), - listOrders: (f, p) => h.orderStore.listOrders(f, p), - countOrders: (f) => h.orderStore.countOrders(f), - linkGuestOrders: (c, ref) => h.orderStore.linkGuestOrders(c, ref), - claimNextEmail: (now, lease) => h.orderStore.claimNextEmail(now, lease), - markEmailSent: (id, now) => h.orderStore.markEmailSent(id, now), - rescheduleEmail: (id, at) => h.orderStore.rescheduleEmail(id, at), - }; - - const settled = await settleOrder( - { ...h.settleDeps, orderStore: racingOrderStore }, - h.stripeGw, - h.stripeGw.webhook(evt(res.order)), - ); - expect(settled.ok).toBe(true); - if (settled.ok) expect(settled.noop).toBe(true); - const order = await h.orderStore.getById(res.order.id); - expect(order?.state).toBe("expired"); - expect(order?.reconciliationFlag).not.toBeNull(); - const anomalies = await h.db - .selectFrom("payment_events") - .selectAll() - .where("kind", "=", "PAID_FLIP_LOST") - .where("order_id", "=", res.order.id) - .execute(); - expect(anomalies).toHaveLength(1); - expect(await h.reservationState(reservationId)).toBe("released"); - }); - - test("ONE receipt settles ONE order: the same dedupe key aimed at a SECOND order is refused", async () => { - // Review round 2, A1/B1, against a REAL database — the SQL is the point - // here. `settleOrder` discarded `dedupe`'s answer, so a receipt already - // bound to order A, resubmitted naming order B, settled B; `recordPayment` - // then conflicted on the globally-unique `provider_ref` and silently wrote - // nothing, so the ledger did not even show the second settlement. The - // binding is now enforced through `orderForDedupeKey`, which is a real - // SELECT against `payment_events` and is exercised on both dialects here. - const h = await makeHarness(); - await h.seedPhysical({ - productId: "p1", - sku: "SKU-1", - priceCents: 1500, - title: "W", - onHand: 5, - }); - const cartA = await h.cartWith([{ sku: "SKU-1", productId: "p1", qty: 1, kind: "physical" }]); - // DISTINCT idempotency keys, or the second create REPLAYS the first and - // hands back order A — which would make this test pass for no reason. - const a = await createOrderFromCart(h.createDeps, cmd(cartA, "stripe", "k-order-a")); - if (!a.ok) throw new Error(a.reason); - const cartB = await h.cartWith([{ sku: "SKU-1", productId: "p1", qty: 1, kind: "physical" }]); - const b = await createOrderFromCart(h.createDeps, cmd(cartB, "stripe", "k-order-b")); - if (!b.ok) throw new Error(b.reason); - - const SHARED = "evt-one-payment-two-orders"; - const first = await settleOrder( - h.settleDeps, - h.stripeGw, - h.stripeGw.webhook(evt(a.order, { dedupeKey: SHARED })), - ); - expect(first.ok).toBe(true); - - const rebound = await settleOrder( - h.settleDeps, - h.stripeGw, - h.stripeGw.webhook(evt(b.order, { dedupeKey: SHARED })), - ); - expect(rebound).toEqual({ ok: false, reason: "RECEIPT_REBOUND" }); - // Order B never moved, and the refusal is RECORDED rather than silent. - expect((await h.orderStore.getById(b.order.id))?.state).toBe("pending"); - expect((await h.orderStore.getById(a.order.id))?.state).toBe("paid"); - const anomalies = await h.db - .selectFrom("payment_events") - .selectAll() - .where("kind", "=", "RECEIPT_REBOUND") - .where("order_id", "=", b.order.id) - .execute(); - expect(anomalies).toHaveLength(1); - // ONE payment in the ledger, for order A only. - const payments = await h.db.selectFrom("payments").selectAll().execute(); - expect(payments).toHaveLength(1); - expect(payments[0]?.order_id).toBe(a.order.id); - }); - - test("a settle retry after a crash between dedupe and markPaid completes the settlement", async () => { - const h = await makeHarness(); - await h.seedPhysical({ - productId: "p1", - sku: "SKU-1", - priceCents: 1500, - title: "W", - onHand: 5, - }); - const cartId = await h.cartWith([ - { sku: "SKU-1", productId: "p1", qty: 1, kind: "physical" }, - ]); - const res = await createOrderFromCart(h.createDeps, cmd(cartId)); - if (!res.ok) throw new Error(res.reason); - const reservationId = res.order.lines[0]!.reservationId!; - const event = evt(res.order); - // Simulate the crash: only the payment_events dedupe row landed. - await h.paymentEventStore.dedupe(event.dedupeKey, res.order.id, "stripe", FUTURE); - - // The gateway retry re-delivers the SAME event: it must RESUME, not no-op. - const settled = await settleOrder(h.settleDeps, h.stripeGw, h.stripeGw.webhook(event)); - expect(settled.ok).toBe(true); - if (settled.ok) expect(settled.noop).toBe(false); - expect((await h.orderStore.getById(res.order.id))?.state).toBe("paid"); - expect(await h.reservationState(reservationId)).toBe("committed"); - const payments = await h.db - .selectFrom("payments") - .selectAll() - .where("order_id", "=", res.order.id) - .execute(); - expect(payments).toHaveLength(1); - }); - - test("a settle retry after a crash between markPaid and commit completes the side-effects exactly once", async () => { - const h = await makeHarness(); - await h.seedPhysical({ - productId: "p1", - sku: "SKU-1", - priceCents: 1500, - title: "W", - onHand: 5, - }); - const cartId = await h.cartWith([ - { sku: "SKU-1", productId: "p1", qty: 1, kind: "physical" }, - ]); - const res = await createOrderFromCart(h.createDeps, cmd(cartId)); - if (!res.ok) throw new Error(res.reason); - const reservationId = res.order.lines[0]!.reservationId!; - const event = evt(res.order); - // Simulate the crash: dedupe + the paid flip landed; commit/record did not. - await h.paymentEventStore.dedupe(event.dedupeKey, res.order.id, "stripe", FUTURE); - await h.orderStore.markPaid(res.order.id); - expect(await h.reservationState(reservationId)).toBe("adopted"); - - const settled = await settleOrder(h.settleDeps, h.stripeGw, h.stripeGw.webhook(event)); - expect(settled.ok).toBe(true); - expect(await h.reservationState(reservationId)).toBe("committed"); - // A further retry moves nothing more (exactly once). - await settleOrder(h.settleDeps, h.stripeGw, h.stripeGw.webhook(event)); - const payments = await h.db - .selectFrom("payments") - .selectAll() - .where("order_id", "=", res.order.id) - .execute(); - expect(payments).toHaveLength(1); - const anomalies = await h.db - .selectFrom("payment_events") - .selectAll() - .where("kind", "is not", null) - .execute(); - expect(anomalies).toHaveLength(0); - }); - - test("commit against a released reservation throws the loud ReservationCommitLostError; against a committed one it is a benign no-op (guard-first)", async () => { - const h = await makeHarness(); - await h.seedPhysical({ - productId: "p1", - sku: "SKU-1", - priceCents: 1500, - title: "W", - onHand: 5, - }); - const cartId = await h.cartWith([ - { sku: "SKU-1", productId: "p1", qty: 1, kind: "physical" }, - ]); - const cart = (await h.cartStore.get(cartId))!; - const reservationId = cart.lines[0]!.reservationId!; - await h.inventory.commit(reservationId); // held → committed - await expect(h.inventory.commit(reservationId)).resolves.toBeUndefined(); // benign replay - expect(await h.reservationState(reservationId)).toBe("committed"); - - // A second, lost hold: released before commit → the loud typed anomaly. - await h.seedPhysical({ - productId: "p2", - sku: "SKU-2", - priceCents: 500, - title: "X", - onHand: 5, - }); - const cartId2 = await h.cartWith([ - { sku: "SKU-2", productId: "p2", qty: 1, kind: "physical" }, - ]); - const cart2 = (await h.cartStore.get(cartId2))!; - const lost = cart2.lines[0]!.reservationId!; - await h.inventory.release(lost); - await expect(h.inventory.commit(lost)).rejects.toThrow("not held/adopted/committed"); - }); - }); -} - -orderFlowTests(makeSqliteOrderFlow, "sqlite"); - -describe.skipIf(!PG_ENABLED)("[postgres]", () => { - orderFlowTests(makePgOrderFlow, "pg"); -}); diff --git a/packages/store-postgres/test/order-fulfillment-contract.dialects.test.ts b/packages/store-postgres/test/order-fulfillment-contract.dialects.test.ts deleted file mode 100644 index a95f4711..00000000 --- a/packages/store-postgres/test/order-fulfillment-contract.dialects.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { - dispatchOrderEmails, - idempotencyKey, - orderId, - productId, - recordFulfillment, - reservationId, - sku, - transitionOrder, - cents, - currency, - type CreateOrderInput, - type OrderId, -} from "@otta-sh/domain"; -import { orderFulfillmentContract, type OrderTransitionHarness } from "@otta-sh/domain/testing"; -import { afterEach, describe, expect, test } from "vitest"; -import { PG_ENABLED } from "./describe-each-dialect.js"; -import { - makePgOrderTransitionHarness, - makeSqliteOrderTransitionHarness, - teardownOrderFlow, -} from "./order-harness.js"; - -// The order-fulfillment spec on the real adapters (admin-UX Increment 1). SQLite -// verifies the DDL + the record/ship/enqueue compose; Postgres additionally runs -// the concurrency races below (SQLite serializes writes, so it can't race). - -afterEach(teardownOrderFlow); - -orderFulfillmentContract(makeSqliteOrderTransitionHarness, { dialect: "sqlite" }); - -const USD = currency("USD"); - -function pendingInput(id: string, key: string): CreateOrderInput { - return { - orderId: orderId(id), - cartId: "cart-1", - currency: USD, - idempotencyKey: idempotencyKey(key), - holdExpiresAt: "2026-07-10T00:15:00.000Z", - buyerRef: "buyer@example.com", - paymentMethod: "stripe", - lines: [ - { - productId: productId("p1"), - sku: sku("SKU-1"), - title: "Widget", - unitPrice: cents(500), - currency: USD, - quantity: 1, - fulfillmentKind: "physical", - reservationId: reservationId("res-1"), - }, - ], - totals: { subtotal: cents(500), total: cents(500), currency: USD }, - }; -} - -/** Seed an order straight to `processing` (fulfillment's only legal from-state), - * draining + resetting the pre-ship emails so a later assertion counts only the - * shipped one. */ -async function seedProcessing( - h: OrderTransitionHarness, - id: string, - key: string, -): Promise { - const { order } = await h.store.createFromCart(pendingInput(id, key)); - for (const to of ["paid", "processing"] as const) { - await transitionOrder( - { orderStore: h.store }, - { orderId: order.id, toState: to, idempotencyKey: idempotencyKey(`t:${order.id}:${to}`) }, - ); - } - await dispatch(h); - h.emailSender.reset(); - return order.id; -} - -describe.skipIf(!PG_ENABLED)("[postgres]", () => { - orderFulfillmentContract(makePgOrderTransitionHarness, { dialect: "pg" }); - - // Concurrency (Postgres-required, like the no-oversell race): N concurrent - // record-fulfillment calls on the SAME processing order must ship it EXACTLY - // ONCE — the guarded `WHERE state='processing'` flip makes one caller win and - // records its tracking; the rest observe the already-shipped order. Exactly one - // shipped email is enqueued (outbox `UNIQUE(order_id, to_state)`). - test("concurrent record-fulfillment ships exactly once (no double fulfillment / no double email)", async () => { - const h = await makePgOrderTransitionHarness(); - const id = await seedProcessing(h, "ord-race", "key-race"); - const N = 8; - const results = await Promise.all( - Array.from({ length: N }, (_v, i) => - recordFulfillment( - { orderStore: h.store }, - { - orderId: id, - carrier: "UPS", - trackingNumber: `1Z-${i}`, - recordedBy: "concurrent", - idempotencyKey: idempotencyKey(`f:${id}:${i}`), - }, - ), - ), - ); - // Exactly one caller won the guarded flip and recorded; the rest are benign - // no-ops (recorded:false) — none is an error. - expect(results.every((r) => r.ok)).toBe(true); - expect(results.filter((r) => r.ok && r.recorded)).toHaveLength(1); - const order = await h.store.getById(id); - expect(order?.state).toBe("shipped"); - expect(order?.fulfillment).not.toBeNull(); - // Exactly one shipped email drains. - expect(await dispatch(h)).toBe(1); - expect(h.emailSender.countByTemplate("order-shipped", id)).toBe(1); - // The state-change audit rode the SAME guarded flip transaction — exactly - // ONE `processing → shipped` event, never one per losing caller (timeline - // slice: a replay/lost race is a 0-row flip and records no event). - const shippedEvents = (await h.store.listEventsForOrder(id)).filter( - (e) => e.toState === "shipped", - ); - expect(shippedEvents).toHaveLength(1); - expect(shippedEvents[0]).toMatchObject({ fromState: "processing", actor: "concurrent" }); - }); - - // Record-vs-cancel: a record-fulfillment and a `processing → cancelled` - // transition race on the same order. The state flip is the arbiter — exactly one - // wins. If cancel wins, the order is cancelled and record is a NOT_FULFILLABLE - // no-op (never shipped behind the cancel's back); if record wins, cancel's - // guarded `WHERE state='processing'` flip is a 0-row no-op. - test("record-fulfillment racing a cancel: exactly one wins, the order is never both", async () => { - const h = await makePgOrderTransitionHarness(); - const id = await seedProcessing(h, "ord-vs-cancel", "key-vs-cancel"); - const [fulfil, cancel] = await Promise.all([ - recordFulfillment( - { orderStore: h.store }, - { - orderId: id, - carrier: "UPS", - trackingNumber: "1Z-vs", - recordedBy: "shipper", - idempotencyKey: idempotencyKey(`f:${id}`), - }, - ), - transitionOrder( - { orderStore: h.store }, - { orderId: id, toState: "cancelled", idempotencyKey: idempotencyKey(`t:${id}:cancelled`) }, - ), - ]); - const finalState = (await h.store.getById(id))?.state; - expect(["shipped", "cancelled"]).toContain(finalState); - if (finalState === "shipped") { - // Record won: it shipped + recorded; the cancel found no processing row. - expect(fulfil.ok && fulfil.recorded).toBe(true); - expect(cancel.ok && cancel.transitioned).toBe(false); - expect((await h.store.getById(id))?.fulfillment).not.toBeNull(); - } else { - // Cancel won: the order is cancelled with no fulfillment; record is a no-op. - expect(cancel.ok && cancel.transitioned).toBe(true); - expect(fulfil).toEqual({ ok: false, reason: "NOT_FULFILLABLE" }); - expect((await h.store.getById(id))?.fulfillment).toBeNull(); - } - }); -}); - -function dispatch(h: OrderTransitionHarness) { - return dispatchOrderEmails({ orderStore: h.store, emailSender: h.emailSender, clock: h.clock }); -} diff --git a/packages/store-postgres/test/order-harness.ts b/packages/store-postgres/test/order-harness.ts deleted file mode 100644 index 2c1bee10..00000000 --- a/packages/store-postgres/test/order-harness.ts +++ /dev/null @@ -1,488 +0,0 @@ -import { - addLine, - type CartDeps, - cents, - createCart, - currency, - type CreateOrderDeps, - type ExpireOrdersDeps, - type FulfillmentKind, - idempotencyKey, - money, - productId as brandProductId, - type SettleDeps, - sku as brandSku, -} from "@otta-sh/domain"; -import { - buildRefundSeed, - CountingIdGen, - type EntitlementStoreHarness, - FakeEmailSender, - FakePaymentGateway, - FixedClock, - type OrderNotesStoreHarness, - type OrderStoreHarness, - type OrderTimelineHarness, - type OrderTransitionHarness, - type RefundOrderHarness, -} from "@otta-sh/domain/testing"; -import type { Kysely } from "kysely"; -import { - KyselyCartStore, - KyselyCouponStore, - KyselyEntitlementStore, - KyselyInventoryStore, - KyselyOrderNotesStore, - KyselyOrderStore, - KyselyPaymentEventStore, - KyselyProductCommerceStore, - KyselyShippingRulesStore, - KyselyTaxRulesStore, - makeSqliteDb, - migrateToLatest, - uuidIdGen, -} from "../src/index.js"; -import type { Database } from "../src/schema.js"; -import { createIsolatedPgSchema } from "../src/testing.js"; - -const USD = currency("USD"); -const cleanups: Array<() => Promise> = []; - -export async function teardownOrderFlow(): Promise { - const fns = cleanups.splice(0); - for (const fn of fns) await fn(); -} - -export interface OrderFlowHarness { - db: Kysely; - clock: FixedClock; - createDeps: CreateOrderDeps; - settleDeps: SettleDeps; - expireDeps: ExpireOrdersDeps; - cartDeps: CartDeps; - orderStore: KyselyOrderStore; - entitlementStore: KyselyEntitlementStore; - paymentEventStore: KyselyPaymentEventStore; - couponStore: KyselyCouponStore; - inventory: KyselyInventoryStore; - cartStore: KyselyCartStore; - stripeGw: FakePaymentGateway; - x402Gw: FakePaymentGateway; - seedPhysical(i: { - productId: string; - sku: string; - priceCents: number; - title: string; - onHand: number; - }): Promise; - seedDigital(i: { - productId: string; - sku: string; - priceCents: number; - title: string; - }): Promise; - editProduct(i: { - productId: string; - sku: string; - priceCents: number; - title: string; - }): Promise; - cartWith( - specs: { sku: string; productId: string; qty: number; kind: FulfillmentKind }[], - ): Promise; - onHand(sku: string): Promise; - reservationState(id: string): Promise; - sweepHeldHolds(): Promise; -} - -function build(db: Kysely): OrderFlowHarness { - const clock = new FixedClock(new Date("2026-07-10T00:00:00.000Z")); - const inventory = new KyselyInventoryStore({ db, idGen: uuidIdGen, clock }); - const cartStore = new KyselyCartStore({ db, idGen: uuidIdGen, clock }); - const productCommerce = new KyselyProductCommerceStore({ db, clock }); - const orderStore = new KyselyOrderStore({ db, idGen: uuidIdGen, clock }); - const entitlementStore = new KyselyEntitlementStore({ db, idGen: uuidIdGen, clock }); - const paymentEventStore = new KyselyPaymentEventStore({ db, idGen: uuidIdGen }); - const stripeGw = new FakePaymentGateway({ id: "stripe" }); - const x402Gw = new FakePaymentGateway({ id: "x402" }); - let seq = 0; - - const couponStore = new KyselyCouponStore({ db, idGen: uuidIdGen, clock }); - const cartDeps: CartDeps = { cartStore, inventoryStore: inventory, clock }; - const createDeps: CreateOrderDeps = { - orderStore, - cartStore, - inventoryStore: inventory, - productCommerce, - shippingRules: new KyselyShippingRulesStore({ db }), - taxRules: new KyselyTaxRulesStore({ db }), - couponStore, - clock, - idGen: new CountingIdGen("order"), - gateways: { stripe: stripeGw, x402: x402Gw }, - }; - const settleDeps: SettleDeps = { - orderStore, - entitlementStore, - paymentEventStore, - inventoryStore: inventory, - couponStore, - clock, - }; - const expireDeps: ExpireOrdersDeps = { - orderStore, - inventoryStore: inventory, - couponStore, - clock, - }; - - return { - db, - clock, - createDeps, - settleDeps, - expireDeps, - cartDeps, - orderStore, - entitlementStore, - paymentEventStore, - couponStore, - inventory, - cartStore, - stripeGw, - x402Gw, - async seedPhysical(i) { - await productCommerce.upsert( - { - productId: brandProductId(i.productId), - sku: brandSku(i.sku), - price: money(cents(i.priceCents), USD), - title: i.title, - productKind: "physical", - }, - idempotencyKey(`seed-${seq++}`), - ); - await inventory.seedOnHand(i.sku, i.onHand); - }, - async seedDigital(i) { - await productCommerce.upsert( - { - productId: brandProductId(i.productId), - sku: brandSku(i.sku), - price: money(cents(i.priceCents), USD), - title: i.title, - productKind: "digital", - }, - idempotencyKey(`seed-${seq++}`), - ); - }, - async editProduct(i) { - // Edit the product's price + title via the Phase-1 sync path. - await productCommerce.upsert( - { - productId: brandProductId(i.productId), - sku: brandSku(i.sku), - price: money(cents(i.priceCents), USD), - title: i.title, - }, - idempotencyKey(`edit-${seq++}`), - ); - }, - async cartWith(specs) { - const cartId = await createCart(cartDeps, USD); - for (const spec of specs) { - const res = await addLine( - cartDeps, - cartId, - brandSku(spec.sku), - spec.productId, - spec.qty, - idempotencyKey(`add-${seq++}`), - spec.kind, - ); - if (!res.ok) throw new Error(`seed addLine failed: ${res.reason}`); - } - return cartId; - }, - async onHand(sku) { - const row = await db - .selectFrom("inventory") - .select("on_hand") - .where("sku", "=", sku) - .executeTakeFirst(); - return row?.on_hand ?? 0; - }, - async reservationState(id) { - const row = await db - .selectFrom("reservations") - .select("state") - .where("id", "=", id) - .executeTakeFirst(); - return row?.state; - }, - async sweepHeldHolds() { - // Drive the Phase-3 reservation sweep directly (held-scoped) to prove an - // adopted hold is invisible to it. - const { expireHolds } = await import("@otta-sh/domain"); - clock.advance(16 * 60 * 1000); - return expireHolds(cartDeps); - }, - }; -} - -export async function makeSqliteOrderFlow(): Promise { - const db = makeSqliteDb(":memory:"); - await migrateToLatest(db); - cleanups.push(async () => { - await db.destroy(); - }); - return build(db); -} - -export async function makePgOrderFlow(): Promise { - const connectionString = process.env.PG_CONNECTION_STRING; - if (connectionString === undefined) throw new Error("PG_CONNECTION_STRING is not set"); - const iso = await createIsolatedPgSchema(connectionString, { poolMax: 8 }); - cleanups.push(() => iso.teardown()); - return build(iso.db); -} - -// -- store contract harnesses (order + entitlement) -------------------------- - -export function buildOrderStoreHarness(db: Kysely): OrderStoreHarness { - const clock = new FixedClock(new Date("2026-07-10T00:00:00.000Z")); - return { - store: new KyselyOrderStore({ db, idGen: new CountingIdGen("oi"), clock }), - // Direct orders + order_totals insert (mirrors the reporting harness) so the - // admin-list contract can pin an EXACT created_at/state/buyer_ref/total per - // row — the fake, sqlite, and pg then exercise the identical spec (MOD-5). - async seedOrder(row) { - await db - .insertInto("orders") - .values({ - id: row.id, - cart_id: null, - currency: row.currency, - state: row.state as Database["orders"]["state"], - idempotency_key: `seed-${row.id}`, - hold_expires_at: row.createdAt, - payment_method: row.paymentMethod ?? null, - buyer_ref: row.buyerRef, - customer_id: row.customerId ?? null, - reconciliation_flag: row.reconciliationFlag ?? null, - created_at: row.createdAt, - updated_at: row.createdAt, - }) - .execute(); - await db - .insertInto("order_totals") - .values({ - order_id: row.id, - currency: row.currency, - subtotal_cents: row.totalCents, - discount_cents: 0, - shipping_cents: 0, - tax_cents: 0, - total_cents: row.totalCents, - applied_coupon_code: null, - shipping_method_snapshot: null, - tax_breakdown: null, - }) - .execute(); - }, - }; -} - -export function buildEntitlementHarness(db: Kysely): EntitlementStoreHarness { - const clock = new FixedClock(new Date("2026-07-10T00:00:00.000Z")); - return { - store: new KyselyEntitlementStore({ db, idGen: new CountingIdGen("ent"), clock }), - async revoke(orderId: string) { - await db - .updateTable("entitlements") - .set({ state: "revoked" }) - .where("order_id", "=", orderId) - .execute(); - }, - }; -} - -export async function makeSqliteOrderStoreHarness(): Promise { - const db = makeSqliteDb(":memory:"); - await migrateToLatest(db); - cleanups.push(async () => { - await db.destroy(); - }); - return buildOrderStoreHarness(db); -} - -export async function makePgOrderStoreHarness(): Promise { - const connectionString = process.env.PG_CONNECTION_STRING; - if (connectionString === undefined) throw new Error("PG_CONNECTION_STRING is not set"); - const iso = await createIsolatedPgSchema(connectionString, { poolMax: 4 }); - cleanups.push(() => iso.teardown()); - return buildOrderStoreHarness(iso.db); -} - -export async function makeSqliteEntitlementHarness(): Promise { - const db = makeSqliteDb(":memory:"); - await migrateToLatest(db); - cleanups.push(async () => { - await db.destroy(); - }); - return buildEntitlementHarness(db); -} - -export async function makePgEntitlementHarness(): Promise { - const connectionString = process.env.PG_CONNECTION_STRING; - if (connectionString === undefined) throw new Error("PG_CONNECTION_STRING is not set"); - const iso = await createIsolatedPgSchema(connectionString, { poolMax: 4 }); - cleanups.push(() => iso.teardown()); - return buildEntitlementHarness(iso.db); -} - -// -- refunds ledger harness (ADR-0008) --------------------------------------- - -/** A Kysely order store + the adapter-agnostic `seedPaidOrder` (createFromCart → - * markPaid → recordPayment), so the refunds contract runs identically on - * sqlite/pg and the fake. `CountingIdGen` gives lexically-increasing ids so the - * `created_at ASC, id ASC` refund order IS chronological under a fixed clock. */ -export function buildRefundOrderHarness(db: Kysely): RefundOrderHarness { - const clock = new FixedClock(new Date("2026-07-10T00:00:00.000Z")); - const orderStore = new KyselyOrderStore({ db, idGen: new CountingIdGen("oi"), clock }); - return { orderStore, seedPaidOrder: buildRefundSeed(orderStore) }; -} - -export async function makeSqliteRefundOrderHarness(): Promise { - const db = makeSqliteDb(":memory:"); - await migrateToLatest(db); - cleanups.push(async () => { - await db.destroy(); - }); - return buildRefundOrderHarness(db); -} - -export async function makePgRefundOrderHarness(): Promise { - const connectionString = process.env.PG_CONNECTION_STRING; - if (connectionString === undefined) throw new Error("PG_CONNECTION_STRING is not set"); - // poolMax ≥ N so the concurrent-refund race runs on independent connections. - const iso = await createIsolatedPgSchema(connectionString, { poolMax: 40 }); - cleanups.push(() => iso.teardown()); - return buildRefundOrderHarness(iso.db); -} - -/** Like {@link makePgRefundOrderHarness} but exposes the concrete - * `KyselyOrderStore` + `db` for the concurrency race (direct seeding + reads). */ -export async function makePgRefundOrderStore(): Promise<{ - store: KyselyOrderStore; - db: Kysely; - seedPaidOrder: RefundOrderHarness["seedPaidOrder"]; -}> { - const connectionString = process.env.PG_CONNECTION_STRING; - if (connectionString === undefined) throw new Error("PG_CONNECTION_STRING is not set"); - const iso = await createIsolatedPgSchema(connectionString, { poolMax: 40 }); - cleanups.push(() => iso.teardown()); - const clock = new FixedClock(new Date("2026-07-10T00:00:00.000Z")); - const store = new KyselyOrderStore({ db: iso.db, idGen: new CountingIdGen("oi"), clock }); - return { store, db: iso.db, seedPaidOrder: buildRefundSeed(store) }; -} - -// -- order transition + email outbox harness (Phase 5 §5) -------------------- - -export function buildOrderTransitionHarness(db: Kysely): OrderTransitionHarness { - const clock = new FixedClock(new Date("2026-07-10T00:00:00.000Z")); - const store = new KyselyOrderStore({ db, idGen: new CountingIdGen("oi"), clock }); - const emailSender = new FakeEmailSender(); - return { - store, - emailSender, - clock, - // The real transition transaction, force-rolled-back (§5 atomicity case). - forceFailedTransition: (input) => store.transitionForTestRollback(input), - }; -} - -export async function makeSqliteOrderTransitionHarness(): Promise { - const db = makeSqliteDb(":memory:"); - await migrateToLatest(db); - cleanups.push(async () => { - await db.destroy(); - }); - return buildOrderTransitionHarness(db); -} - -export async function makePgOrderTransitionHarness(): Promise { - const connectionString = process.env.PG_CONNECTION_STRING; - if (connectionString === undefined) throw new Error("PG_CONNECTION_STRING is not set"); - const iso = await createIsolatedPgSchema(connectionString, { poolMax: 4 }); - cleanups.push(() => iso.teardown()); - return buildOrderTransitionHarness(iso.db); -} - -// -- order notes harness (admin-UX Increment 0) ------------------------------ - -/** The concrete Kysely notes store + the FixedClock the contract's `tick()` - * advances — so sqlite, pg, and the fake exercise the identical append-order - * spec. `CountingIdGen("note")` gives lexically-increasing ids, so the - * `created_at ASC, id ASC` order the SQL emits IS append order under a fixed - * clock (mirrors `buildOrderStoreHarness`'s deterministic idGen). */ -export function buildOrderNotesStoreHarness( - db: Kysely, -): OrderNotesStoreHarness & { store: KyselyOrderNotesStore; clock: FixedClock } { - const clock = new FixedClock(new Date("2026-07-10T00:00:00.000Z")); - const store = new KyselyOrderNotesStore({ db, idGen: new CountingIdGen("note"), clock }); - return { store, clock, tick: (ms: number) => clock.advance(ms) }; -} - -export async function makeSqliteOrderNotesStoreHarness(): Promise { - const db = makeSqliteDb(":memory:"); - await migrateToLatest(db); - cleanups.push(async () => { - await db.destroy(); - }); - return buildOrderNotesStoreHarness(db); -} - -export async function makePgOrderNotesStoreHarness(): Promise< - OrderNotesStoreHarness & { store: KyselyOrderNotesStore } -> { - const connectionString = process.env.PG_CONNECTION_STRING; - if (connectionString === undefined) throw new Error("PG_CONNECTION_STRING is not set"); - // poolMax ≥ N so the concurrent-replay race runs on independent connections. - const iso = await createIsolatedPgSchema(connectionString, { poolMax: 8 }); - cleanups.push(() => iso.teardown()); - return buildOrderNotesStoreHarness(iso.db); -} - -// -- order timeline / audit harness (admin-UX Increment 1, timeline slice) ---- - -/** An order store + a notes store over ONE db, sharing a FixedClock the - * contract's `tick()` advances — so the state-change audit (order_events) and - * the notes interleave on one merged timeline, and sqlite/pg/the fake exercise - * the identical spec. `CountingIdGen` gives lexically-increasing ids so `at ASC, - * id ASC` IS chronological under a fixed clock. */ -export function buildOrderTimelineHarness(db: Kysely): OrderTimelineHarness { - const clock = new FixedClock(new Date("2026-07-10T00:00:00.000Z")); - return { - orderStore: new KyselyOrderStore({ db, idGen: new CountingIdGen("oi"), clock }), - orderNotesStore: new KyselyOrderNotesStore({ db, idGen: new CountingIdGen("note"), clock }), - tick: (ms: number) => clock.advance(ms), - }; -} - -export async function makeSqliteOrderTimelineHarness(): Promise { - const db = makeSqliteDb(":memory:"); - await migrateToLatest(db); - cleanups.push(async () => { - await db.destroy(); - }); - return buildOrderTimelineHarness(db); -} - -export async function makePgOrderTimelineHarness(): Promise { - const connectionString = process.env.PG_CONNECTION_STRING; - if (connectionString === undefined) throw new Error("PG_CONNECTION_STRING is not set"); - const iso = await createIsolatedPgSchema(connectionString, { poolMax: 8 }); - cleanups.push(() => iso.teardown()); - return buildOrderTimelineHarness(iso.db); -} diff --git a/packages/store-postgres/test/order-items-insert-batch.dialects.test.ts b/packages/store-postgres/test/order-items-insert-batch.dialects.test.ts deleted file mode 100644 index 04759971..00000000 --- a/packages/store-postgres/test/order-items-insert-batch.dialects.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { - cents, - currency, - idempotencyKey, - orderId, - productId, - reservationId, - sku, -} from "@otta-sh/domain"; -import { CountingIdGen, FixedClock } from "@otta-sh/domain/testing"; -import type { - InsertQueryNode, - KyselyPlugin, - PluginTransformQueryArgs, - PluginTransformResultArgs, - QueryResult, - RootOperationNode, - UnknownRow, -} from "kysely"; -import { afterEach, describe, expect, test } from "vitest"; -import { KyselyOrderStore, makeSqliteDb, migrateToLatest } from "../src/index.js"; -import type { Database } from "../src/schema.js"; -import { createIsolatedPgSchema } from "../src/testing.js"; -import type { Kysely } from "kysely"; - -/** - * The store-level half of the checkout write-batching guard: `createFromCart` - * with N lines must persist `order_items` in EXACTLY ONE multi-row INSERT, never - * a per-line loop of N single-row inserts. A regression fails THIS test, not just - * a review. The behavioral membership cases (all N lines persist + reload) live - * in the shared `orderStoreContract`; this file pins only the statement-count - * invariant, which the contract suite cannot see. - */ - -const USD = currency("USD"); - -/** Counts INSERT-INTO-`order_items` root statements. Kysely calls - * `transformQuery` once per executed root statement, so counting the ones whose - * target table is `order_items` yields the exact number of item-insert - * statements the create emitted. (BEGIN/COMMIT are driver-level, not routed - * through plugins, so the transaction wrapper is invisible here.) */ -class OrderItemsInsertCountingPlugin implements KyselyPlugin { - count = 0; - - transformQuery(args: PluginTransformQueryArgs): RootOperationNode { - const node = args.node; - if (node.kind === "InsertQueryNode") { - const insert = node as InsertQueryNode; - if (insert.into?.table.identifier.name === "order_items") this.count++; - } - return node; - } - - transformResult(args: PluginTransformResultArgs): Promise> { - return Promise.resolve(args.result); - } -} - -const PG_ENABLED = Boolean(process.env.PG_CONNECTION_STRING); -const cleanups: Array<() => Promise> = []; -afterEach(async () => { - for (const fn of cleanups.splice(0)) await fn(); -}); - -async function makeSqliteRawDb(): Promise> { - const db = makeSqliteDb(":memory:"); - await migrateToLatest(db); - cleanups.push(async () => { - await db.destroy(); - }); - return db; -} - -async function makePgRawDb(): Promise> { - const connectionString = process.env.PG_CONNECTION_STRING; - if (connectionString === undefined) throw new Error("PG_CONNECTION_STRING is not set"); - const iso = await createIsolatedPgSchema(connectionString, { poolMax: 4 }); - cleanups.push(() => iso.teardown()); - return iso.db; -} - -function orderItemsInsertBatchSuite( - makeDb: () => Promise>, - dialect: string, -): void { - describe(`createFromCart order_items insert count [${dialect}]`, () => { - test("a 3-line create issues exactly one order_items INSERT statement", async () => { - const db = await makeDb(); - const counter = new OrderItemsInsertCountingPlugin(); - const store = new KyselyOrderStore({ - db: db.withPlugin(counter), - idGen: new CountingIdGen("oi"), - clock: new FixedClock(new Date("2026-07-10T00:00:00.000Z")), - }); - - const { created, order } = await store.createFromCart({ - orderId: orderId("ord-batch"), - cartId: "cart-batch", - currency: USD, - idempotencyKey: idempotencyKey("key-batch"), - holdExpiresAt: "2026-07-10T00:15:00.000Z", - buyerRef: "buyer@example.com", - paymentMethod: "stripe", - lines: [ - { - productId: productId("p1"), - sku: sku("SKU-1"), - title: "Widget", - unitPrice: cents(500), - currency: USD, - quantity: 3, - fulfillmentKind: "physical", - reservationId: reservationId("res-1"), - }, - { - productId: productId("p2"), - sku: sku("SKU-2"), - title: "Gadget", - unitPrice: cents(1200), - currency: USD, - quantity: 1, - fulfillmentKind: "physical", - reservationId: reservationId("res-2"), - }, - { - productId: productId("p3"), - sku: sku("SKU-3"), - title: "Ebook", - unitPrice: cents(999), - currency: USD, - quantity: 2, - fulfillmentKind: "digital", - reservationId: null, - }, - ], - totals: { subtotal: cents(4698), total: cents(4698), currency: USD }, - }); - - expect(created).toBe(true); - expect(order.lines).toHaveLength(3); - // The batching invariant: ONE statement for N lines, not N. - expect(counter.count).toBe(1); - }); - }); -} - -orderItemsInsertBatchSuite(makeSqliteRawDb, "sqlite"); - -describe.skipIf(!PG_ENABLED)("[postgres]", () => { - orderItemsInsertBatchSuite(makePgRawDb, "pg"); -}); diff --git a/packages/store-postgres/test/order-lookup-indices.test.ts b/packages/store-postgres/test/order-lookup-indices.test.ts deleted file mode 100644 index 995223a4..00000000 --- a/packages/store-postgres/test/order-lookup-indices.test.ts +++ /dev/null @@ -1,254 +0,0 @@ -import { customerId } from "@otta-sh/domain"; -import { FixedClock } from "@otta-sh/domain/testing"; -import { type CompiledQuery, Kysely, PostgresDialect, sql } from "kysely"; -import { afterEach, describe, expect, test } from "vitest"; -import { - KyselyOrderStore, - makePostgresPool, - makeSqliteDb, - migrateToLatest, - uuidIdGen, -} from "../src/index.js"; -import type { Database, OrderStateColumn } from "../src/schema.js"; -import { createIsolatedPgSchema } from "../src/testing.js"; - -const PENDING: OrderStateColumn = "pending"; - -// Pins the two `orders` lookup indices (0022_order_lookup_indices) at the DDL -// level, AND ties them to the real predicates `KyselyOrderStore` compiles — -// not a hand-restated copy of them. A plain column index survives a -// predicate rewrite; a functional index (idx_orders_buyer_ref_lower) or a -// partial index (idx_orders_customer_id) works for exactly the predicate -// shape it was built for — if `orderFilterConditions`/`linkGuestOrders`/ -// `listForCustomer` is ever rewritten to a different fold, a `LIKE`, or a -// different NULL-handling, the index silently stops being used. A test that -// EXPLAINs its own hand-written SQL literal would not notice that (it would -// keep matching the unchanged index, not the changed production query), so -// the pg half below captures the ACTUAL compiled SQL Kysely sends for each -// real store call (via `Kysely`'s `log` hook) and EXPLAINs THAT. - -const PG = process.env.PG_CONNECTION_STRING; - -test("sqlite: both indices exist with the expected definitions", async () => { - const db = makeSqliteDb(":memory:"); - try { - await migrateToLatest(db); - const rows = await sql<{ name: string; sql: string | null }>` - select name, sql from sqlite_master - where type = 'index' and name in ('idx_orders_customer_id', 'idx_orders_buyer_ref_lower') - order by name - `.execute(db); - const defs = Object.fromEntries(rows.rows.map((r) => [r.name, r.sql])); - - expect(defs["idx_orders_buyer_ref_lower"]).toContain("lower(buyer_ref)"); - - const customerIdx = defs["idx_orders_customer_id"] ?? ""; - // Match the parenthesised COLUMN LIST specifically, not the whole - // statement — `indexOf("customer_id")` alone would also match inside the - // index NAME (`idx_orders_customer_id`), so a deliberately permuted - // `(created_at, customer_id, id)` index would satisfy a bare - // `indexOf("customer_id") < indexOf("created_at")` check even though the - // column order is wrong. Isolate `(...)` first, then check order inside it. - const columnList = /\(([^()]*)\)/.exec(customerIdx)?.[1] ?? ""; - expect(columnList).not.toBe(""); - expect(columnList).toContain("customer_id"); - expect(columnList).toContain("created_at"); - expect(columnList.indexOf("customer_id")).toBeLessThan(columnList.indexOf("created_at")); - expect(columnList.indexOf("created_at")).toBeLessThan(columnList.lastIndexOf("id")); - expect(customerIdx.toLowerCase()).toContain("where"); - expect(customerIdx.toLowerCase()).toContain("is not null"); - } finally { - await db.destroy(); - } -}); - -const cleanups: Array<() => Promise> = []; -afterEach(async () => { - for (const fn of cleanups.splice(0)) await fn(); -}); - -describe.skipIf(PG === undefined)("postgres: order lookup indices [pg]", () => { - test("both indices exist with the expected definitions, and the REAL compiled predicates use them", async () => { - if (PG === undefined) throw new Error("PG_CONNECTION_STRING is not set"); - const iso = await createIsolatedPgSchema(PG, { poolMax: 1 }); - cleanups.push(() => iso.teardown()); - - // -- 1. pin the definitions ----------------------------------------- - const idxRows = await sql<{ indexname: string; indexdef: string }>` - select indexname, indexdef from pg_indexes - where schemaname = ${iso.schema} and tablename = 'orders' - and indexname in ('idx_orders_customer_id', 'idx_orders_buyer_ref_lower') - order by indexname - `.execute(iso.db); - const defs = Object.fromEntries(idxRows.rows.map((r) => [r.indexname, r.indexdef])); - - expect(defs["idx_orders_buyer_ref_lower"]).toBe( - `CREATE INDEX idx_orders_buyer_ref_lower ON ${iso.schema}.orders USING btree (lower(buyer_ref))`, - ); - expect(defs["idx_orders_customer_id"]).toBe( - `CREATE INDEX idx_orders_customer_id ON ${iso.schema}.orders USING btree (customer_id, created_at, id) WHERE (customer_id IS NOT NULL)`, - ); - - // -- 2. a SEPARATE, logged, single-connection pool bound to the same --- - // schema. `max: 1` means every query below — seeds, the real store - // calls, and the raw EXPLAINs — shares the one physical connection, so - // a session-level `enable_seqscan = off` (cheap, low-row-count way to - // force an index path without needing thousands of rows) holds for all - // of them without needing a transaction wrapper (which `KyselyOrderStore` - // can't be constructed over — it's typed `Kysely`, not - // `Transaction`). - const capturedQueries: CompiledQuery[] = []; - const pool = makePostgresPool({ - connectionString: PG, - max: 1, - options: `-c search_path=${iso.schema}`, - }); - const loggedDb = new Kysely({ - dialect: new PostgresDialect({ pool }), - log: (event) => { - if (event.level === "query") capturedQueries.push(event.query); - }, - }); - cleanups.push(() => loggedDb.destroy()); - - await sql`set enable_seqscan = off`.execute(loggedDb); - - const now = new Date().toISOString(); - const noise = Array.from({ length: 20 }, (_, i) => ({ - id: `noise_${i}`, - cart_id: null, - currency: "USD", - state: PENDING, - idempotency_key: `idem_noise_${i}`, - hold_expires_at: now, - payment_method: null, - buyer_ref: `noise_${i}@example.com`, - customer_id: null, - reconciliation_flag: null, - created_at: now, - updated_at: now, - })); - const listForCustomerTargetId = "order_listforcustomer_target"; - const linkGuestTargetBuyerRef = "Mixed.Case.LinkGuest@Example.com"; - const unionTargetCustomerId = "cus_union_target"; - const unionTargetBuyerRef = "Mixed.Case.Union@Example.com"; - await loggedDb - .insertInto("orders") - .values([ - ...noise, - { - id: listForCustomerTargetId, - cart_id: null, - currency: "USD", - state: PENDING, - idempotency_key: "idem_listforcustomer_target", - hold_expires_at: now, - payment_method: null, - buyer_ref: "listforcustomer_target@example.com", - customer_id: "cus_listforcustomer_target", - reconciliation_flag: null, - created_at: now, - updated_at: now, - }, - { - id: "order_linkguest_target", - cart_id: null, - currency: "USD", - state: PENDING, - idempotency_key: "idem_linkguest_target", - hold_expires_at: now, - payment_method: null, - buyer_ref: linkGuestTargetBuyerRef, - customer_id: null, - reconciliation_flag: null, - created_at: now, - updated_at: now, - }, - { - id: "order_union_target", - cart_id: null, - currency: "USD", - state: PENDING, - idempotency_key: "idem_union_target", - hold_expires_at: now, - payment_method: null, - buyer_ref: unionTargetBuyerRef, - customer_id: unionTargetCustomerId, - reconciliation_flag: null, - created_at: now, - updated_at: now, - }, - ]) - .execute(); - // `listForCustomer` fans out into `#loadById`, which throws without a - // matching `order_totals` row (`executeTakeFirstOrThrow`) — only the one - // row that predicate (1) actually matches needs one. - await loggedDb - .insertInto("order_totals") - .values({ - order_id: listForCustomerTargetId, - currency: "USD", - subtotal_cents: 0, - discount_cents: 0, - shipping_cents: 0, - tax_cents: 0, - total_cents: 0, - }) - .execute(); - await sql`analyze orders`.execute(loggedDb); - - const store = new KyselyOrderStore({ - db: loggedDb, - idGen: uuidIdGen, - clock: new FixedClock(new Date("2026-08-01T00:00:00.000Z")), - }); - - async function explainCaptured(query: CompiledQuery | undefined): Promise { - expect(query, "no query was captured — did the store method run?").toBeDefined(); - const q = query as CompiledQuery; - const result = await pool.query(`explain ${q.sql}`, [...q.parameters]); - return (result.rows as Array<{ "QUERY PLAN": string }>) - .map((r) => r["QUERY PLAN"]) - .join("\n"); - } - - // -- (1) listForCustomer: `.where("customer_id", "=", customerId) - // .orderBy("created_at").orderBy("id")` — capture the FIRST query - // it issues; the row-fan-out `#loadById` calls (order_items, - // order_totals, …) that follow are irrelevant here and are issued - // strictly AFTER this one (sequential `await`s in the source). - capturedQueries.length = 0; - await store.listForCustomer(customerId("cus_listforcustomer_target")); - const listForCustomerSql = capturedQueries[0]; - expect(listForCustomerSql?.sql).toContain('"customer_id"'); - const text1 = await explainCaptured(listForCustomerSql); - expect(text1).toContain("idx_orders_customer_id"); - expect(text1).not.toContain("Sort"); - - // -- (2) linkGuestOrders: `where(sql\`lower(buyer_ref)\`, "=", - // buyerRef.toLowerCase())`. This is a single UPDATE statement — - // no fan-out — so it's the only captured query. Executes for - // real (mutates the target row); re-EXPLAINing the identical - // captured statement afterward is a pure plan lookup, not a - // second execution, so that's harmless. - capturedQueries.length = 0; - await store.linkGuestOrders(customerId("cus_irrelevant"), linkGuestTargetBuyerRef); - const linkGuestSql = capturedQueries[0]; - expect(linkGuestSql?.sql).toContain("lower(buyer_ref)"); - const text2 = await explainCaptured(linkGuestSql); - expect(text2).toContain("idx_orders_buyer_ref_lower"); - - // -- (3) orderFilterConditions customer key (via `countOrders`, the - // single-query half of the shared predicate builder): - // `customer_id = :id OR lower(buyer_ref) = lower(:buyerRef)`. - capturedQueries.length = 0; - await store.countOrders({ - customer: { customerId: unionTargetCustomerId, buyerRef: unionTargetBuyerRef }, - }); - const unionSql = capturedQueries[0]; - expect(unionSql?.sql).toContain("lower(orders.buyer_ref)"); - const text3 = await explainCaptured(unionSql); - expect(text3).toContain("idx_orders_customer_id"); - expect(text3).toContain("idx_orders_buyer_ref_lower"); - }, 30_000); -}); diff --git a/packages/store-postgres/test/order-notes-store-contract.dialects.test.ts b/packages/store-postgres/test/order-notes-store-contract.dialects.test.ts deleted file mode 100644 index f20d1fd1..00000000 --- a/packages/store-postgres/test/order-notes-store-contract.dialects.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { idempotencyKey, orderId } from "@otta-sh/domain"; -import { orderNotesStoreContract } from "@otta-sh/domain/testing"; -import { afterEach, describe, expect, test } from "vitest"; -import { PG_ENABLED } from "./describe-each-dialect.js"; -import { - makePgOrderNotesStoreHarness, - makeSqliteOrderNotesStoreHarness, - teardownOrderFlow, -} from "./order-harness.js"; - -afterEach(teardownOrderFlow); - -// The shared OrderNotesStore contract, green against the real SQL of each dialect -// (admin-UX Increment 0). SQLite verifies the DDL + queries; Postgres additionally -// runs the concurrent-replay race below (SQLite can't race). -orderNotesStoreContract(makeSqliteOrderNotesStoreHarness, { dialect: "sqlite" }); - -describe.skipIf(!PG_ENABLED)("[postgres]", () => { - orderNotesStoreContract(makePgOrderNotesStoreHarness, { dialect: "pg" }); - - // Idempotency under concurrency (Postgres-required, like the no-oversell race): - // N concurrent appends carrying the SAME idempotency_key must land EXACTLY ONE - // row — the `idempotency_key` UNIQUE + `ON CONFLICT DO NOTHING` guard makes the - // duplicate-key race resolve to a single insert, every loser reloading the same - // stored note. `better-sqlite3` serializes writes, so this is a real race only - // on pg. - test("concurrent appends with one idempotency_key insert exactly once (no duplicates)", async () => { - const h = await makePgOrderNotesStoreHarness(); - const key = idempotencyKey("race-key"); - const N = 8; - const results = await Promise.all( - Array.from({ length: N }, () => - h.store.append({ - orderId: orderId("ord-race"), - author: "concurrent", - body: "exactly one", - idempotencyKey: key, - }), - ), - ); - // Exactly one caller performed the insert; the rest observed the replay. - expect(results.filter((r) => r.appended)).toHaveLength(1); - // All callers agree on the one stored note id. - const ids = new Set(results.map((r) => r.note.id)); - expect(ids.size).toBe(1); - // And the table holds a single note for the order. - const notes = await h.store.listForOrder(orderId("ord-race")); - expect(notes).toHaveLength(1); - expect(notes[0]?.body).toBe("exactly one"); - }); -}); diff --git a/packages/store-postgres/test/order-store-contract.dialects.test.ts b/packages/store-postgres/test/order-store-contract.dialects.test.ts deleted file mode 100644 index f99dbce0..00000000 --- a/packages/store-postgres/test/order-store-contract.dialects.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { orderStoreContract } from "@otta-sh/domain/testing"; -import { afterEach, describe } from "vitest"; -import { PG_ENABLED } from "./describe-each-dialect.js"; -import { - makePgOrderStoreHarness, - makeSqliteOrderStoreHarness, - teardownOrderFlow, -} from "./order-harness.js"; - -afterEach(teardownOrderFlow); - -orderStoreContract(makeSqliteOrderStoreHarness, { dialect: "sqlite" }); - -describe.skipIf(!PG_ENABLED)("[postgres]", () => { - orderStoreContract(makePgOrderStoreHarness, { dialect: "pg" }); -}); diff --git a/packages/store-postgres/test/order-timeline-contract.dialects.test.ts b/packages/store-postgres/test/order-timeline-contract.dialects.test.ts deleted file mode 100644 index 5341fe72..00000000 --- a/packages/store-postgres/test/order-timeline-contract.dialects.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { - cents, - currency, - idempotencyKey, - orderId, - productId, - reservationId, - sku, - type CreateOrderInput, -} from "@otta-sh/domain"; -import { orderTimelineContract } from "@otta-sh/domain/testing"; -import { describe, expect, test } from "vitest"; -import { PG_ENABLED } from "./describe-each-dialect.js"; -import { - makePgOrderTimelineHarness, - makeSqliteOrderTimelineHarness, - teardownOrderFlow, -} from "./order-harness.js"; -import { afterEach } from "vitest"; - -// The order timeline / audit spec on the real adapters (admin-UX Increment 1, -// timeline slice). SQLite verifies the DDL + the state-change audit written -// inside each guarded flip + the merge read; Postgres additionally runs the -// exactly-one-event-under-concurrency races below (SQLite serializes writes, so -// it can't race). - -afterEach(teardownOrderFlow); - -orderTimelineContract(makeSqliteOrderTimelineHarness, { dialect: "sqlite" }); - -const USD = currency("USD"); - -function pendingInput(id: string, key: string): CreateOrderInput { - return { - orderId: orderId(id), - cartId: "cart-1", - currency: USD, - idempotencyKey: idempotencyKey(key), - holdExpiresAt: "2026-07-10T00:15:00.000Z", - buyerRef: "buyer@example.com", - paymentMethod: "stripe", - lines: [ - { - productId: productId("p1"), - sku: sku("SKU-1"), - title: "Widget", - unitPrice: cents(500), - currency: USD, - quantity: 1, - fulfillmentKind: "physical", - reservationId: reservationId("res-1"), - }, - ], - totals: { subtotal: cents(500), total: cents(500), currency: USD }, - }; -} - -describe.skipIf(!PG_ENABLED)("[postgres]", () => { - orderTimelineContract(makePgOrderTimelineHarness, { dialect: "pg" }); - - // Concurrency (Postgres-required, like the no-oversell race): N concurrent - // markPaid on the SAME pending order flip it EXACTLY ONCE — the guarded `WHERE - // state='pending'` UPDATE lets one caller win. The state-change audit rides - // that guarded flip transaction, so EXACTLY ONE `state_change` event is - // written — a replay/lost race is a 0-row flip and records none. This is the - // audit analogue of the outbox `UNIQUE(order_id, to_state)` exactly-once. - test("concurrent state flips write exactly one audit event (no double audit under a race)", async () => { - const h = await makePgOrderTimelineHarness(); - const id = orderId("ord-audit-race"); - await h.orderStore.createFromCart(pendingInput("ord-audit-race", "key-audit-race")); - - const N = 12; - const results = await Promise.all(Array.from({ length: N }, () => h.orderStore.markPaid(id))); - // Exactly one caller won the guarded flip; the rest are benign 0-row misses. - expect(results.filter((won) => won)).toHaveLength(1); - - const events = await h.orderStore.listEventsForOrder(id); - expect(events).toHaveLength(1); - expect(events[0]).toMatchObject({ fromState: "pending", toState: "paid" }); - }, 60_000); -}); diff --git a/packages/store-postgres/test/order-transition-contract.dialects.test.ts b/packages/store-postgres/test/order-transition-contract.dialects.test.ts deleted file mode 100644 index 4629aca4..00000000 --- a/packages/store-postgres/test/order-transition-contract.dialects.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { orderTransitionContract } from "@otta-sh/domain/testing"; -import { afterEach, describe } from "vitest"; -import { PG_ENABLED } from "./describe-each-dialect.js"; -import { - makePgOrderTransitionHarness, - makeSqliteOrderTransitionHarness, - teardownOrderFlow, -} from "./order-harness.js"; - -// The order state-machine + exactly-once-email spec on the real adapters. The -// forced-rollback atomicity case (§5) runs here (both dialects support real -// transactions) — the fake harness skips it (no transaction to roll back). - -afterEach(teardownOrderFlow); - -orderTransitionContract(makeSqliteOrderTransitionHarness, { dialect: "sqlite" }); - -describe.skipIf(!PG_ENABLED)("[postgres]", () => { - orderTransitionContract(makePgOrderTransitionHarness, { dialect: "pg" }); -}); diff --git a/packages/store-postgres/test/outbox-dispatch.dialects.test.ts b/packages/store-postgres/test/outbox-dispatch.dialects.test.ts deleted file mode 100644 index 46c6fbbd..00000000 --- a/packages/store-postgres/test/outbox-dispatch.dialects.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { - cents, - currency, - dispatchOrderEmails, - idempotencyKey, - orderId, - productId, - reservationId, - sku, - type CreateOrderInput, - type OrderStore, -} from "@otta-sh/domain"; -import { CountingIdGen, FakeEmailSender, FixedClock } from "@otta-sh/domain/testing"; -import type { Kysely } from "kysely"; -import { afterEach, describe, expect, test } from "vitest"; -import { KyselyOrderStore, makeSqliteDb, migrateToLatest } from "../src/index.js"; -import type { Database } from "../src/schema.js"; -import { createIsolatedPgSchema } from "../src/testing.js"; -import { PG_ENABLED } from "./describe-each-dialect.js"; - -// Step 5.8: the outbox dispatcher's retry + lease semantics on the real -// adapters — a crashed run's claimed row becomes claimable again once its lease -// expires; a failed send returns the row to pending for the next tick. - -const USD = currency("USD"); -const cleanups: Array<() => Promise> = []; -afterEach(async () => { - for (const fn of cleanups.splice(0)) await fn(); -}); - -interface OutboxHarness { - store: OrderStore; - emailSender: FakeEmailSender; - clock: FixedClock; -} - -function build(db: Kysely): OutboxHarness { - const clock = new FixedClock(new Date("2026-07-10T00:00:00.000Z")); - return { - store: new KyselyOrderStore({ db, idGen: new CountingIdGen("oi"), clock }), - emailSender: new FakeEmailSender(), - clock, - }; -} - -async function makeSqlite(): Promise { - const db = makeSqliteDb(":memory:"); - await migrateToLatest(db); - cleanups.push(async () => { - await db.destroy(); - }); - return build(db); -} - -async function makePg(): Promise { - const connectionString = process.env.PG_CONNECTION_STRING; - if (connectionString === undefined) throw new Error("PG_CONNECTION_STRING is not set"); - const iso = await createIsolatedPgSchema(connectionString, { poolMax: 4 }); - cleanups.push(() => iso.teardown()); - return build(iso.db); -} - -function pendingInput(): CreateOrderInput { - return { - orderId: orderId("ord-1"), - cartId: "cart-1", - currency: USD, - idempotencyKey: idempotencyKey("key-1"), - holdExpiresAt: "2026-07-10T00:15:00.000Z", - buyerRef: "buyer@example.com", - paymentMethod: "stripe", - lines: [ - { - productId: productId("p1"), - sku: sku("SKU-1"), - title: "Widget", - unitPrice: cents(500), - currency: USD, - quantity: 1, - fulfillmentKind: "physical", - reservationId: reservationId("res-1"), - }, - ], - totals: { subtotal: cents(500), total: cents(500), currency: USD }, - }; -} - -function suite(make: () => Promise, dialect: string): void { - describe(`outbox dispatcher [${dialect}]`, () => { - test("a crashed dispatcher run leaves the row claimable again after its lease expires", async () => { - const h = await make(); - await h.store.createFromCart(pendingInput()); - await h.store.markPaid(orderId("ord-1")); // enqueues one confirmation row - - const now = "2026-07-10T00:00:00.000Z"; - const lease = "2026-07-10T00:05:00.000Z"; - const first = await h.store.claimNextEmail(now, lease); - expect(first).not.toBeNull(); - - // Simulate a crash: the row is 'sending' but never marked sent. A second - // claim within the lease window finds nothing. - expect(await h.store.claimNextEmail(now, lease)).toBeNull(); - - // After the lease expires, the same row is claimable again (reclaimed). - const afterLease = "2026-07-10T00:06:00.000Z"; - const reclaimed = await h.store.claimNextEmail(afterLease, "2026-07-10T00:11:00.000Z"); - expect(reclaimed?.id).toBe(first?.id); - expect(reclaimed?.attempts).toBe(2); // incremented on each claim - }); - - test("a failed send returns the row to pending; the next dispatch delivers it exactly once", async () => { - const h = await make(); - await h.store.createFromCart(pendingInput()); - await h.store.markPaid(orderId("ord-1")); - - const deps = { orderStore: h.store, emailSender: h.emailSender, clock: h.clock }; - h.emailSender.failNextSends(1); // first send throws - expect(await dispatchOrderEmails(deps)).toBe(0); // failed → backed off, nothing delivered - // Backoff lease hasn't elapsed yet → still not claimable this tick. - expect(await dispatchOrderEmails(deps)).toBe(0); - // Next cron tick (past the backoff lease) delivers it exactly once. - h.clock.advance(10 * 60 * 1000); - expect(await dispatchOrderEmails(deps)).toBe(1); - expect(await dispatchOrderEmails(deps)).toBe(0); // no double-send - expect(h.emailSender.countByTemplate("order-confirmation", "ord-1")).toBe(1); - }); - }); -} - -suite(makeSqlite, "sqlite"); -describe.skipIf(!PG_ENABLED)("[postgres]", () => { - suite(makePg, "pg"); -}); diff --git a/packages/store-postgres/test/parse-aggregate.test.ts b/packages/store-postgres/test/parse-aggregate.test.ts deleted file mode 100644 index da738377..00000000 --- a/packages/store-postgres/test/parse-aggregate.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { parseAggregate } from "../src/kysely-reporting-store.js"; - -// Review round J4: the pg SUM/COUNT bigint→Number defense. An actual >2^53 sum -// would need millions of rows to reproduce end-to-end, so the guard is unit- -// tested directly at the coercion boundary. -describe("parseAggregate (pg bigint-string → safe integer)", () => { - test("passes through a bigint string within the safe-integer range", () => { - expect(parseAggregate("1000")).toBe(1000); - expect(parseAggregate(String(Number.MAX_SAFE_INTEGER))).toBe(Number.MAX_SAFE_INTEGER); - }); - - test("THROWS on a bigint string above Number.MAX_SAFE_INTEGER instead of silently rounding", () => { - // 2^53 + 1 — the classic value Number() would round down to 2^53. - expect(() => parseAggregate("9007199254740993")).toThrow(RangeError); - }); - - test("passes through a safe-integer number (sqlite path)", () => { - expect(parseAggregate(42)).toBe(42); - }); - - test("THROWS on a non-safe-integer number (sqlite dynamic-typing float footgun)", () => { - expect(() => parseAggregate(1.5)).toThrow(RangeError); - expect(() => parseAggregate(Number.MAX_SAFE_INTEGER + 1)).toThrow(RangeError); - }); -}); diff --git a/packages/store-postgres/test/product-commerce-batch.dialects.test.ts b/packages/store-postgres/test/product-commerce-batch.dialects.test.ts deleted file mode 100644 index 818f1b54..00000000 --- a/packages/store-postgres/test/product-commerce-batch.dialects.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { cents, currency, idempotencyKey, money, productId, sku } from "@otta-sh/domain"; -import { FixedClock } from "@otta-sh/domain/testing"; -import type { - KyselyPlugin, - PluginTransformQueryArgs, - PluginTransformResultArgs, - QueryResult, - RootOperationNode, - UnknownRow, -} from "kysely"; -import { afterEach, describe, expect, test } from "vitest"; -import { KyselyProductCommerceStore, makeSqliteDb, migrateToLatest } from "../src/index.js"; -import type { Database } from "../src/schema.js"; -import { createIsolatedPgSchema } from "../src/testing.js"; -import type { Kysely } from "kysely"; - -/** - * Phase 2 §7 step 2 invariant guard (§6 "protect this from refactoring"): - * `listCommerceByIds` issues EXACTLY ONE SQL statement for a batch of N ids, - * `inStock` included — the intra-service `product_commerce ⋈ inventory` join - * must never be split into a commerce query + a separate inventory query. A - * regression fails THIS test, not just a review. - * - * The behavioral cases themselves live in the shared - * `productCommerceStoreContract` (run per dialect by - * `product-commerce-store-contract.dialects.test.ts`); this file pins only - * the query-count invariant, which the contract suite cannot see. - */ - -/** Counts root-query executions: Kysely calls `transformQuery` once per - * executed root statement, so the count IS the statement count. */ -class QueryCountingPlugin implements KyselyPlugin { - count = 0; - - transformQuery(args: PluginTransformQueryArgs): RootOperationNode { - this.count++; - return args.node; - } - - transformResult(args: PluginTransformResultArgs): Promise> { - return Promise.resolve(args.result); - } -} - -const PG_ENABLED = Boolean(process.env.PG_CONNECTION_STRING); -const cleanups: Array<() => Promise> = []; -afterEach(async () => { - for (const fn of cleanups.splice(0)) await fn(); -}); - -async function makeSqliteRawDb(): Promise> { - const db = makeSqliteDb(":memory:"); - await migrateToLatest(db); - cleanups.push(async () => { - await db.destroy(); - }); - return db; -} - -async function makePgRawDb(): Promise> { - const connectionString = process.env.PG_CONNECTION_STRING; - if (connectionString === undefined) throw new Error("PG_CONNECTION_STRING is not set"); - const iso = await createIsolatedPgSchema(connectionString, { poolMax: 4 }); - cleanups.push(() => iso.teardown()); - return iso.db; -} - -function batchQueryCountSuite(makeDb: () => Promise>, dialect: string): void { - describe(`listCommerceByIds query count [${dialect}]`, () => { - test("listCommerceByIds issues exactly one SQL query for a batch of N ids, including inStock", async () => { - const db = await makeDb(); - const clock = new FixedClock(new Date("2026-07-10T00:00:00.000Z")); - - // Seed through an UNcounted store/connection so setup writes don't - // pollute the count. - const seedStore = new KyselyProductCommerceStore({ db, clock }); - const ids = []; - for (let i = 0; i < 25; i++) { - const pid = productId(`prod-count-${i}`); - ids.push(pid); - await seedStore.upsert( - { - productId: pid, - sku: sku(`SKU-COUNT-${i}`), - price: money(cents(100 + i), currency("USD")), - }, - idempotencyKey(`k-count-${i}`), - ); - } - // Half the skus get an inventory row (in stock), half none — so the - // single statement demonstrably carried BOTH outcomes of the join. - for (let i = 0; i < 25; i += 2) { - await db - .insertInto("inventory") - .values({ sku: `SKU-COUNT-${i}`, on_hand: 3 }) - .execute(); - } - - const counter = new QueryCountingPlugin(); - const countedStore = new KyselyProductCommerceStore({ - db: db.withPlugin(counter), - clock, - }); - - const views = await countedStore.listCommerceByIds(ids); - - expect(counter.count).toBe(1); - expect(views).toHaveLength(25); - expect(views.filter((v) => v.inStock)).toHaveLength(13); - expect(views.filter((v) => !v.inStock)).toHaveLength(12); - }); - - test("an empty id batch issues zero SQL queries", async () => { - const db = await makeDb(); - const counter = new QueryCountingPlugin(); - const store = new KyselyProductCommerceStore({ - db: db.withPlugin(counter), - clock: new FixedClock(new Date("2026-07-10T00:00:00.000Z")), - }); - - expect(await store.listCommerceByIds([])).toEqual([]); - expect(counter.count).toBe(0); - }); - }); -} - -batchQueryCountSuite(makeSqliteRawDb, "sqlite"); - -describe.skipIf(!PG_ENABLED)("[postgres]", () => { - batchQueryCountSuite(makePgRawDb, "pg"); -}); diff --git a/packages/store-postgres/test/product-commerce-snapshot-batch.dialects.test.ts b/packages/store-postgres/test/product-commerce-snapshot-batch.dialects.test.ts deleted file mode 100644 index 228bfc71..00000000 --- a/packages/store-postgres/test/product-commerce-snapshot-batch.dialects.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { cents, currency, idempotencyKey, money, productId, sku } from "@otta-sh/domain"; -import { FixedClock } from "@otta-sh/domain/testing"; -import type { - KyselyPlugin, - PluginTransformQueryArgs, - PluginTransformResultArgs, - QueryResult, - RootOperationNode, - UnknownRow, -} from "kysely"; -import { afterEach, describe, expect, test } from "vitest"; -import { KyselyProductCommerceStore, makeSqliteDb, migrateToLatest } from "../src/index.js"; -import type { Database } from "../src/schema.js"; -import { createIsolatedPgSchema } from "../src/testing.js"; -import type { Kysely } from "kysely"; - -/** - * The store-level half of the checkout anti-N+1 guard: `getManyByProductId` - * issues EXACTLY ONE SQL statement for a batch of N ids — the bulk snapshot - * read must never fan back out into one query per id. A regression fails THIS - * test, not just a review. (The caller-level half — that `createOrderFromCart` - * and `POST /checkout/quote` actually call the bulk method once instead of - * looping `getByProductId` — is pinned in the domain's create-order test.) - * - * The behavioral cases live in the shared `productCommerceStoreContract` (run - * per dialect by `product-commerce-store-contract.dialects.test.ts`); this file - * pins only the query-count invariant, which the contract suite cannot see. - */ - -/** Counts root-query executions: Kysely calls `transformQuery` once per - * executed root statement, so the count IS the statement count. */ -class QueryCountingPlugin implements KyselyPlugin { - count = 0; - - transformQuery(args: PluginTransformQueryArgs): RootOperationNode { - this.count++; - return args.node; - } - - transformResult(args: PluginTransformResultArgs): Promise> { - return Promise.resolve(args.result); - } -} - -const PG_ENABLED = Boolean(process.env.PG_CONNECTION_STRING); -const cleanups: Array<() => Promise> = []; -afterEach(async () => { - for (const fn of cleanups.splice(0)) await fn(); -}); - -async function makeSqliteRawDb(): Promise> { - const db = makeSqliteDb(":memory:"); - await migrateToLatest(db); - cleanups.push(async () => { - await db.destroy(); - }); - return db; -} - -async function makePgRawDb(): Promise> { - const connectionString = process.env.PG_CONNECTION_STRING; - if (connectionString === undefined) throw new Error("PG_CONNECTION_STRING is not set"); - const iso = await createIsolatedPgSchema(connectionString, { poolMax: 4 }); - cleanups.push(() => iso.teardown()); - return iso.db; -} - -function snapshotBatchQueryCountSuite( - makeDb: () => Promise>, - dialect: string, -): void { - describe(`getManyByProductId query count [${dialect}]`, () => { - test("getManyByProductId issues exactly one SQL query for a batch of N ids", async () => { - const db = await makeDb(); - const clock = new FixedClock(new Date("2026-07-10T00:00:00.000Z")); - - // Seed through an UNcounted store so setup writes don't pollute the count. - const seedStore = new KyselyProductCommerceStore({ db, clock }); - const ids = []; - for (let i = 0; i < 10; i++) { - const pid = productId(`prod-snap-${i}`); - ids.push(pid); - await seedStore.upsert( - { - productId: pid, - sku: sku(`SKU-SNAP-${i}`), - price: money(cents(100 + i), currency("USD")), - title: `Title ${i}`, - }, - idempotencyKey(`k-snap-${i}`), - ); - } - - const counter = new QueryCountingPlugin(); - const countedStore = new KyselyProductCommerceStore({ - db: db.withPlugin(counter), - clock, - }); - - const map = await countedStore.getManyByProductId(ids); - - // The anti-N+1 invariant at the store level: ONE statement, N ids. - expect(counter.count).toBe(1); - expect(map.size).toBe(10); - }); - - test("an empty id batch issues zero SQL queries", async () => { - const db = await makeDb(); - const counter = new QueryCountingPlugin(); - const store = new KyselyProductCommerceStore({ - db: db.withPlugin(counter), - clock: new FixedClock(new Date("2026-07-10T00:00:00.000Z")), - }); - - expect((await store.getManyByProductId([])).size).toBe(0); - expect(counter.count).toBe(0); - }); - }); -} - -snapshotBatchQueryCountSuite(makeSqliteRawDb, "sqlite"); - -describe.skipIf(!PG_ENABLED)("[postgres]", () => { - snapshotBatchQueryCountSuite(makePgRawDb, "pg"); -}); diff --git a/packages/store-postgres/test/product-commerce-store-contract.dialects.test.ts b/packages/store-postgres/test/product-commerce-store-contract.dialects.test.ts deleted file mode 100644 index 0d9cf773..00000000 --- a/packages/store-postgres/test/product-commerce-store-contract.dialects.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { productCommerceStoreContract } from "@otta-sh/domain/testing"; -import { afterEach, describe } from "vitest"; -import { - makePgProductCommerceHarness, - makeSqliteProductCommerceHarness, - PG_ENABLED, - teardownDialects, -} from "./describe-each-dialect.js"; - -// The SAME reusable contract suite (Phase 1 step 3) runs against every DB -// dialect (step 4): SQLite always, Postgres only when PG_CONNECTION_STRING is -// set. -afterEach(teardownDialects); - -productCommerceStoreContract(makeSqliteProductCommerceHarness, { dialect: "sqlite" }); - -describe.skipIf(!PG_ENABLED)("[postgres]", () => { - productCommerceStoreContract(makePgProductCommerceHarness, { dialect: "pg" }); -}); diff --git a/packages/store-postgres/test/refund-order-contract.dialects.test.ts b/packages/store-postgres/test/refund-order-contract.dialects.test.ts deleted file mode 100644 index 6e2866f5..00000000 --- a/packages/store-postgres/test/refund-order-contract.dialects.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { refundOrderContract } from "@otta-sh/domain/testing"; -import { afterEach, describe } from "vitest"; -import { PG_ENABLED } from "./describe-each-dialect.js"; -import { - makePgRefundOrderHarness, - makeSqliteRefundOrderHarness, - teardownOrderFlow, -} from "./order-harness.js"; - -// The refunds spec (ADR-0008) on the real adapters. SQLite verifies the DDL + the -// ceiling-guarded ledger write + the full-refund flip compose; Postgres runs the -// same spec AND the concurrency races (see refund-race.pg.test.ts — SQLite -// serializes writes, so it can't race). - -afterEach(teardownOrderFlow); - -refundOrderContract(makeSqliteRefundOrderHarness, { dialect: "sqlite" }); - -describe.skipIf(!PG_ENABLED)("[postgres]", () => { - refundOrderContract(makePgRefundOrderHarness, { dialect: "pg" }); -}); diff --git a/packages/store-postgres/test/refund-race.pg.test.ts b/packages/store-postgres/test/refund-race.pg.test.ts deleted file mode 100644 index bb687bfb..00000000 --- a/packages/store-postgres/test/refund-race.pg.test.ts +++ /dev/null @@ -1,400 +0,0 @@ -import { cancelOrder, currency, idempotencyKey, refundOrder, cents } from "@otta-sh/domain"; -import type { - ClientAction, - ConfirmationResult, - CreateIntentInput, - PaymentGateway, - PaymentIntentHandle, - RawConfirmation, - RefundInput, - RefundResult, -} from "@otta-sh/domain"; -import { FakePaymentGateway } from "@otta-sh/domain/testing"; -import { afterEach, describe, expect, test } from "vitest"; -import { makePgRefundOrderStore, teardownOrderFlow } from "./order-harness.js"; - -// Money movement under concurrency (Postgres-required, like no-oversell): the -// refunds ledger ceiling `Σ refunds ≤ min(Σ captured, total)` must hold under -// EVERY interleaving of N racing refunds. `recordRefund` locks the order row and -// re-reads the sums inside one transaction, so concurrent refunds serialize and -// none over-shoots. SQLite serializes writes globally, so these can't race there. - -const PG = process.env.PG_CONNECTION_STRING; -const USD = currency("USD"); - -afterEach(teardownOrderFlow); - -// A record-only (manual, refundable:false) gateway keeps the race a PURE test of -// the ledger arbiter — no external gateway calls interleave. -function manualGw(): FakePaymentGateway { - return new FakePaymentGateway({ id: "x402", refundable: false }); -} - -/** - * A refundable (Stripe-shaped) gateway with INJECTED LATENCY on `refund` — the - * seam the reserve-before-issue protocol runs across (ADR-0008). Every `refund` - * is counted, so a race can assert the provider is called ONLY after a committed - * reservation (never "issued without a row"). The latency widens the window - * between reserve and finalize so N winners' issue+finalize legs genuinely - * interleave. Records the peak concurrent in-flight issues to prove the arbiter - * — not the gateway — is what bounds issuance. - */ -class LatencyRefundGateway implements PaymentGateway { - readonly id = "stripe" as const; - readonly refundable = true; - #delayMs: number; - issueCount = 0; - inFlight = 0; - peakInFlight = 0; - - constructor(delayMs: number) { - this.#delayMs = delayMs; - } - - async refund(input: RefundInput): Promise { - this.issueCount += 1; - this.inFlight += 1; - this.peakInFlight = Math.max(this.peakInFlight, this.inFlight); - try { - await new Promise((r) => setTimeout(r, this.#delayMs)); - return { - ok: true, - refundRef: `re_${input.idempotencyKey}`, - amount: input.amount, - currency: input.currency, - }; - } finally { - this.inFlight -= 1; - } - } - - // Unused by the refund path — the race never drives money-in. - async createIntent(input: CreateIntentInput): Promise { - const clientAction: ClientAction = { kind: "none" }; - return { gateway: this.id, intentId: `pi_${input.orderId}`, clientAction }; - } - async verifyConfirmation(_raw: RawConfirmation): Promise { - return { ok: false, reason: "MALFORMED" }; - } -} - -describe.skipIf(PG === undefined)("refund ceiling under concurrency [postgres]", () => { - test("N concurrent full refunds (each = ceiling) yield exactly ONE winner; Σ = ceiling; one → refunded event", async () => { - const h = await makePgRefundOrderStore(); - const gw = manualGw(); - const N = 24; - const id = await h.seedPaidOrder({ id: "ord-full-race", totalCents: 1000, gateway: "x402" }); - - const results = await Promise.all( - Array.from({ length: N }, (_v, i) => - refundOrder({ orderStore: h.store }, gw, { - orderId: id, - amount: cents(1000), // each caller wants the WHOLE ceiling - currency: USD, - refundedBy: `admin-${i}`, - idempotencyKey: idempotencyKey(`rf-full-${i}`), // distinct keys ⇒ real race - }), - ), - ); - - const winners = results.filter((r) => r.ok && r.recorded); - expect(winners, "exactly one winner").toHaveLength(1); - // Every loser is a typed ceiling rejection — never a silent success, never a throw. - for (const r of results) { - if (!(r.ok && r.recorded)) { - expect(r.ok).toBe(false); - if (!r.ok) expect(r.reason).toBe("REFUND_EXCEEDS_TOTAL"); - } - } - const ledger = await h.store.listRefunds(id); - expect( - ledger.reduce((s, x) => s + x.amount, 0), - "Σ never exceeds ceiling", - ).toBe(1000); - expect((await h.store.getById(id))?.state).toBe("refunded"); - const refundedEvents = (await h.store.listEventsForOrder(id)).filter( - (e) => e.toState === "refunded", - ); - expect(refundedEvents, "exactly one → refunded audit event").toHaveLength(1); - }, 60_000); - - test("N concurrent partial refunds are sum-bounded under every interleaving; the ceiling-reaching one flips → refunded", async () => { - const h = await makePgRefundOrderStore(); - const gw = manualGw(); - const LOOPS = 8; - for (let loop = 0; loop < LOOPS; loop++) { - const N = 20; // 20 × 100 = 2000 requested against a 1000 ceiling ⇒ 10 fit - const id = await h.seedPaidOrder({ - id: `ord-part-${loop}`, - totalCents: 1000, - gateway: "x402", - }); - const results = await Promise.all( - Array.from({ length: N }, (_v, i) => - refundOrder({ orderStore: h.store }, gw, { - orderId: id, - amount: cents(100), - currency: USD, - refundedBy: `admin-${i}`, - idempotencyKey: idempotencyKey(`rf-part-${loop}-${i}`), - }), - ), - ); - const recorded = results.filter((r) => r.ok && r.recorded); - const ledger = await h.store.listRefunds(id); - const sum = ledger.reduce((s, x) => s + x.amount, 0); - expect(sum, `loop ${loop}: Σ bounded at ceiling`).toBe(1000); - expect(recorded, `loop ${loop}: exactly 10 fit`).toHaveLength(10); - // The one that reached the ceiling flipped the order — exactly one → refunded. - expect((await h.store.getById(id))?.state, `loop ${loop}: refunded`).toBe("refunded"); - expect( - results.filter((r) => r.ok && r.fullyRefunded), - `loop ${loop}: exactly one fullyRefunded`, - ).toHaveLength(1); - } - }, 120_000); - - test("a same-key replay under concurrency records exactly once (no second row)", async () => { - const h = await makePgRefundOrderStore(); - const gw = manualGw(); - const N = 16; - const id = await h.seedPaidOrder({ id: "ord-idem-race", totalCents: 1000, gateway: "x402" }); - const key = idempotencyKey("rf-idem-race"); - const results = await Promise.all( - Array.from({ length: N }, (_v, i) => - refundOrder({ orderStore: h.store }, gw, { - orderId: id, - amount: cents(400), - currency: USD, - refundedBy: `admin-${i}`, - idempotencyKey: key, // SAME key ⇒ once-only - }), - ), - ); - expect(results.every((r) => r.ok)).toBe(true); - expect( - results.filter((r) => r.ok && r.recorded), - "recorded exactly once", - ).toHaveLength(1); - const ledger = await h.store.listRefunds(id); - expect(ledger, "one ledger row").toHaveLength(1); - expect(ledger[0]?.amount).toBe(400); - }, 60_000); - - // -- GATEWAY-INTERLEAVED: reserve-before-issue under a real (latent) gateway -- - // The blocker fix (ADR-0008): the ledger slot is RESERVED (atomic ceiling - // arbitration under the orders row lock) BEFORE the provider is ever called, so - // no interleaving can let money leave the gateway only for the ledger to refuse - // it. These runs inject latency into `gateway.refund` to force the reserve and - // issue+finalize legs of N racing refunds to genuinely overlap, and assert the - // money invariants hold under every interleaving. - - test("N concurrent FULL gateway refunds: the provider is called at most ONCE; never issued-without-a-row; exactly one → refunded", async () => { - const LOOPS = 12; // a flaky money race is a blocker — loop hard - for (let loop = 0; loop < LOOPS; loop++) { - const h = await makePgRefundOrderStore(); - const gw = new LatencyRefundGateway(15); - const N = 24; - const id = await h.seedPaidOrder({ id: `ord-gw-full-${loop}`, totalCents: 1000 }); - - const results = await Promise.all( - Array.from({ length: N }, (_v, i) => - refundOrder({ orderStore: h.store }, gw, { - orderId: id, - amount: cents(1000), // each wants the WHOLE ceiling - currency: USD, - refundedBy: `admin-${i}`, - idempotencyKey: idempotencyKey(`rf-gw-full-${loop}-${i}`), // distinct ⇒ real race - }), - ), - ); - - const winners = results.filter((r) => r.ok && r.recorded); - expect(winners, `loop ${loop}: exactly one winner`).toHaveLength(1); - // The CORE invariant: the provider is only ever reached AFTER a committed - // reservation, so the number of issue calls can never exceed the number of - // won reservations. For a full-ceiling race that is exactly ONE — the - // losers were rejected at reserve, BEFORE any gateway call. - expect(gw.issueCount, `loop ${loop}: never issued-without-a-row`).toBe(1); - expect(gw.peakInFlight, `loop ${loop}: arbiter (not the gateway) bounds issuance`).toBe(1); - - const ledger = await h.store.listRefunds(id); - const finalizedSum = ledger - .filter((r) => r.status === "recorded") - .reduce((s, x) => s + x.amount, 0); - const activeSum = ledger - .filter((r) => r.status !== "voided") - .reduce((s, x) => s + x.amount, 0); - expect(finalizedSum, `loop ${loop}: finalized Σ = ceiling`).toBe(1000); - expect(activeSum, `loop ${loop}: Σ(finalized+reserved) never exceeds ceiling`).toBe(1000); - expect((await h.store.getById(id))?.state, `loop ${loop}: refunded`).toBe("refunded"); - const refundedEvents = (await h.store.listEventsForOrder(id)).filter( - (e) => e.toState === "refunded", - ); - expect(refundedEvents, `loop ${loop}: exactly one → refunded event`).toHaveLength(1); - await teardownOrderFlow(); - } - }, 180_000); - - test("N concurrent PARTIAL gateway refunds interleave: issues == winners (never orphaned); Σ(active) bounded; one flip", async () => { - const LOOPS = 12; - for (let loop = 0; loop < LOOPS; loop++) { - const h = await makePgRefundOrderStore(); - const gw = new LatencyRefundGateway(10); - const N = 20; // 20 × 100 = 2000 requested vs a 1000 ceiling ⇒ exactly 10 fit - const id = await h.seedPaidOrder({ id: `ord-gw-part-${loop}`, totalCents: 1000 }); - - const results = await Promise.all( - Array.from({ length: N }, (_v, i) => - refundOrder({ orderStore: h.store }, gw, { - orderId: id, - amount: cents(100), - currency: USD, - refundedBy: `admin-${i}`, - idempotencyKey: idempotencyKey(`rf-gw-part-${loop}-${i}`), - }), - ), - ); - - const recorded = results.filter((r) => r.ok && r.recorded); - expect(recorded, `loop ${loop}: exactly 10 fit`).toHaveLength(10); - // Never issued-without-a-row AND never a row-without-issue: on the gateway - // path each winner reserves → issues → finalizes exactly once, so the count - // of provider calls equals the count of winners. Losers never touched it. - expect(gw.issueCount, `loop ${loop}: issues == winners (no orphaned issue)`).toBe(10); - - const ledger = await h.store.listRefunds(id); - const finalizedSum = ledger - .filter((r) => r.status === "recorded") - .reduce((s, x) => s + x.amount, 0); - const activeSum = ledger - .filter((r) => r.status !== "voided") - .reduce((s, x) => s + x.amount, 0); - expect(activeSum, `loop ${loop}: Σ(finalized+reserved) bounded at ceiling`).toBe(1000); - expect(finalizedSum, `loop ${loop}: finalized Σ = ceiling`).toBe(1000); - expect((await h.store.getById(id))?.state, `loop ${loop}: refunded`).toBe("refunded"); - expect( - results.filter((r) => r.ok && r.fullyRefunded), - `loop ${loop}: exactly one fullyRefunded`, - ).toHaveLength(1); - await teardownOrderFlow(); - } - }, 180_000); - - test("a TERMINAL gateway leg voids its reservation, RELEASING capacity for a concurrent winner; a HELD (unverified) one does not", async () => { - const LOOPS = 10; - for (let loop = 0; loop < LOOPS; loop++) { - const h = await makePgRefundOrderStore(); - // A gateway that fails the FIRST issue TERMINAL (voids → releases capacity) - // and succeeds the rest, with latency so the release races a live winner. - let calls = 0; - const gw: PaymentGateway = { - id: "stripe", - refundable: true, - async refund(input: RefundInput): Promise { - const mine = ++calls; - await new Promise((r) => setTimeout(r, 12)); - if (mine === 1) return { ok: false, reason: "TERMINAL" }; - return { - ok: true, - refundRef: `re_${input.idempotencyKey}`, - amount: input.amount, - currency: input.currency, - }; - }, - async createIntent(input: CreateIntentInput): Promise { - return { - gateway: "stripe", - intentId: `pi_${input.orderId}`, - clientAction: { kind: "none" }, - }; - }, - async verifyConfirmation(): Promise { - return { ok: false, reason: "MALFORMED" }; - }, - }; - const id = await h.seedPaidOrder({ id: `ord-gw-void-${loop}`, totalCents: 1000 }); - - // Two full-ceiling refunds race. Exactly one wins the RESERVATION; if that - // winner's issue is the TERMINAL one, it voids (releases capacity) — but the - // OTHER caller already lost the reservation, so it cannot re-win here. This - // asserts the arbiter never lets Σ(active) exceed the ceiling regardless of - // which leg voided. - const [a, b] = await Promise.all([ - refundOrder({ orderStore: h.store }, gw, { - orderId: id, - amount: cents(1000), - currency: USD, - refundedBy: "admin-a", - idempotencyKey: idempotencyKey(`rf-gw-void-${loop}-a`), - }), - refundOrder({ orderStore: h.store }, gw, { - orderId: id, - amount: cents(1000), - currency: USD, - refundedBy: "admin-b", - idempotencyKey: idempotencyKey(`rf-gw-void-${loop}-b`), - }), - ]); - const ledger = await h.store.listRefunds(id); - const activeSum = ledger - .filter((r) => r.status !== "voided") - .reduce((s, x) => s + x.amount, 0); - expect(activeSum, `loop ${loop}: Σ(active) never exceeds ceiling`).toBeLessThanOrEqual(1000); - // The two settle to distinct fates — never both recorded, never both fully. - const fullies = [a, b].filter((r) => r.ok && r.fullyRefunded); - expect(fullies.length, `loop ${loop}: at most one → refunded`).toBeLessThanOrEqual(1); - // After a released (voided) reservation, a FRESH refund can reclaim the - // capacity — proving the void truly released it. - if (activeSum === 0) { - const reclaim = await refundOrder({ orderStore: h.store }, new LatencyRefundGateway(0), { - orderId: id, - amount: cents(1000), - currency: USD, - refundedBy: "admin-reclaim", - idempotencyKey: idempotencyKey(`rf-gw-void-${loop}-reclaim`), - }); - expect( - reclaim.ok && reclaim.fullyRefunded, - `loop ${loop}: voided capacity reclaimable`, - ).toBe(true); - } - await teardownOrderFlow(); - } - }, 120_000); - - test("refund-vs-cancel: the order is never BOTH refunded and cancelled; Σ stays bounded", async () => { - const h = await makePgRefundOrderStore(); - const gw = manualGw(); - const LOOPS = 10; - for (let loop = 0; loop < LOOPS; loop++) { - const id = await h.seedPaidOrder({ id: `ord-vs-${loop}`, totalCents: 1000, gateway: "x402" }); - const [refund, cancel] = await Promise.all([ - refundOrder({ orderStore: h.store }, gw, { - orderId: id, - amount: cents(1000), // a FULL refund → would flip to refunded - currency: USD, - refundedBy: "refunder", - idempotencyKey: idempotencyKey(`rf-vs-${loop}`), - }), - cancelOrder( - { orderStore: h.store }, - { - orderId: id, - reason: "customer_request", - cancelledBy: "canceller", - idempotencyKey: idempotencyKey(`cx-vs-${loop}`), - }, - ), - ]); - const state = (await h.store.getById(id))?.state; - // The order settles on exactly ONE terminal state — never a torn "both". - expect(["refunded", "cancelled", "paid"], `loop ${loop}`).toContain(state); - const sum = (await h.store.listRefunds(id)).reduce((s, x) => s + x.amount, 0); - expect(sum, `loop ${loop}: Σ bounded`).toBeLessThanOrEqual(1000); - // If the cancel won the state, the refund never flipped to refunded. - if (state === "cancelled") expect(refund.ok && refund.fullyRefunded).not.toBe(true); - if (state === "refunded") expect(cancel.ok && cancel.cancelled).not.toBe(true); - } - }, 120_000); -}); diff --git a/packages/store-postgres/test/reporting.contract.dialects.test.ts b/packages/store-postgres/test/reporting.contract.dialects.test.ts deleted file mode 100644 index 903f2e40..00000000 --- a/packages/store-postgres/test/reporting.contract.dialects.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { reportingStoreContract } from "@otta-sh/domain/testing"; -import { afterEach } from "vitest"; -import { - makePgReportingHarness, - makeSqliteReportingHarness, - PG_ENABLED, - teardownDialects, -} from "./describe-each-dialect.js"; - -// Phase 7 §7 Step 3: the shared ReportingStore contract on BOTH dialects — -// SQLite always, Postgres when PG_CONNECTION_STRING is set (CI). -afterEach(teardownDialects); - -reportingStoreContract(makeSqliteReportingHarness, { dialect: "sqlite" }); -if (PG_ENABLED) reportingStoreContract(makePgReportingHarness, { dialect: "postgres" }); diff --git a/packages/store-postgres/test/reporting.seeded.test.ts b/packages/store-postgres/test/reporting.seeded.test.ts deleted file mode 100644 index 9acc1e20..00000000 --- a/packages/store-postgres/test/reporting.seeded.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { - EXPECTED_ORDERS_BY_STATUS, - EXPECTED_REVENUE_BY_DAY, - EXPECTED_SUM_ALL, - EXPECTED_SUM_EXCLUDING_CANCELLED_REFUNDED, - EXPECTED_TOP_BY_QUANTITY, - EXPECTED_TOP_BY_REVENUE, - EXPECTED_TOTAL_REVENUE, - FIXTURE_INVENTORY, - FIXTURE_ITEMS, - FIXTURE_ORDERS, - FIXTURE_REFUNDS, - REPORTING_WINDOW, - type ReportingStoreHarness, -} from "@otta-sh/domain/testing"; -import { afterEach, describe, expect, test } from "vitest"; -import { - makePgReportingHarness, - makeSqliteReportingHarness, - PG_ENABLED, - teardownDialects, -} from "./describe-each-dialect.js"; - -afterEach(teardownDialects); - -async function seeded(make: () => Promise): Promise { - const h = await make(); - for (const o of FIXTURE_ORDERS) await h.seedOrder(o); - for (const it of FIXTURE_ITEMS) await h.seedOrderItem(it); - for (const inv of FIXTURE_INVENTORY) await h.seedInventory(inv); - for (const r of FIXTURE_REFUNDS) await h.seedRefund(r); - return h; -} - -// Deterministic LCG so the "large randomized cents" property is reproducible. -function makeRng(seed: number): () => number { - let s = seed >>> 0; - return () => { - s = (s * 1_664_525 + 1_013_904_223) >>> 0; - return s; - }; -} - -// The phase's NAMED headline test, on both dialects. -function suite(make: () => Promise, dialect: string): void { - describe(`reporting seeded aggregates [${dialect}]`, () => { - test("reporting queries return correct aggregates over seeded orders", async () => { - const { store } = await seeded(make); - - // Revenue: exact hand-computed buckets, grouped by currency, day buckets. - const revenue = await store.revenueByPeriod(REPORTING_WINDOW, "day"); - expect(revenue).toEqual(EXPECTED_REVENUE_BY_DAY); - - // Revenue is the allow-list sum — provably NOT "sum everything" and NOT - // "sum excluding only cancelled/refunded" (which still counts - // pending/failed/expired). Revenue reads order_totals.total_cents, never a - // column on `orders` (it has none). - const total = revenue.reduce((s, b) => s + b.revenueCents, 0); - expect(total).toBe(EXPECTED_TOTAL_REVENUE); - expect(total).not.toBe(EXPECTED_SUM_ALL); - expect(total).not.toBe(EXPECTED_SUM_EXCLUDING_CANCELLED_REFUNDED); - - // Orders-by-status counts EVERY state, including expired. - expect(await store.ordersByStatus(REPORTING_WINDOW)).toEqual(EXPECTED_ORDERS_BY_STATUS); - expect( - (await store.ordersByStatus(REPORTING_WINDOW)).find((s) => s.status === "expired"), - ).toEqual({ status: "expired", orderCount: 1 }); - - // Top-products uses the order_items snapshot (no product_commerce rows were - // ever seeded), same allow-list — excluded orders' items never count. - expect(await store.topProducts(REPORTING_WINDOW, "revenue", 10)).toEqual( - EXPECTED_TOP_BY_REVENUE, - ); - expect(await store.topProducts(REPORTING_WINDOW, "quantity", 10)).toEqual( - EXPECTED_TOP_BY_QUANTITY, - ); - expect(await store.topProducts(REPORTING_WINDOW, "revenue", 2)).toEqual( - EXPECTED_TOP_BY_REVENUE.slice(0, 2), - ); - - // Low-stock straddles the threshold. NO `product_commerce` rows were - // seeded here at all, so every title is null — which doubles as a pin - // that the title join is a LEFT join: an unmatched sku still lists. - expect(await store.lowStock(5)).toEqual([ - { sku: "SKU-A", onHand: 0, title: null }, - { sku: "SKU-B", onHand: 3, title: null }, - { sku: "SKU-C", onHand: 5, title: null }, - { sku: "SKU-E", onHand: 5, title: null }, - ]); - }); - - test("revenue SUM stays an exact integer under a large randomized set of cents (no float drift)", async () => { - const h = await make(); - const rng = makeRng(0xc0ffee); - const N = 250; - // Each amount < 2.1e9 to fit the `integer` (int4) total_cents column; - // Σ over 250 orders < 5e11, safely within Number.MAX_SAFE_INTEGER, so the - // exact integer sum is representable (pg SUM(int4) widens to int8/bigint). - let expected = 0; - for (let i = 0; i < N; i++) { - const amount = 1 + (rng() % 2_000_000_000); - expected += amount; - await h.seedOrder({ - id: `r${i}`, - state: "paid", - currency: "USD", - createdAt: "2026-07-10T12:00:00.000Z", - totalCents: amount, - }); - } - const buckets = await h.store.revenueByPeriod(REPORTING_WINDOW, "day"); - expect(buckets).toHaveLength(1); - expect(buckets[0]?.revenueCents).toBe(expected); - expect(Number.isSafeInteger(buckets[0]?.revenueCents ?? NaN)).toBe(true); - // The union's zero-filled other half must not perturb the sum, and an - // unrefunded set reports zero refunded — a fact, not a gap. - expect(buckets[0]?.refundedCents).toBe(0); - }); - }); -} - -suite(makeSqliteReportingHarness, "sqlite"); -if (PG_ENABLED) suite(makePgReportingHarness, "postgres"); diff --git a/packages/store-postgres/test/reserve-cart-line-crash.dialects.test.ts b/packages/store-postgres/test/reserve-cart-line-crash.dialects.test.ts deleted file mode 100644 index eaabf650..00000000 --- a/packages/store-postgres/test/reserve-cart-line-crash.dialects.test.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { - addLine, - createCart, - currency, - expireHolds, - getCart, - idempotencyKey, - removeLine, - sku, -} from "@otta-sh/domain"; -import { afterEach, describe, expect, test } from "vitest"; -import { - type CartDialectHarness, - makePgCartHarness, - makeSqliteCartHarness, - PG_ENABLED, - teardownDialects, -} from "./describe-each-dialect.js"; - -// C4 (required, §7/§8 Risk 2) — the reserve↔cart-line crash window against real -// stores. Seed a held reservation with no cart line (a real process kill is not -// reproducible in CI) and assert idempotent-replay healing + TTL/sweep reclaim. -afterEach(teardownDialects); - -const USD = currency("USD"); - -/** Insert a `held` reservation and apply its decrement — the state after an - * add-to-cart that claimed its mutation key, reserved, and crashed before the - * cart-line write. The `cart_mutations` claim row (written BEFORE the reserve - * in the ledger-first choreography) is what marks the dangling hold - * cart-originated, so the sweep may reap it; a raw reserve has no claim and is - * never reaped (see hold-expiry.dialects.test.ts). */ -async function seedCrashedHold( - h: CartDialectHarness, - opts: { - id: string; - cartId: string; - sku: string; - qty: number; - key: string; - createdAt: string; - onHandAfter: number; - }, -): Promise { - await h.db - .insertInto("cart_mutations") - .values({ - idempotency_key: opts.key, - cart_id: opts.cartId, - line_id: null, - kind: "add", - resulting_qty: null, - completed: 0, - created_at: opts.createdAt, - }) - .execute(); - await h.db - .insertInto("reservations") - .values({ - id: opts.id, - sku: opts.sku, - qty: opts.qty, - state: "held", - idempotency_key: opts.key, - created_at: opts.createdAt, - expires_at: null, - }) - .execute(); - await h.db - .updateTable("inventory") - .set({ on_hand: opts.onHandAfter }) - .where("sku", "=", opts.sku) - .execute(); -} - -function runCrashWindow(make: () => Promise, dialect: string): void { - describe(`reserve ↔ cart-line crash window [${dialect}]`, () => { - test("a replayed add heals the missing line without a second decrement", async () => { - const h = await make(); - await h.seedStock("SKU-1", 5); - const cartId = await createCart(h.deps, USD); - await seedCrashedHold(h, { - id: "res-crash-1", - cartId, - sku: "SKU-1", - qty: 2, - key: "k1", - createdAt: h.clock.now().toISOString(), - onHandAfter: 3, - }); - - const replay = await addLine(h.deps, cartId, sku("SKU-1"), null, 2, idempotencyKey("k1")); - expect(replay.ok).toBe(true); - if (!replay.ok) return; - expect(replay.line.reservationId).toBe("res-crash-1"); - expect(await h.onHand("SKU-1")).toBe(3); // still exactly one decrement - expect((await getCart(h.deps, cartId))?.lines).toHaveLength(1); - }); - - test("an unreplayed dangling hold is reclaimed by the sweep once its TTL passes", async () => { - const h = await make(); - await h.seedStock("SKU-1", 5); - const cartId = await createCart(h.deps, USD); - // created_at older than the TTL so the NULL-expires fallback reaps it. - const stale = new Date(h.clock.now().getTime() - 20 * 60 * 1000).toISOString(); - await seedCrashedHold(h, { - id: "res-crash-2", - cartId, - sku: "SKU-1", - qty: 2, - key: "k1", - createdAt: stale, - onHandAfter: 3, - }); - - expect(await expireHolds(h.deps)).toBe(1); - expect(await h.onHand("SKU-1")).toBe(5); - const res = await h.db - .selectFrom("reservations") - .select("state") - .where("id", "=", "res-crash-2") - .executeTakeFirst(); - expect(res?.state).toBe("released"); - }); - - test("a late add replay after the sweep reaped its crashed hold does not resurrect a line (HOLD_EXPIRED)", async () => { - const h = await make(); - await h.seedStock("SKU-1", 5); - const cartId = await createCart(h.deps, USD); - // Crashed add: claim + held reservation, no cart line; TTL long past. - const stale = new Date(h.clock.now().getTime() - 20 * 60 * 1000).toISOString(); - await seedCrashedHold(h, { - id: "res-crash-3", - cartId, - sku: "SKU-1", - qty: 2, - key: "k1", - createdAt: stale, - onHandAfter: 3, - }); - - // The sweep reaps the dangling hold and returns its stock. - expect(await expireHolds(h.deps)).toBe(1); - expect(await h.onHand("SKU-1")).toBe(5); - - // The ORIGINAL key finally replays: reserve resolves the released hold - // as ok (Phase-0 replay-by-state), but the `state='held'`-scoped attach - // guard matches 0 rows — no visible line over dead stock, typed failure. - const late = await addLine(h.deps, cartId, sku("SKU-1"), null, 2, idempotencyKey("k1")); - expect(late).toEqual({ ok: false, reason: "HOLD_EXPIRED" }); - expect((await getCart(h.deps, cartId))?.lines).toHaveLength(0); - expect(await h.onHand("SKU-1")).toBe(5); // stock unchanged - const res = await h.db - .selectFrom("reservations") - .select("state") - .where("id", "=", "res-crash-3") - .executeTakeFirst(); - expect(res?.state).toBe("released"); - }); - - test("a remove that crashed after release is healed on replay: line removed, stock returned exactly once", async () => { - const h = await make(); - await h.seedStock("SKU-1", 5); - const cartId = await createCart(h.deps, USD); - const add = await addLine(h.deps, cartId, sku("SKU-1"), null, 2, idempotencyKey("k1")); - if (!add.ok) throw new Error("add must succeed"); - const reservationId = add.line.reservationId ?? ""; - expect(await h.onHand("SKU-1")).toBe(3); - - // Crash simulation: the remove's `release` landed (stock returned, - // reservation `released`) but the line delete never ran. - await h.deps.inventoryStore.release(reservationId); - expect(await h.onHand("SKU-1")).toBe(5); - expect((await getCart(h.deps, cartId))?.lines).toHaveLength(1); - - // The replay finds the line with a `released` reservation and COMPLETES - // the removal — never a spurious LINE_CHECKED_OUT, never a second return. - const replay = await removeLine(h.deps, cartId, add.line.lineId, idempotencyKey("k2")); - expect(replay).toEqual({ ok: true }); - expect((await getCart(h.deps, cartId))?.lines).toHaveLength(0); - expect(await h.onHand("SKU-1")).toBe(5); // returned exactly once - }); - }); -} - -runCrashWindow(makeSqliteCartHarness, "sqlite"); -describe.skipIf(!PG_ENABLED)("[postgres]", () => { - runCrashWindow(makePgCartHarness, "pg"); -}); diff --git a/packages/store-postgres/test/resolve-reconciliation-race.pg.test.ts b/packages/store-postgres/test/resolve-reconciliation-race.pg.test.ts deleted file mode 100644 index 9e2138dd..00000000 --- a/packages/store-postgres/test/resolve-reconciliation-race.pg.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { orderId as toOrderId, idempotencyKey } from "@otta-sh/domain"; -import { CountingIdGen, FixedClock } from "@otta-sh/domain/testing"; -import type { Kysely } from "kysely"; -import { afterEach, describe, expect, test } from "vitest"; -import { KyselyOrderStore } from "../src/index.js"; -import type { Database } from "../src/schema.js"; -import { createIsolatedPgSchema } from "../src/testing.js"; - -const PG = process.env.PG_CONNECTION_STRING; - -const cleanups: Array<() => Promise> = []; -afterEach(async () => { - for (const fn of cleanups.splice(0)) await fn(); -}); - -interface Fixture { - store: KyselyOrderStore; - db: Kysely; - seedFlagged(id: string): Promise; -} - -/** A schema-isolated pg order store whose pool holds `poolMax` connections, so N - * concurrent resolves each take an INDEPENDENT connection (a real row race). */ -async function freshStore(poolMax: number): Promise { - if (PG === undefined) throw new Error("PG_CONNECTION_STRING is not set"); - const iso = await createIsolatedPgSchema(PG, { poolMax }); - cleanups.push(() => iso.teardown()); - const db = iso.db; - const clock = new FixedClock(new Date("2026-07-10T00:00:00.000Z")); - const store = new KyselyOrderStore({ db, idGen: new CountingIdGen("oi"), clock }); - return { - store, - db, - async seedFlagged(id) { - await db - .insertInto("orders") - .values({ - id, - cart_id: null, - currency: "USD", - state: "paid", - idempotency_key: `seed-${id}`, - hold_expires_at: "2026-07-10T00:00:00.000Z", - payment_method: "stripe", - buyer_ref: "buyer@example.com", - customer_id: null, - reconciliation_flag: "commit lost for reservation res-1", - created_at: "2026-07-10T00:00:00.000Z", - updated_at: "2026-07-10T00:00:00.000Z", - }) - .execute(); - await db - .insertInto("order_totals") - .values({ - order_id: id, - currency: "USD", - subtotal_cents: 1000, - discount_cents: 0, - shipping_cents: 0, - tax_cents: 0, - total_cents: 1000, - applied_coupon_code: null, - shipping_method_snapshot: null, - tax_breakdown: null, - }) - .execute(); - }, - }; -} - -describe.skipIf(PG === undefined)("resolveReconciliation race [postgres]", () => { - test("N concurrent resolves on one flagged order yield exactly ONE winner; the disposition is written once — Postgres", async () => { - const N = 30; - const LOOPS = 15; - const h = await freshStore(N + 4); - - for (let loop = 0; loop < LOOPS; loop++) { - const id = `ord-race-${loop}`; - await h.seedFlagged(id); - - // Each caller carries a distinct outcome/reason so we can prove WHICH one - // the single winner persisted (only the guarded-flip winner may write). - const results = await Promise.all( - Array.from({ length: N }, (_unused, i) => - h.store.resolveReconciliation({ - orderId: toOrderId(id), - // Every caller reviewed the SAME live flag — the race is on the clear. - expectedFlag: "commit lost for reservation res-1", - outcome: i % 2 === 0 ? "fulfilled" : "refunded", - reason: `caller ${i}`, - resolvedBy: `admin-${i}`, - idempotencyKey: idempotencyKey(`res-${loop}-${i}`), - }), - ), - ); - - const winners = results.filter((r) => r.resolved); - expect(winners, `loop ${loop}: exactly one winner`).toHaveLength(1); - expect( - results.filter((r) => !r.resolved), - `loop ${loop}: losers`, - ).toHaveLength(N - 1); - - // The persisted disposition matches the winner's exactly, and the flag is - // cleared — no torn write, no double-resolve. - const after = await h.store.getById(toOrderId(id)); - expect(after?.reconciliationFlag, `loop ${loop}: flag cleared`).toBeNull(); - const wonReason = winners[0]?.order?.reconciliationResolution?.reason; - expect(after?.reconciliationResolution?.reason, `loop ${loop}: winner's reason`).toBe( - wonReason, - ); - expect(after?.reconciliationResolution?.resolvedBy).toBe( - winners[0]?.order?.reconciliationResolution?.resolvedBy, - ); - expect(after?.state, `loop ${loop}: state untouched`).toBe("paid"); - } - }, 120_000); -}); diff --git a/packages/store-postgres/test/restock-concurrency.pg.test.ts b/packages/store-postgres/test/restock-concurrency.pg.test.ts deleted file mode 100644 index 5d41c545..00000000 --- a/packages/store-postgres/test/restock-concurrency.pg.test.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { idempotencyKey } from "@otta-sh/domain"; -import { FixedClock } from "@otta-sh/domain/testing"; -import type { Kysely } from "kysely"; -import { afterEach, describe, expect, test } from "vitest"; -import { KyselyInventoryStore, uuidIdGen } from "../src/index.js"; -import type { Database } from "../src/schema.js"; -import { createIsolatedPgSchema } from "../src/testing.js"; - -// Merchant restock / removeStock must uphold the headline no-oversell invariant -// under REAL concurrency (Postgres-required, independent connections; -// better-sqlite3 serializes on one connection and cannot race). A restock is an -// unconditional commutative increment (can never oversell); a removeStock is a -// guarded decrement (WHERE on_hand >= qty) that competes for the same units a -// reserve does — neither can drive on_hand negative or honor a reservation that -// wasn't backed by real stock. - -const PG = process.env.PG_CONNECTION_STRING; - -interface PgFixture { - store: KyselyInventoryStore; - db: Kysely; - seed(sku: string, qty: number): Promise; - onHand(sku: string): Promise; -} - -const cleanups: Array<() => Promise> = []; -afterEach(async () => { - for (const fn of cleanups.splice(0)) await fn(); -}); - -/** A schema-isolated pg store whose pool can hold `poolMax` connections, so N - * concurrent movements each acquire an INDEPENDENT connection (a real race). */ -async function freshPgStore(poolMax: number): Promise { - if (PG === undefined) throw new Error("PG_CONNECTION_STRING is not set"); - const iso = await createIsolatedPgSchema(PG, { poolMax }); - cleanups.push(() => iso.teardown()); - const db = iso.db; - const store = new KyselyInventoryStore({ - db, - idGen: uuidIdGen, - clock: new FixedClock(new Date("2026-07-10T00:00:00.000Z")), - }); - return { - store, - db, - async seed(sku, qty) { - await db - .insertInto("inventory") - .values({ sku, on_hand: qty }) - .onConflict((oc) => oc.column("sku").doUpdateSet({ on_hand: qty })) - .execute(); - }, - async onHand(sku) { - const row = await db - .selectFrom("inventory") - .select("on_hand") - .where("sku", "=", sku) - .executeTakeFirst(); - return row?.on_hand ?? 0; - }, - }; -} - -describe.skipIf(PG === undefined)("restock / removeStock concurrency [postgres]", () => { - test("no oversell: a restock of +N races M reservations — successes bounded by real units, exact conservation, losers fail cleanly, never negative — Postgres", async () => { - const INITIAL = 5; - const RESTOCK = 10; - const M = 40; // reservations of 1 unit each - const LOOPS = 15; - const h = await freshPgStore(M + 8); - - for (let loop = 0; loop < LOOPS; loop++) { - await h.db.deleteFrom("reservations").execute(); - await h.seed("SKU-1", INITIAL); - - // One restock (+N) racing M single-unit reservations on INDEPENDENT - // connections. The restock only ever RAISES availability, so no - // reservation it commutes with can be pushed into oversell. - // - // NOTE the success COUNT is deliberately asserted as a RANGE, not an - // exact number: how many reservations land depends on WHEN the restock - // commits relative to them. A reservation that runs after the initial - // units are drained but BEFORE the restock commits legitimately fails - // OUT_OF_STOCK — a terminal, key-consuming outcome (R2), NOT a bug. - // "Every reservation that could fit after +N succeeds" is a timing - // assumption, not an invariant; only the bounds below hold under EVERY - // legal interleaving. (The deterministic sequenced test that follows - // pins the "restock landed ⇒ new units are reservable" liveness.) - const restockP = h.store.restock("SKU-1", RESTOCK, idempotencyKey(`rs-${loop}`)); - const reserveP = Array.from({ length: M }, (_u, i) => - h.store.reserve("SKU-1", 1, idempotencyKey(`rv-${loop}-${i}`)), - ); - const [restock, ...reserves] = await Promise.all([restockP, ...reserveP]); - - expect(restock.ok, `loop ${loop}: restock ok`).toBe(true); - const okReserves = reserves.filter((r) => r.ok).length; - const capacity = INITIAL + RESTOCK; - - // (a) NO OVERSELL — the invariant: successes can never exceed the real - // units that ever existed (initial + restocked). - expect(okReserves, `loop ${loop}: no oversell`).toBeLessThanOrEqual(Math.min(M, capacity)); - // Lower bound: a 1-unit guarded decrement only fails when on_hand = 0 at - // its moment, which requires ≥ INITIAL prior successes — so at least the - // initial units are ALWAYS honored, whatever the restock timing. - expect(okReserves, `loop ${loop}: initial units honored`).toBeGreaterThanOrEqual( - Math.min(M, INITIAL), - ); - // (c) every loser failed CLEANLY with OUT_OF_STOCK (never a throw — all - // M promises resolved into the union) and its key stays consumed. - for (const r of reserves) { - if (!r.ok) expect(r.reason, `loop ${loop}: clean failure`).toBe("OUT_OF_STOCK"); - } - - // (b) EXACT CONSERVATION — forbids both a lost restock and a phantom - // unit: final on_hand = initial + N − (successful reservations × 1). - const finalOnHand = await h.onHand("SKU-1"); - expect(finalOnHand, `loop ${loop}: conservation`).toBe(capacity - okReserves); - expect(finalOnHand, `loop ${loop}: never negative`).toBeGreaterThanOrEqual(0); - } - }, 120_000); - - test("liveness: once a restock has COMMITTED, the added units are reservable — M reservations then honor exactly min(M, initial + N) — Postgres", async () => { - const INITIAL = 5; - const RESTOCK = 10; - const M = 40; - const LOOPS = 10; - const h = await freshPgStore(M + 8); - - for (let loop = 0; loop < LOOPS; loop++) { - await h.db.deleteFrom("reservations").execute(); - await h.seed("SKU-1", INITIAL); - - // SEQUENCED, not raced: the restock is awaited (durably committed) - // BEFORE any reservation starts. Now the exact count IS an invariant: - // every unit of initial + N is visible to the guarded decrements, so - // exactly min(M, capacity) reservations must be honored — pinning that a - // landed restock is never masked by ledger locking or a stale read. - const restock = await h.store.restock("SKU-1", RESTOCK, idempotencyKey(`rs-${loop}`)); - expect(restock).toEqual({ ok: true, onHand: INITIAL + RESTOCK }); - - const reserves = await Promise.all( - Array.from({ length: M }, (_u, i) => - h.store.reserve("SKU-1", 1, idempotencyKey(`rv-${loop}-${i}`)), - ), - ); - const okReserves = reserves.filter((r) => r.ok).length; - const capacity = INITIAL + RESTOCK; - expect(okReserves, `loop ${loop}: exact honor count`).toBe(Math.min(M, capacity)); - expect(await h.onHand("SKU-1"), `loop ${loop}: conservation`).toBe(capacity - okReserves); - } - }, 120_000); - - test("concurrent restock replays (same idempotency key) add the units exactly once — Postgres", async () => { - const N = 24; - const LOOPS = 12; - const h = await freshPgStore(N + 4); - - for (let loop = 0; loop < LOOPS; loop++) { - await h.seed("SKU-1", 3); - const key = idempotencyKey(`same-restock-${loop}`); - - const results = await Promise.all( - Array.from({ length: N }, () => h.store.restock("SKU-1", 7, key)), - ); - - // Exactly-once: every racer resolves to the SAME recorded result and the - // +7 lands ONCE (3 → 10), never N times. - const first = results[0]; - if (first === undefined) throw new Error("no results"); - for (const r of results) expect(r).toEqual(first); - expect(first).toEqual({ ok: true, onHand: 10 }); - expect(await h.onHand("SKU-1"), `loop ${loop}: added once`).toBe(10); - - // One ledger row for the key. - const rows = await h.db - .selectFrom("inventory_stock_movements") - .selectAll() - .where("idempotency_key", "=", key) - .execute(); - expect(rows, `loop ${loop}: single ledger row`).toHaveLength(1); - } - }, 120_000); - - test("no oversell under removal: N guarded removals race M reservations — total units removed ≤ initial, on_hand never negative, losers fail cleanly — Postgres", async () => { - const INITIAL = 12; - const REMOVERS = 20; // removeStock of 1 unit each - const RESERVERS = 20; // reserve of 1 unit each - const LOOPS = 15; - const h = await freshPgStore(REMOVERS + RESERVERS + 8); - - for (let loop = 0; loop < LOOPS; loop++) { - await h.db.deleteFrom("reservations").execute(); - await h.seed("SKU-1", INITIAL); - - // N guarded removals AND M guarded reservations all competing for the same - // INITIAL units on independent connections. Both are `WHERE on_hand >= 1` - // decrements, so the DB serializes them and the total that succeed can - // never exceed INITIAL — no over-removal, no oversell. - const removeP = Array.from({ length: REMOVERS }, (_u, i) => - h.store.removeStock("SKU-1", 1, idempotencyKey(`rm-${loop}-${i}`)), - ); - const reserveP = Array.from({ length: RESERVERS }, (_u, i) => - h.store.reserve("SKU-1", 1, idempotencyKey(`rv-${loop}-${i}`)), - ); - const [removeResults, reserveResults] = await Promise.all([ - Promise.all(removeP), - Promise.all(reserveP), - ]); - - const removed = removeResults.filter((r) => r.ok).length; - const reserved = reserveResults.filter((r) => r.ok).length; - // Every loser fails cleanly — a removal with INSUFFICIENT_STOCK, a reserve - // with OUT_OF_STOCK; never a throw, never negative stock. - for (const r of removeResults) { - if (!r.ok) expect(r.reason, `loop ${loop}`).toBe("INSUFFICIENT_STOCK"); - } - // CONSERVATION: exactly INITIAL units are accounted for — each successful - // removal permanently retires a unit, each successful reserve holds one. - expect(removed + reserved, `loop ${loop}: total consumed = initial`).toBe(INITIAL); - expect(await h.onHand("SKU-1"), `loop ${loop}: on_hand exhausted, never negative`).toBe(0); - } - }, 120_000); - - test("concurrent removeStock replays (same idempotency key) remove the units exactly once — Postgres", async () => { - const N = 24; - const LOOPS = 12; - const h = await freshPgStore(N + 4); - - for (let loop = 0; loop < LOOPS; loop++) { - await h.seed("SKU-1", 10); - const key = idempotencyKey(`same-remove-${loop}`); - - const results = await Promise.all( - Array.from({ length: N }, () => h.store.removeStock("SKU-1", 4, key)), - ); - - const first = results[0]; - if (first === undefined) throw new Error("no results"); - for (const r of results) expect(r).toEqual(first); - expect(first).toEqual({ ok: true, onHand: 6 }); - // Removed ONCE (10 → 6), never N times, never negative. - expect(await h.onHand("SKU-1"), `loop ${loop}: removed once`).toBe(6); - } - }, 120_000); -}); diff --git a/packages/store-postgres/test/rules-cas-race.pg.test.ts b/packages/store-postgres/test/rules-cas-race.pg.test.ts deleted file mode 100644 index 7f16ef5f..00000000 --- a/packages/store-postgres/test/rules-cas-race.pg.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { Kysely } from "kysely"; -import { afterEach, describe, expect, test } from "vitest"; -import { KyselyTaxRulesStore } from "../src/index.js"; -import type { Database } from "../src/schema.js"; -import { createIsolatedPgSchema } from "../src/testing.js"; - -const PG = process.env.PG_CONNECTION_STRING; - -const cleanups: Array<() => Promise> = []; -afterEach(async () => { - for (const fn of cleanups.splice(0)) await fn(); -}); - -/** A schema-isolated pg tax store whose pool holds `poolMax` connections, so N - * concurrent edits each take an INDEPENDENT connection (a real row race). */ -async function freshStore(poolMax: number): Promise { - if (PG === undefined) throw new Error("PG_CONNECTION_STRING is not set"); - const iso = await createIsolatedPgSchema(PG, { poolMax }); - cleanups.push(() => iso.teardown()); - return new KyselyTaxRulesStore({ db: iso.db as Kysely }); -} - -/** - * The admin-edit analogue of the no-oversell contract: the money-bearing - * `updateRate` CAS on `rate_bps`. N admins all reviewed the SAME rate and each - * submit a distinct new value against the SAME `expectedRateBps` — exactly ONE - * must win, and every loser must be reported `stale` (never a silent clobber - * that loses a tax-rate edit). This is the race a single-statement guarded - * `UPDATE ... WHERE rate_bps = :expected` exists to serialize; better-sqlite3 - * cannot exercise it, so it is Postgres-required. - */ -describe.skipIf(PG === undefined)("tax-rate updateRate CAS race [postgres]", () => { - test("N concurrent edits on one rate yield exactly ONE winner; losers are stale", async () => { - const N = 24; - const LOOPS = 12; - const store = await freshStore(N + 4); - await store.createClass({ id: "standard", name: "Standard" }); - - for (let loop = 0; loop < LOOPS; loop++) { - const id = `r-${loop}`; - await store.createRate({ - id, - taxClassId: "standard", - zoneId: "z-us", - rateBps: 725, - appliesToShipping: false, - }); - - const results = await Promise.all( - Array.from({ length: N }, (_unused, i) => - store.updateRate(id, { rateBps: 800 + i, appliesToShipping: false }, 725), - ), - ); - - const winners = results.filter((r) => r.ok); - expect(winners, `loop ${loop}: exactly one winner`).toHaveLength(1); - const losers = results.filter((r) => !r.ok); - expect(losers, `loop ${loop}: N-1 losers`).toHaveLength(N - 1); - for (const l of losers) { - expect(l.ok).toBe(false); - if (!l.ok) expect(l.reason, `loop ${loop}: loser is stale`).toBe("stale"); - } - - // The persisted value is the winner's, and it moved off the expected 725. - const persisted = await store.getRate("standard", "z-us"); - const wonBps = winners[0]?.ok ? winners[0].rate.rateBps : undefined; - expect(persisted?.rateBps, `loop ${loop}: persisted == winner`).toBe(wonBps); - expect(persisted?.rateBps).not.toBe(725); - await store.deleteRate(id); - } - }, 120_000); -}); diff --git a/packages/store-postgres/test/rules-stores-contract.dialects.test.ts b/packages/store-postgres/test/rules-stores-contract.dialects.test.ts deleted file mode 100644 index fb4add25..00000000 --- a/packages/store-postgres/test/rules-stores-contract.dialects.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { - couponStoreContract, - shippingRulesStoreContract, - taxRulesStoreContract, -} from "@otta-sh/domain/testing"; -import { afterEach } from "vitest"; -import { - makePgCouponHarness, - makePgShippingHarness, - makePgTaxHarness, - makeSqliteCouponHarness, - makeSqliteShippingHarness, - makeSqliteTaxHarness, - PG_ENABLED, - teardownDialects, -} from "./describe-each-dialect.js"; - -afterEach(teardownDialects); - -// SQLite runs everywhere; Postgres only when PG_CONNECTION_STRING is present. -shippingRulesStoreContract(makeSqliteShippingHarness, { dialect: "sqlite" }); -taxRulesStoreContract(makeSqliteTaxHarness, { dialect: "sqlite" }); -couponStoreContract(makeSqliteCouponHarness, { dialect: "sqlite" }); - -if (PG_ENABLED) { - shippingRulesStoreContract(makePgShippingHarness, { dialect: "postgres" }); - taxRulesStoreContract(makePgTaxHarness, { dialect: "postgres" }); - couponStoreContract(makePgCouponHarness, { dialect: "postgres" }); -} diff --git a/packages/store-postgres/test/session-contract.dialects.test.ts b/packages/store-postgres/test/session-contract.dialects.test.ts deleted file mode 100644 index 305d0e94..00000000 --- a/packages/store-postgres/test/session-contract.dialects.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { sessionContract } from "@otta-sh/domain/testing"; -import { afterEach, describe } from "vitest"; -import { PG_ENABLED } from "./describe-each-dialect.js"; -import { - makePgSessionHarness, - makeSqliteSessionHarness, - teardownCustomers, -} from "./customer-harness.js"; - -afterEach(teardownCustomers); - -sessionContract(makeSqliteSessionHarness, { dialect: "sqlite" }); - -describe.skipIf(!PG_ENABLED)("[postgres]", () => { - sessionContract(makePgSessionHarness, { dialect: "pg" }); -}); diff --git a/packages/store-postgres/test/settings.contract.dialects.test.ts b/packages/store-postgres/test/settings.contract.dialects.test.ts deleted file mode 100644 index b5d88a9b..00000000 --- a/packages/store-postgres/test/settings.contract.dialects.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { settingsStoreContract } from "@otta-sh/domain/testing"; -import { afterEach } from "vitest"; -import { - makePgSettingsHarness, - makeSqliteSettingsHarness, - PG_ENABLED, - teardownDialects, -} from "./describe-each-dialect.js"; - -// Phase 7 §7 Step 3: the shared SettingsStore contract on BOTH dialects. -afterEach(teardownDialects); - -settingsStoreContract(makeSqliteSettingsHarness, { dialect: "sqlite" }); -if (PG_ENABLED) settingsStoreContract(makePgSettingsHarness, { dialect: "postgres" }); diff --git a/packages/store-postgres/test/sku-rename-ledger.dialects.test.ts b/packages/store-postgres/test/sku-rename-ledger.dialects.test.ts deleted file mode 100644 index d865c2b7..00000000 --- a/packages/store-postgres/test/sku-rename-ledger.dialects.test.ts +++ /dev/null @@ -1,256 +0,0 @@ -import { idempotencyKey, productId, sku } from "@otta-sh/domain"; -import { FixedClock } from "@otta-sh/domain/testing"; -import type { Kysely } from "kysely"; -import { afterEach, describe, expect, test } from "vitest"; -import { KyselyProductCommerceStore, makeSqliteDb, migrateToLatest } from "../src/index.js"; -import type { Database } from "../src/schema.js"; -import { createIsolatedPgSchema } from "../src/testing.js"; - -/** - * The sku-rename carry's AUDIT TRAIL — the pair of `inventory_stock_movements` - * rows a rename writes for the units it moved. - * - * This lives outside `productCommerceStoreContract` because the ledger is a - * STORE table, not part of the `ProductCommerceStore` port: the fake has no - * such table and nothing to say about it, so the contract suite cannot see - * these rows. It runs per dialect all the same, because the trail is a - * durability claim and only a real database can be asked whether it kept it. - */ - -const PG_ENABLED = Boolean(process.env.PG_CONNECTION_STRING); -const cleanups: Array<() => Promise> = []; -afterEach(async () => { - for (const fn of cleanups.splice(0)) await fn(); -}); - -async function makeSqliteRawDb(): Promise> { - const db = makeSqliteDb(":memory:"); - await migrateToLatest(db); - cleanups.push(async () => { - await db.destroy(); - }); - return db; -} - -async function makePgRawDb(): Promise> { - const connectionString = process.env.PG_CONNECTION_STRING; - if (connectionString === undefined) throw new Error("PG_CONNECTION_STRING is not set"); - const iso = await createIsolatedPgSchema(connectionString, { poolMax: 4 }); - cleanups.push(() => iso.teardown()); - return iso.db; -} - -/** Every ledger row for a sku, oldest first. */ -async function movements(db: Kysely, s: string) { - return db - .selectFrom("inventory_stock_movements") - .selectAll() - .where("sku", "=", s) - .orderBy("created_at") - .execute(); -} - -/** A live product on `s`, its inventory row stocked at `onHand`; returns the - * `updatedAt` watermark its next guarded edit has to pass back. */ -async function seedStocked( - db: Kysely, - store: KyselyProductCommerceStore, - id: string, - s: string, - onHand: number, -): Promise { - const row = await store.upsert( - { productId: productId(id), sku: sku(s) }, - idempotencyKey(`seed-${id}`), - ); - await db.insertInto("inventory").values({ sku: s, on_hand: onHand }).execute(); - return row.updatedAt.toISOString(); -} - -function renameLedgerSuite(makeDb: () => Promise>, dialect: string): void { - describe(`sku-rename audit trail [${dialect}]`, () => { - const clock = new FixedClock(new Date("2026-07-10T00:00:00.000Z")); - - test("a rename writes one row OUT of the source and one INTO the target, with the moved quantity on both", async () => { - const db = await makeDb(); - const store = new KyselyProductCommerceStore({ db, clock }); - const wm = await seedStocked(db, store, "prod-led", "SKU-LED-FROM", 40); - - const res = await store.updateCommerceFields( - { productId: productId("prod-led"), sku: sku("SKU-LED-TO") }, - idempotencyKey("led-rename"), - wm, - ); - expect(res.ok).toBe(true); - - const out = await movements(db, "SKU-LED-FROM"); - expect(out).toHaveLength(1); - expect(out[0]).toMatchObject({ - sku: "SKU-LED-FROM", - direction: "rename_out", - qty: 40, - outcome: "ok", - // The source is left empty, so its resulting count is 0. - result_on_hand: 0, - }); - - const into = await movements(db, "SKU-LED-TO"); - expect(into).toHaveLength(1); - expect(into[0]).toMatchObject({ - sku: "SKU-LED-TO", - direction: "rename_in", - qty: 40, - outcome: "ok", - // The target ends holding exactly what arrived. - result_on_hand: 40, - }); - - // The two rows are a PAIR: same quantity, opposite ends of one move, so - // the ledger can be read as "40 left here, 40 arrived there" rather than - // two unrelated adjustments. - expect(out[0]?.qty).toBe(into[0]?.qty); - }); - - test("the trail commits WITH the move — a refused rename leaves no ledger row behind", async () => { - const db = await makeDb(); - const store = new KyselyProductCommerceStore({ db, clock }); - const wm = await seedStocked(db, store, "prod-led-ref", "SKU-LEDR-FROM", 12); - // An occupied target: the rename is refused after the source has been - // read and locked, so the whole transaction rolls back. - await db.insertInto("inventory").values({ sku: "SKU-LEDR-TAKEN", on_hand: 3 }).execute(); - - await expect( - store.updateCommerceFields( - { productId: productId("prod-led-ref"), sku: sku("SKU-LEDR-TAKEN") }, - idempotencyKey("ledr-rename"), - wm, - ), - ).rejects.toMatchObject({ name: "SkuStockConflictError" }); - - // No move, therefore no record of one — the trail can never claim units - // travelled that did not. - expect(await movements(db, "SKU-LEDR-FROM")).toHaveLength(0); - expect(await movements(db, "SKU-LEDR-TAKEN")).toHaveLength(0); - }); - - test("an idempotent REPLAY of a rename writes no second pair", async () => { - const db = await makeDb(); - const store = new KyselyProductCommerceStore({ db, clock }); - const wm = await seedStocked(db, store, "prod-led-rep", "SKU-LEDP-FROM", 25); - const key = idempotencyKey("ledp-rename"); - const input = { productId: productId("prod-led-rep"), sku: sku("SKU-LEDP-TO") }; - - await store.updateCommerceFields(input, key, wm); - const replay = await store.updateCommerceFields(input, key, wm); - expect(replay.ok).toBe(true); - - // A replay applies no update, so it never carries, so it records - // nothing: the ledger counts MOVEMENTS, not attempts. - expect(await movements(db, "SKU-LEDP-FROM")).toHaveLength(1); - expect(await movements(db, "SKU-LEDP-TO")).toHaveLength(1); - }); - - test("a rename that carries NOTHING records nothing — an empty source is not a movement", async () => { - const db = await makeDb(); - const store = new KyselyProductCommerceStore({ db, clock }); - const wm = await seedStocked(db, store, "prod-led-zero", "SKU-LEDZ-FROM", 0); - - const res = await store.updateCommerceFields( - { productId: productId("prod-led-zero"), sku: sku("SKU-LEDZ-TO") }, - idempotencyKey("ledz-rename"), - wm, - ); - expect(res.ok).toBe(true); - - // The rename happened and the target row was claimed, but zero units - // moved. `qty > 0` is a column CHECK, so a zero-quantity entry could not - // be written even if we wanted one — and it would be a lie regardless. - expect(await movements(db, "SKU-LEDZ-FROM")).toHaveLength(0); - expect(await movements(db, "SKU-LEDZ-TO")).toHaveLength(0); - }); - - test("a ledger key collision costs the audit row, never the merchant's rename", async () => { - const db = await makeDb(); - const store = new KyselyProductCommerceStore({ db, clock }); - const wm = await seedStocked(db, store, "prod-led-col", "SKU-LEDC-FROM", 30); - - // These keys are derived from the CLIENT's idempotency key, so a caller - // can occupy one — by reusing a key across two renames of the same - // source sku, or by crafting a restock key that lands on the same - // string. Squat on the "out" key with a real movement row. - await db - .insertInto("inventory_stock_movements") - .values({ - idempotency_key: "ledc-rename:sku-rename:out:SKU-LEDC-FROM", - sku: "SKU-LEDC-FROM", - direction: "removal", - qty: 1, - outcome: "ok", - result_on_hand: 29, - created_at: "2026-07-09T00:00:00.000Z", - }) - .execute(); - - const res = await store.updateCommerceFields( - { productId: productId("prod-led-col"), sku: sku("SKU-LEDC-TO") }, - idempotencyKey("ledc-rename"), - wm, - ); - - // The rename is legal and must not be aborted by a collision in its own - // bookkeeping — a raw unique violation here would fail a correct write - // on a key the operator never chose. - expect(res.ok).toBe(true); - expect( - await db - .selectFrom("inventory") - .select("on_hand") - .where("sku", "=", "SKU-LEDC-TO") - .executeTakeFirst(), - ).toEqual({ on_hand: 30 }); - - // The squatted row is left exactly as it was — the carry's own entry is - // what gets dropped, and only that one. - const out = await movements(db, "SKU-LEDC-FROM"); - expect(out).toHaveLength(1); - expect(out[0]).toMatchObject({ direction: "removal", qty: 1 }); - - // ONLY the colliding half is lost. The other half's key was never - // squatted, so it lands normally — DO NOTHING drops the row that - // conflicts, not the whole insert, so the trail keeps what it can. - const into = await movements(db, "SKU-LEDC-TO"); - expect(into).toHaveLength(1); - expect(into[0]).toMatchObject({ direction: "rename_in", qty: 30, result_on_hand: 30 }); - }); - - test("a rename through UPSERT writes the same pair — the trail follows the column, not one writer", async () => { - const db = await makeDb(); - const store = new KyselyProductCommerceStore({ db, clock }); - await seedStocked(db, store, "prod-led-up", "SKU-LEDU-FROM", 17); - - const renamed = await store.upsert( - { productId: productId("prod-led-up"), sku: sku("SKU-LEDU-TO") }, - idempotencyKey("ledu-rename"), - ); - expect(renamed.sku).toBe("SKU-LEDU-TO"); - - // The integrator PUT moves stock exactly as the console edit does, so it - // has to leave the same record behind — an audit trail with a hole in it - // for one of the two writers is worse than none, because it reads as a - // complete history. - const out = await movements(db, "SKU-LEDU-FROM"); - expect(out).toHaveLength(1); - expect(out[0]).toMatchObject({ direction: "rename_out", qty: 17, result_on_hand: 0 }); - - const into = await movements(db, "SKU-LEDU-TO"); - expect(into).toHaveLength(1); - expect(into[0]).toMatchObject({ direction: "rename_in", qty: 17, result_on_hand: 17 }); - }); - }); -} - -renameLedgerSuite(makeSqliteRawDb, "sqlite"); - -describe.skipIf(!PG_ENABLED)("[postgres]", () => { - renameLedgerSuite(makePgRawDb, "pg"); -}); diff --git a/packages/store-postgres/test/sku-rename-race.pg.test.ts b/packages/store-postgres/test/sku-rename-race.pg.test.ts deleted file mode 100644 index 99b4f6b5..00000000 --- a/packages/store-postgres/test/sku-rename-race.pg.test.ts +++ /dev/null @@ -1,567 +0,0 @@ -import { idempotencyKey, productId, sku, SkuStockConflictError } from "@otta-sh/domain"; -import type { Kysely } from "kysely"; -import { afterEach, describe, expect, test } from "vitest"; -import { KyselyInventoryStore, KyselyProductCommerceStore, uuidIdGen } from "../src/index.js"; -import type { Database } from "../src/schema.js"; -import { createIsolatedPgSchema } from "../src/testing.js"; -import { TickingClock } from "./ticking-clock.js"; - -// THE SKU-RENAME RULE under REAL concurrency (Postgres-required, independent -// connections — better-sqlite3 serializes every writer onto one connection and -// therefore cannot race at all). -// -// The rule's whole point is that a rename MOVES units rather than stranding -// them, and a move is only safe if exactly one mover can ever win a target sku. -// Two renames aimed at one target is the case that decides it: the loser must -// fail cleanly and leave BOTH products exactly as they were, and the units must -// be conserved to the unit — never duplicated onto the target, never lost -// between the two rows. -// -// BOTH WRITERS ARE RACED, deliberately. `updateCommerceFields` is protected by -// its compare-and-set on `updated_at`; `upsert` has NO such guard, so it is the -// one whose before-read has to take the row lock itself, and the one whose -// races below would go quiet first if that lock were dropped. - -const PG = process.env.PG_CONNECTION_STRING; - -interface PgFixture { - products: KyselyProductCommerceStore; - inventory: KyselyInventoryStore; - db: Kysely; - /** A live, sku-bearing product with a stocked inventory row; returns the - * `updatedAt` watermark its next guarded edit has to pass back. */ - seedProduct(id: string, s: string, onHand: number): Promise; - onHand(s: string): Promise; - skuOf(id: string): Promise; -} - -const cleanups: Array<() => Promise> = []; -afterEach(async () => { - for (const fn of cleanups.splice(0)) await fn(); -}); - -/** A schema-isolated store whose pool holds `poolMax` connections, so the - * concurrent renames below each get an INDEPENDENT one (a real race). */ -async function freshPg(poolMax: number): Promise { - if (PG === undefined) throw new Error("PG_CONNECTION_STRING is not set"); - const iso = await createIsolatedPgSchema(PG, { poolMax }); - cleanups.push(() => iso.teardown()); - const db = iso.db; - const clock = new TickingClock("2026-07-10T00:00:00.000Z"); - const products = new KyselyProductCommerceStore({ db, clock }); - const inventory = new KyselyInventoryStore({ db, idGen: uuidIdGen, clock }); - return { - products, - inventory, - db, - async seedProduct(id, s, onHand) { - const row = await products.upsert( - { productId: productId(id), sku: sku(s) }, - idempotencyKey(`seed-${id}`), - ); - await db - .insertInto("inventory") - .values({ sku: s, on_hand: onHand }) - .onConflict((oc) => oc.column("sku").doUpdateSet({ on_hand: onHand })) - .execute(); - return row.updatedAt.toISOString(); - }, - async onHand(s) { - const row = await db - .selectFrom("inventory") - .select("on_hand") - .where("sku", "=", s) - .executeTakeFirst(); - return row?.on_hand ?? null; - }, - async skuOf(id) { - const row = await db - .selectFrom("product_commerce") - .select("sku") - .where("product_id", "=", id) - .executeTakeFirst(); - return row?.sku ?? null; - }, - }; -} - -describe.skipIf(PG === undefined)("sku rename concurrency [postgres]", () => { - test("two renames onto ONE free target: exactly one lands, the loser leaves no trace, and the units are conserved", async () => { - const LOOPS = 12; - const h = await freshPg(8); - - for (let loop = 0; loop < LOOPS; loop++) { - const a = `prod-a-${loop}`; - const b = `prod-b-${loop}`; - const skuA = `SKU-A-${loop}`; - const skuB = `SKU-B-${loop}`; - const target = `SKU-T-${loop}`; - const wmA = await h.seedProduct(a, skuA, 40); - const wmB = await h.seedProduct(b, skuB, 7); - - // Both products reach for the same, currently free, target sku on - // independent connections. Two guards can arbitrate this — the live-sku - // partial index on `product_commerce` and the rule's own inventory - // claim — and which one fires is a timing detail. What this case pins - // is the OUTCOME, whichever does: one winner, a clean loser, and every - // unit accounted for. (The claim's own contention is the fourth case - // below, where the index has nothing to say.) - const results = await Promise.allSettled([ - h.products.updateCommerceFields( - { productId: productId(a), sku: sku(target) }, - idempotencyKey(`rename-a-${loop}`), - wmA, - ), - h.products.updateCommerceFields( - { productId: productId(b), sku: sku(target) }, - idempotencyKey(`rename-b-${loop}`), - wmB, - ), - ]); - - const winners = results.filter((r) => r.status === "fulfilled"); - const losers = results.filter((r) => r.status === "rejected"); - - // (a) EXACTLY ONE renamed. Two winners would mean two products sharing - // one sku and one inventory row; zero would mean the rule deadlocked - // itself out of a legal rename. - expect(winners, `loop ${loop}: exactly one winner`).toHaveLength(1); - expect(losers, `loop ${loop}: exactly one loser`).toHaveLength(1); - - // (b) The loser failed with a TYPED domain error, never a raw - // constraint violation surfacing as a 500. Either refusal is legal - // here and which one fires is a timing detail: the live-sku partial - // index may reject the second product row before the rule is reached, - // or the rule's own claim may find the target taken. - const reason: unknown = (losers[0] as PromiseRejectedResult).reason; - expect(reason, `loop ${loop}: typed refusal`).toBeInstanceOf(Error); - expect( - ["SkuConflictError", "SkuStockConflictError"], - `loop ${loop}: typed refusal, got ${String((reason as Error).message)}`, - ).toContain((reason as Error).name); - - // (c) The loser's product is UNTOUCHED — still its own sku, still its - // own units. A partially applied rename is the failure mode that would - // leave a product pointing at stock it does not own. - const renamedA = (await h.skuOf(a)) === target; - const loserId = renamedA ? b : a; - const loserSku = renamedA ? skuB : skuA; - const loserUnits = renamedA ? 7 : 40; - const winnerUnits = renamedA ? 40 : 7; - expect(await h.skuOf(loserId), `loop ${loop}: loser keeps its sku`).toBe(loserSku); - expect(await h.onHand(loserSku), `loop ${loop}: loser keeps its units`).toBe(loserUnits); - - // (d) CONSERVATION: the target holds exactly the winner's count — not - // both counts merged, not a fresh zero beside the winner's orphaned - // units — and the winner's old row is retained, emptied. - expect(await h.onHand(target), `loop ${loop}: target holds the winner's units`).toBe( - winnerUnits, - ); - const winnerOldSku = renamedA ? skuA : skuB; - expect(await h.onHand(winnerOldSku), `loop ${loop}: source retained at zero`).toBe(0); - const total = - ((await h.onHand(target)) ?? 0) + - ((await h.onHand(winnerOldSku)) ?? 0) + - ((await h.onHand(loserSku)) ?? 0); - expect(total, `loop ${loop}: 47 units in, 47 units out`).toBe(47); - } - }, 120_000); - - test("two renames onto one ALREADY-OCCUPIED target: both refuse, and no product adopts the parked units", async () => { - const LOOPS = 12; - const h = await freshPg(8); - - for (let loop = 0; loop < LOOPS; loop++) { - const a = `occ-a-${loop}`; - const b = `occ-b-${loop}`; - const skuA = `SKU-OA-${loop}`; - const skuB = `SKU-OB-${loop}`; - const parked = `SKU-PARKED-${loop}`; - const wmA = await h.seedProduct(a, skuA, 10); - const wmB = await h.seedProduct(b, skuB, 3); - // Units parked under a sku NO live product holds — what an earlier - // rename leaves behind, and the state the rule refuses to arbitrate. - await h.db.insertInto("inventory").values({ sku: parked, on_hand: 99 }).execute(); - - const results = await Promise.allSettled([ - h.products.updateCommerceFields( - { productId: productId(a), sku: sku(parked) }, - idempotencyKey(`occ-a-${loop}`), - wmA, - ), - h.products.updateCommerceFields( - { productId: productId(b), sku: sku(parked) }, - idempotencyKey(`occ-b-${loop}`), - wmB, - ), - ]); - - // Both lose, and both lose the SAME way: the rule never picks a winner - // for a target that already has a row. - for (const r of results) { - expect(r.status, `loop ${loop}: both refuse`).toBe("rejected"); - expect( - (r as PromiseRejectedResult).reason, - `loop ${loop}: the stock refusal, not the index's`, - ).toBeInstanceOf(SkuStockConflictError); - } - - expect(await h.skuOf(a), `loop ${loop}`).toBe(skuA); - expect(await h.skuOf(b), `loop ${loop}`).toBe(skuB); - expect(await h.onHand(skuA), `loop ${loop}`).toBe(10); - expect(await h.onHand(skuB), `loop ${loop}`).toBe(3); - expect(await h.onHand(parked), `loop ${loop}: parked units untouched`).toBe(99); - } - }, 120_000); - - test("a rename racing a SEED of the target sku: the claim decides it, and the loser is still a typed refusal", async () => { - const LOOPS = 30; - const h = await freshPg(8); - // An interleaving case that only ever took ONE branch would assert half of - // what it claims and never say so. Counted, then asserted at the end. - let renameWon = 0; - let seedWon = 0; - - for (let loop = 0; loop < LOOPS; loop++) { - const id = `seed-race-${loop}`; - const from = `SKU-SR-FROM-${loop}`; - const target = `SKU-SR-TO-${loop}`; - const wm = await h.seedProduct(id, from, 40); - - // The one contention the live-sku index CANNOT arbitrate. `seedOnHand` - // is attempted on every product save, and live-sku uniqueness is a - // PARTIAL index — a soft-deleted product may still hold the target sku, - // so a sync save of that tombstone seeds the target's inventory row - // while a live product is renaming onto it. Both creators reach for the - // same row with nothing above them to serialize the attempt. - const [renamed] = await Promise.allSettled([ - h.products.updateCommerceFields( - { productId: productId(id), sku: sku(target) }, - idempotencyKey(`seed-race-${loop}`), - wm, - ), - h.inventory.seedOnHand(target, 0), - ]); - - if (renamed === undefined) throw new Error("no result"); - if (renamed.status === "rejected") seedWon++; - else renameWon++; - if (renamed.status === "rejected") { - // The seed got there first. That MUST arrive as the typed refusal — - // a naive "look, then insert" would surface the collision as a raw - // duplicate-key violation instead, i.e. a 500 where the operator - // should have been told the sku is taken. - expect(renamed.reason, `loop ${loop}: typed, never a raw constraint error`).toBeInstanceOf( - SkuStockConflictError, - ); - // …and it refused ATOMICALLY: the product kept its sku and its units. - expect(await h.skuOf(id), `loop ${loop}`).toBe(from); - expect(await h.onHand(from), `loop ${loop}`).toBe(40); - expect(await h.onHand(target), `loop ${loop}: the seed's empty row`).toBe(0); - } else { - // The rename got there first: it owns the row, and the seed that - // followed found it and left the carried units alone. - expect(renamed.value.ok, `loop ${loop}`).toBe(true); - expect(await h.skuOf(id), `loop ${loop}`).toBe(target); - expect(await h.onHand(target), `loop ${loop}: carried, not reset`).toBe(40); - expect(await h.onHand(from), `loop ${loop}: source retained at zero`).toBe(0); - } - - // Either way, 40 units in, 40 units out — never 80, never 0. - const total = ((await h.onHand(from)) ?? 0) + ((await h.onHand(target)) ?? 0); - expect(total, `loop ${loop}: conservation`).toBe(40); - } - - // Both interleavings actually happened, so both branches above were - // genuinely asserted rather than merely written down. - expect(renameWon, "the rename-first branch fired").toBeGreaterThan(0); - expect(seedWon, "the seed-first branch fired").toBeGreaterThan(0); - }, 120_000); - - // -- upsert: the writer with NO compare-and-set ------------------------- - // - // `updateCommerceFields` is guarded by its CAS on `updated_at`, so an - // interleaved write turns it into `stale` and the carry never runs on a sku - // that moved underneath it. `upsert` has no such guard: its before-read is - // the only thing standing between a concurrent rename and a carry against a - // sku the row no longer holds, which is why the read takes the row lock and - // why these three cases exist. - - test("upsert: two concurrent renames of ONE product to DIFFERENT skus chain — the second carries from the first's result, not from a stale read", async () => { - const LOOPS = 40; - const h = await freshPg(8); - let bWon = 0; - let cWon = 0; - - for (let loop = 0; loop < LOOPS; loop++) { - const id = `up-diff-${loop}`; - const from = `SKU-UD-FROM-${loop}`; - const toB = `SKU-UD-B-${loop}`; - const toC = `SKU-UD-C-${loop}`; - await h.seedProduct(id, from, 40); - - // With a plain SELECT before-read this is the silent-stranding case: the - // loser reads `from`, the winner moves the units to its own target, and - // the loser then carries an already-empty `from` to ITS target — leaving - // 40 units under a sku no product owns, with no error raised anywhere. - // - // The two calls are ALTERNATED rather than left to the scheduler. Both - // start in the same tick and the first one issued always reaches the row - // lock first, so a fixed order would exercise exactly one interleaving - // forty times over and quietly leave the other unproven. Swapping which - // is issued first drives both, deterministically. - const bFirst = loop % 2 === 0; - const renameB = () => - h.products.upsert( - { productId: productId(id), sku: sku(toB) }, - idempotencyKey(`ud-b-${loop}`), - ); - const renameC = () => - h.products.upsert( - { productId: productId(id), sku: sku(toC) }, - idempotencyKey(`ud-c-${loop}`), - ); - const results = await Promise.allSettled( - bFirst ? [renameB(), renameC()] : [renameC(), renameB()], - ); - - // Both writes are legal — they serialize rather than conflict — so both - // must succeed, and the row ends on whichever committed last. - for (const r of results) { - expect(r.status, `loop ${loop}: both upserts apply`).toBe("fulfilled"); - } - const finalSku = await h.skuOf(id); - if (finalSku === null) throw new Error(`loop ${loop}: the product lost its sku`); - expect([toB, toC], `loop ${loop}`).toContain(finalSku); - if (finalSku === toB) bWon++; - else cWon++; - - // THE ASSERTION THAT BITES: every unit is under the sku the product - // actually holds. A stale before-read parks them under the other target. - expect(await h.onHand(finalSku), `loop ${loop}: units follow the product`).toBe(40); - const orphan = finalSku === toB ? toC : toB; - expect(await h.onHand(from), `loop ${loop}: original source emptied`).toBe(0); - expect(await h.onHand(orphan), `loop ${loop}: intermediate sku emptied`).toBe(0); - const total = - ((await h.onHand(from)) ?? 0) + ((await h.onHand(toB)) ?? 0) + ((await h.onHand(toC)) ?? 0); - expect(total, `loop ${loop}: conservation`).toBe(40); - } - - // Both orderings really did run, so the conservation assertions above were - // exercised in both directions — and the row always ends on whichever - // write was issued SECOND, which is itself the claim that the second - // write read the first's result instead of a stale snapshot. - expect(bWon, "the B-last ordering occurred").toBeGreaterThan(0); - expect(cWon, "the C-last ordering occurred").toBeGreaterThan(0); - }, 120_000); - - test("upsert: two concurrent renames of one product to the SAME sku both succeed — the second sees the rename already done, not a conflict it did not cause", async () => { - const LOOPS = 20; - const h = await freshPg(8); - - for (let loop = 0; loop < LOOPS; loop++) { - const id = `up-same-${loop}`; - const from = `SKU-US-FROM-${loop}`; - const to = `SKU-US-TO-${loop}`; - await h.seedProduct(id, from, 18); - - const results = await Promise.allSettled([ - h.products.upsert( - { productId: productId(id), sku: sku(to) }, - idempotencyKey(`us-1-${loop}`), - ), - h.products.upsert( - { productId: productId(id), sku: sku(to) }, - idempotencyKey(`us-2-${loop}`), - ), - ]); - - // A stale before-read makes the second racer think it is renaming - // from → to all over again, find `to` occupied by the first, and refuse - // with a SkuStockConflictError against a conflict the operator never - // created. Read through the lock, it sees the row already at `to`, - // compares equal, and carries nothing. - for (const r of results) { - const why = r.status === "rejected" ? String((r.reason as Error).message) : ""; - expect(r.status, `loop ${loop}: no spurious refusal — ${why}`).toBe("fulfilled"); - } - expect(await h.skuOf(id), `loop ${loop}`).toBe(to); - expect(await h.onHand(to), `loop ${loop}: carried exactly once`).toBe(18); - expect(await h.onHand(from), `loop ${loop}: source emptied`).toBe(0); - } - }, 120_000); - - test("upsert RACING a CAS edit: whoever loses writes nothing, and the units are never split", async () => { - const LOOPS = 25; - const h = await freshPg(8); - let editStale = 0; - - // Warm the pool before timing anything. A first use of a connection pays - // for the TCP connect and session setup, and that cost lands on whichever - // side happens to open a fresh one — enough to change which writer reaches - // the row first. Warming makes the ordering below the usual one rather - // than a coin flip; the assertions inside the loop do not depend on it. - await Promise.all(Array.from({ length: 8 }, () => h.onHand("warm-up"))); - - for (let loop = 0; loop < LOOPS; loop++) { - const id = `up-cas-${loop}`; - const from = `SKU-UC-FROM-${loop}`; - const viaUpsert = `SKU-UC-UP-${loop}`; - const viaEdit = `SKU-UC-ED-${loop}`; - const wm = await h.seedProduct(id, from, 12); - - // The two writers with different guards, aimed at one row at the same - // moment: the integrator PUT and the console's guarded edit, renaming to - // different skus. - // - // The edit USUALLY loses, and not by luck: its before-read is a plain - // SELECT that takes no lock, so the upsert's locking read slips between - // that SELECT and the edit's guarded UPDATE whichever is issued first. - // The edit then waits, the upsert commits and moves `updated_at`, and - // the CAS no longer matches — the CAS doing exactly its job, which is - // why the edit's before-read needs no lock of its own while the upsert's - // does. - // - // "Usually" is deliberate. On a cold connection the setup cost can hand - // the edit the row first, and then the edit legitimately applies and the - // upsert renames again on top of it. Both are correct, so the assertions - // below describe the OUTCOME rather than the schedule: whichever way it - // falls, no writer leaves units behind and none are duplicated. - const [up, ed] = await Promise.allSettled([ - h.products.upsert( - { productId: productId(id), sku: sku(viaUpsert) }, - idempotencyKey(`uc-up-${loop}`), - ), - h.products.updateCommerceFields( - { productId: productId(id), sku: sku(viaEdit) }, - idempotencyKey(`uc-ed-${loop}`), - wm, - ), - ]); - - // The upsert has no CAS, so it always applies; the edit either applied or - // reported `stale`. Neither may throw, and neither may half-apply. - expect(up?.status, `loop ${loop}: the upsert applies`).toBe("fulfilled"); - expect(ed?.status, `loop ${loop}: the edit resolves, never throws`).toBe("fulfilled"); - if (ed?.status === "fulfilled" && !ed.value.ok) { - expect(ed.value.reason, `loop ${loop}`).toBe("stale"); - editStale++; - } - - // OUTCOME-SHAPED, so both legal schedules pass a correct implementation. - // The product ends on the upsert's sku either way — it is the writer - // with no CAS to lose — and every unit is under whichever sku the - // product actually holds. The edit's sku is left with no units whether - // it never got one (the edit went stale) or was carried through (the - // edit applied and the upsert then moved them on). - const finalSku = await h.skuOf(id); - expect(finalSku, `loop ${loop}`).toBe(viaUpsert); - expect(await h.onHand(viaUpsert), `loop ${loop}: units follow the product`).toBe(12); - expect( - (await h.onHand(viaEdit)) ?? 0, - `loop ${loop}: no units left under the sku the product does not hold`, - ).toBe(0); - // Conservation across EVERY sku that was named — the assertion that - // catches a split, whichever writer did the splitting. - const total = - ((await h.onHand(from)) ?? 0) + - ((await h.onHand(viaEdit)) ?? 0) + - ((await h.onHand(viaUpsert)) ?? 0); - expect(total, `loop ${loop}: conservation`).toBe(12); - } - - // At least one loop genuinely exercised the CAS rejection — without this - // the case could pass having never raced at all. It is deliberately NOT an - // equality: a loop where the edit wins is a legal schedule, not a failure. - expect(editStale, "the CAS rejected the edit at least once").toBeGreaterThan(0); - }, 120_000); - - test("upsert renaming AFTER a CAS edit landed: the before-read comes from the STORED row, not from the caller's input", async () => { - const LOOPS = 25; - const h = await freshPg(8); - - for (let loop = 0; loop < LOOPS; loop++) { - const id = `up-seq-${loop}`; - const from = `SKU-US2-FROM-${loop}`; - const viaEdit = `SKU-US2-ED-${loop}`; - const viaUpsert = `SKU-US2-UP-${loop}`; - const wm = await h.seedProduct(id, from, 12); - - // SEQUENCED, not raced, and it does NOT discriminate the row lock — - // worth saying plainly, because the name invites the opposite reading. - // The edit is fully committed before the upsert starts, so there is no - // concurrent window and nothing for a snapshot to be stale about; a - // plain SELECT would read the edit's sku here just as correctly. - // - // What it DOES pin is that the before-read is taken from the STORED row - // at all, rather than from anything the caller knows. The upsert's own - // input names only the destination, and its caller last saw the product - // on `from` — so a carry sourced from caller state, or from a sku - // remembered anywhere but the row, moves the wrong units. The lock's own - // necessity is pinned by the three concurrent cases above, each of which - // fails without it. - const ed = await h.products.updateCommerceFields( - { productId: productId(id), sku: sku(viaEdit) }, - idempotencyKey(`us2-ed-${loop}`), - wm, - ); - expect(ed.ok, `loop ${loop}: the edit lands`).toBe(true); - expect(await h.onHand(viaEdit), `loop ${loop}`).toBe(12); - - const up = await h.products.upsert( - { productId: productId(id), sku: sku(viaUpsert) }, - idempotencyKey(`us2-up-${loop}`), - ); - - // The upsert's before-read has to yield the EDIT's sku, because that is - // what the row holds. Sourcing it from the caller's last-known value - // would carry an already-empty `from` and strand all twelve units under - // the edit's sku. - expect(up.sku, `loop ${loop}`).toBe(viaUpsert); - expect(await h.onHand(viaUpsert), `loop ${loop}: carried from the edit's sku`).toBe(12); - expect(await h.onHand(viaEdit), `loop ${loop}: the intermediate sku is emptied`).toBe(0); - const total = - ((await h.onHand(from)) ?? 0) + - ((await h.onHand(viaEdit)) ?? 0) + - ((await h.onHand(viaUpsert)) ?? 0); - expect(total, `loop ${loop}: conservation`).toBe(12); - } - }, 120_000); - - test("a rename racing a restock of the sku it is leaving conserves every unit", async () => { - const LOOPS = 15; - const h = await freshPg(8); - - for (let loop = 0; loop < LOOPS; loop++) { - const id = `mv-${loop}`; - const from = `SKU-MV-FROM-${loop}`; - const to = `SKU-MV-TO-${loop}`; - const wm = await h.seedProduct(id, from, 20); - - // The merchant renames while the warehouse books in 5 more units under - // the old label. The carry reads the source through a lock, so it - // cannot copy a count that then changes underneath it: whichever order - // the two commit in, no unit is invented and none disappears. - const [renamed, restocked] = await Promise.all([ - h.products.updateCommerceFields( - { productId: productId(id), sku: sku(to) }, - idempotencyKey(`mv-rename-${loop}`), - wm, - ), - h.inventory.restock(from, 5, idempotencyKey(`mv-restock-${loop}`)), - ]); - - expect(renamed.ok, `loop ${loop}: the rename lands`).toBe(true); - expect(restocked.ok, `loop ${loop}: the restock lands`).toBe(true); - expect(await h.skuOf(id), `loop ${loop}`).toBe(to); - - const total = ((await h.onHand(from)) ?? 0) + ((await h.onHand(to)) ?? 0); - expect(total, `loop ${loop}: 25 units in, 25 units out`).toBe(25); - // Whatever the interleaving, no row ever goes negative or loses a unit - // to the gap between reading the source and zeroing it. - expect(await h.onHand(to), `loop ${loop}: the product's units moved`).toBeGreaterThanOrEqual( - 20, - ); - } - }, 120_000); -}); diff --git a/packages/store-postgres/test/ticking-clock.ts b/packages/store-postgres/test/ticking-clock.ts deleted file mode 100644 index b2494a7f..00000000 --- a/packages/store-postgres/test/ticking-clock.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { Clock } from "@otta-sh/domain"; - -/** - * A clock that ADVANCES a millisecond per reading — the clock every - * compare-and-set race needs, and the reason it is shared rather than copied. - * - * A `FixedClock` makes `updated_at` identical on every write, which silently - * turns a compare-and-set into a guard that always passes: a same-row race would - * then "pass" while proving nothing, because the CAS it depends on was never - * exercised. Real writers read a moving clock, so the race suites do too. - * - * Deliberately NOT `FixedClock.advance`-based: the point is that no test has to - * remember to advance it. Every `now()` is a new instant, exactly as a wall clock - * would be, so a sequence of writes inside one case produces the distinct - * watermarks a CAS needs without any bookkeeping in the case itself. - */ -export class TickingClock implements Clock { - #at: number; - - constructor(start: string) { - this.#at = new Date(start).getTime(); - } - - now(): Date { - this.#at += 1; - return new Date(this.#at); - } -} diff --git a/packages/store-postgres/test/variant-sku-rename-race.pg.test.ts b/packages/store-postgres/test/variant-sku-rename-race.pg.test.ts deleted file mode 100644 index 8f438dd1..00000000 --- a/packages/store-postgres/test/variant-sku-rename-race.pg.test.ts +++ /dev/null @@ -1,940 +0,0 @@ -import { - cents, - currency, - idempotencyKey, - money, - productId, - sku, - SkuConflictError, - SkuStockConflictError, -} from "@otta-sh/domain"; -import type { Kysely } from "kysely"; -import { afterEach, describe, expect, test } from "vitest"; -import { KyselyInventoryStore, KyselyProductCommerceStore, uuidIdGen } from "../src/index.js"; -import type { Database } from "../src/schema.js"; -import { createIsolatedPgSchema } from "../src/testing.js"; -import { TickingClock } from "./ticking-clock.js"; - -// THE SKU-RENAME RULE at VARIANT grain, under REAL concurrency -// (Postgres-required, independent connections — better-sqlite3 serializes every -// writer onto one connection and therefore cannot race at all). -// -// The rule belongs to the `sku` COLUMN rather than to one caller: `inventory` is -// keyed by the bare sku and knows nothing about products or variants. So the -// variant writer is simply a THIRD writer of that column, and it has to be raced -// on its own — the two product-level writers being green proves the carry, not -// that a new caller reaches it correctly. -// -// The last case races something the product level has no analogue for: two -// FIRST-PRICINGS of two different sizes of ONE product, in different currencies. -// Each has its own compare-and-set target, so nothing but the parent row lock -// orders them, and without it a product ends up holding two currencies. - -const PG = process.env.PG_CONNECTION_STRING; - -interface PgFixture { - products: KyselyProductCommerceStore; - inventory: KyselyInventoryStore; - db: Kysely; - /** A product row with no sku and no price of its own — the realistic variants - * shape, where the sizes carry the money. Returns the `updatedAt` watermark - * its next guarded edit has to pass back. */ - seedProduct(id: string): Promise; - /** A declared, sku-bearing, stocked variant; returns the `updatedAt` - * watermark its next guarded edit has to pass back. */ - seedVariant(id: string, key: string, s: string, onHand: number): Promise; - /** A declared variant with no sku and no price; returns its watermark. */ - declareVariant(id: string, key: string): Promise; - onHand(s: string): Promise; - skuOfVariant(id: string, key: string): Promise; - /** A live, sku-bearing PRODUCT row; returns the watermark its next guarded - * edit has to pass back. The other kind of live sellable unit. */ - seedPricedProduct(id: string, s: string, cur: string): Promise; - skuOfProduct(id: string): Promise; - currencyOfProduct(id: string): Promise; - currencies(id: string): Promise; -} - -/** - * A few milliseconds of lead, so one of two overlapping transactions reliably - * reaches a contended row lock first. The transactions still OVERLAP — the point - * is to decide WHICH holds the lock when the other arrives, not to sequence them. - */ -function headStart(): Promise { - return new Promise((resolve) => { - setTimeout(resolve, 15); - }); -} - -/** The reported outcome of a settled guarded write, flattened for assertions. */ -function outcomeOf(r: PromiseSettledResult<{ ok: boolean; reason?: string }> | undefined): string { - if (r?.status !== "fulfilled") return "threw"; - return r.value.ok ? "ok" : (r.value.reason ?? "refused"); -} - -const cleanups: Array<() => Promise> = []; -afterEach(async () => { - for (const fn of cleanups.splice(0)) await fn(); -}); - -/** A schema-isolated store whose pool holds `poolMax` connections, so the - * concurrent writers below each get an INDEPENDENT one (a real race). */ -async function freshPg(poolMax: number): Promise { - if (PG === undefined) throw new Error("PG_CONNECTION_STRING is not set"); - const iso = await createIsolatedPgSchema(PG, { poolMax }); - cleanups.push(() => iso.teardown()); - const db = iso.db; - const clock = new TickingClock("2026-07-10T00:00:00.000Z"); - const products = new KyselyProductCommerceStore({ db, clock }); - const inventory = new KyselyInventoryStore({ db, idGen: uuidIdGen, clock }); - return { - products, - inventory, - db, - async seedProduct(id) { - const row = await products.upsert({ productId: productId(id) }, idempotencyKey(`seed-${id}`)); - return row.updatedAt.toISOString(); - }, - async declareVariant(id, key) { - const row = await products.upsertVariant( - { productId: productId(id), variantKey: key, title: `Variant ${key}` }, - idempotencyKey(`declare-${id}-${key}`), - ); - return row.updatedAt.toISOString(); - }, - async seedVariant(id, key, s, onHand) { - const declared = await products.upsertVariant( - { productId: productId(id), variantKey: key, title: `Variant ${key}` }, - idempotencyKey(`declare-${id}-${key}`), - ); - const res = await products.updateVariantFields( - { productId: productId(id), variantKey: key, sku: sku(s) }, - idempotencyKey(`price-${id}-${key}`), - declared.updatedAt.toISOString(), - ); - if (!res.ok) throw new Error(`seedVariant: ${id}/${key} could not take a sku`); - await db - .insertInto("inventory") - .values({ sku: s, on_hand: onHand }) - .onConflict((oc) => oc.column("sku").doUpdateSet({ on_hand: onHand })) - .execute(); - return res.variant.updatedAt.toISOString(); - }, - async onHand(s) { - const row = await db - .selectFrom("inventory") - .select("on_hand") - .where("sku", "=", s) - .executeTakeFirst(); - return row?.on_hand ?? null; - }, - async seedPricedProduct(id, s, cur) { - const row = await products.upsert( - { - productId: productId(id), - sku: sku(s), - price: money(cents(1000), currency(cur)), - }, - idempotencyKey(`seed-product-${id}`), - ); - return row.updatedAt.toISOString(); - }, - async skuOfProduct(id) { - const row = await db - .selectFrom("product_commerce") - .select("sku") - .where("product_id", "=", id) - .executeTakeFirst(); - return row?.sku ?? null; - }, - async currencyOfProduct(id) { - const row = await db - .selectFrom("product_commerce") - .select("price_currency") - .where("product_id", "=", id) - .executeTakeFirst(); - return row?.price_currency ?? null; - }, - async skuOfVariant(id, key) { - const row = await db - .selectFrom("product_variants") - .select("sku") - .where("product_id", "=", id) - .where("variant_key", "=", key) - .executeTakeFirst(); - return row?.sku ?? null; - }, - async currencies(id) { - const rows = await db - .selectFrom("product_variants") - .select("price_currency") - .where("product_id", "=", id) - .where("price_currency", "is not", null) - .execute(); - return [...new Set(rows.map((r) => r.price_currency ?? ""))].toSorted(); - }, - }; -} - -describe.skipIf(PG === undefined)("variant sku rename concurrency [postgres]", () => { - test("two SIZES of one product renaming onto ONE free target: exactly one lands, the loser leaves no trace, and the units are conserved", async () => { - const LOOPS = 12; - const h = await freshPg(8); - - for (let loop = 0; loop < LOOPS; loop++) { - const id = `prod-${loop}`; - const skuL = `V-L-${loop}`; - const skuS = `V-S-${loop}`; - const target = `V-T-${loop}`; - await h.seedProduct(id); - const wmL = await h.seedVariant(id, "large", skuL, 40); - const wmS = await h.seedVariant(id, "small", skuS, 7); - - // Two sizes of the same product reach for one free sku on independent - // connections. Two guards can arbitrate it — the live-sku partial index - // on `product_variants` and the rename rule's own inventory claim — and - // which fires is a timing detail. What this pins is the OUTCOME: one - // winner, a clean loser, every unit accounted for. - const results = await Promise.allSettled([ - h.products.updateVariantFields( - { productId: productId(id), variantKey: "large", sku: sku(target) }, - idempotencyKey(`rename-l-${loop}`), - wmL, - ), - h.products.updateVariantFields( - { productId: productId(id), variantKey: "small", sku: sku(target) }, - idempotencyKey(`rename-s-${loop}`), - wmS, - ), - ]); - - const winners = results.filter((r) => r.status === "fulfilled"); - const losers = results.filter((r) => r.status === "rejected"); - expect(winners, `loop ${loop}: exactly one winner`).toHaveLength(1); - expect(losers, `loop ${loop}: exactly one loser`).toHaveLength(1); - - // The loser failed with a TYPED domain error, never a raw constraint - // violation surfacing as a 500. - const reason: unknown = (losers[0] as PromiseRejectedResult).reason; - expect(reason, `loop ${loop}: typed refusal`).toBeInstanceOf(Error); - expect( - ["SkuConflictError", "SkuStockConflictError"], - `loop ${loop}: typed refusal, got ${String((reason as Error).message)}`, - ).toContain((reason as Error).name); - - // The loser's SIZE is untouched — still its own sku, still its own units. - const largeWon = (await h.skuOfVariant(id, "large")) === target; - const loserKey = largeWon ? "small" : "large"; - const loserSku = largeWon ? skuS : skuL; - const loserUnits = largeWon ? 7 : 40; - const winnerUnits = largeWon ? 40 : 7; - expect(await h.skuOfVariant(id, loserKey), `loop ${loop}: loser keeps its sku`).toBe( - loserSku, - ); - expect(await h.onHand(loserSku), `loop ${loop}: loser keeps its units`).toBe(loserUnits); - - // CONSERVATION: the target holds exactly the winner's count — not both - // merged, not a fresh zero beside the winner's orphaned units. - expect(await h.onHand(target), `loop ${loop}: target holds the winner's units`).toBe( - winnerUnits, - ); - const winnerOldSku = largeWon ? skuL : skuS; - expect(await h.onHand(winnerOldSku), `loop ${loop}: source retained at zero`).toBe(0); - const total = - ((await h.onHand(target)) ?? 0) + - ((await h.onHand(winnerOldSku)) ?? 0) + - ((await h.onHand(loserSku)) ?? 0); - expect(total, `loop ${loop}: 47 units in, 47 units out`).toBe(47); - } - }, 120_000); - - test("two variant renames onto one ALREADY-OCCUPIED target: both refuse, and no size adopts the parked units", async () => { - const LOOPS = 12; - const h = await freshPg(8); - - for (let loop = 0; loop < LOOPS; loop++) { - const id = `occ-${loop}`; - const skuL = `VO-L-${loop}`; - const skuS = `VO-S-${loop}`; - const parked = `VO-PARKED-${loop}`; - await h.seedProduct(id); - const wmL = await h.seedVariant(id, "large", skuL, 10); - const wmS = await h.seedVariant(id, "small", skuS, 3); - // Units parked under a sku NO live sellable unit holds — what an earlier - // rename leaves behind, and the state the rule refuses to arbitrate. - await h.db.insertInto("inventory").values({ sku: parked, on_hand: 99 }).execute(); - - const results = await Promise.allSettled([ - h.products.updateVariantFields( - { productId: productId(id), variantKey: "large", sku: sku(parked) }, - idempotencyKey(`occ-l-${loop}`), - wmL, - ), - h.products.updateVariantFields( - { productId: productId(id), variantKey: "small", sku: sku(parked) }, - idempotencyKey(`occ-s-${loop}`), - wmS, - ), - ]); - - for (const r of results) { - expect(r.status, `loop ${loop}: both refuse`).toBe("rejected"); - expect( - (r as PromiseRejectedResult).reason, - `loop ${loop}: the stock refusal, not the index's`, - ).toBeInstanceOf(SkuStockConflictError); - } - - expect(await h.skuOfVariant(id, "large"), `loop ${loop}`).toBe(skuL); - expect(await h.skuOfVariant(id, "small"), `loop ${loop}`).toBe(skuS); - expect(await h.onHand(skuL), `loop ${loop}`).toBe(10); - expect(await h.onHand(skuS), `loop ${loop}`).toBe(3); - expect(await h.onHand(parked), `loop ${loop}: parked units untouched`).toBe(99); - } - }, 120_000); - - test("a variant rename racing a SEED of the target sku: the claim decides it, and the loser is still a typed refusal", async () => { - const LOOPS = 30; - const h = await freshPg(8); - // An interleaving case that only ever took ONE branch would assert half of - // what it claims and never say so. Counted, then asserted at the end. - let renameWon = 0; - let seedWon = 0; - - for (let loop = 0; loop < LOOPS; loop++) { - const id = `seed-race-${loop}`; - const from = `VSR-FROM-${loop}`; - const target = `VSR-TO-${loop}`; - await h.seedProduct(id); - const wm = await h.seedVariant(id, "large", from, 40); - - // The one contention no unique index can arbitrate: `seedOnHand` is - // attempted on every sku-bearing save, so another writer can be creating - // the target's inventory row at the very moment the rename claims it. - const [renamed] = await Promise.allSettled([ - h.products.updateVariantFields( - { productId: productId(id), variantKey: "large", sku: sku(target) }, - idempotencyKey(`vsr-${loop}`), - wm, - ), - h.inventory.seedOnHand(target, 0), - ]); - - if (renamed === undefined) throw new Error("no result"); - if (renamed.status === "rejected") { - seedWon++; - // The seed got there first. A naive "look, then insert" would surface - // that as a raw duplicate-key violation — a 500 where the operator - // should have been told the sku is taken. - expect(renamed.reason, `loop ${loop}: typed, never a raw constraint error`).toBeInstanceOf( - SkuStockConflictError, - ); - // …and it refused ATOMICALLY: the size kept its sku and its units. - expect(await h.skuOfVariant(id, "large"), `loop ${loop}`).toBe(from); - expect(await h.onHand(from), `loop ${loop}`).toBe(40); - expect(await h.onHand(target), `loop ${loop}: the seed's empty row`).toBe(0); - } else { - renameWon++; - expect(renamed.value.ok, `loop ${loop}`).toBe(true); - expect(await h.skuOfVariant(id, "large"), `loop ${loop}`).toBe(target); - expect(await h.onHand(target), `loop ${loop}: carried, not reset`).toBe(40); - expect(await h.onHand(from), `loop ${loop}: source retained at zero`).toBe(0); - } - - // Either way, 40 units in, 40 units out — never 80, never 0. - const total = ((await h.onHand(from)) ?? 0) + ((await h.onHand(target)) ?? 0); - expect(total, `loop ${loop}: conservation`).toBe(40); - } - - expect(renameWon, "the rename-first branch fired").toBeGreaterThan(0); - expect(seedWon, "the seed-first branch fired").toBeGreaterThan(0); - }, 120_000); - - test("two sizes FIRST-PRICED at once in different currencies: one lands, and the product never ends up holding two currencies", async () => { - const LOOPS = 25; - const h = await freshPg(8); - let mismatches = 0; - - // Warm the pool before racing anything: a first use of a connection pays for - // the TCP connect and session setup, which is enough to decide which writer - // reaches the parent row first. The assertions do not depend on the order. - await Promise.all(Array.from({ length: 8 }, () => h.onHand("warm-up"))); - - for (let loop = 0; loop < LOOPS; loop++) { - const id = `cur-${loop}`; - await h.seedProduct(id); - const wmL = await h.declareVariant(id, "large"); - const wmS = await h.declareVariant(id, "small"); - - // Two sizes, each with its OWN compare-and-set target, priced at the same - // moment in disagreeing currencies. Neither CAS can see the other, and the - // product row carries no price to read, so the only thing that can order - // them is the parent row lock the currency resolution takes. Without it - // both read "no currency yet" and both apply. - const results = await Promise.allSettled([ - h.products.updateVariantFields( - { - productId: productId(id), - variantKey: "large", - price: money(cents(3000), currency("GBP")), - }, - idempotencyKey(`cur-l-${loop}`), - wmL, - ), - h.products.updateVariantFields( - { - productId: productId(id), - variantKey: "small", - price: money(cents(2500), currency("USD")), - }, - idempotencyKey(`cur-s-${loop}`), - wmS, - ), - ]); - - // Neither may THROW — a currency disagreement is a reported outcome the - // console renders, not an exception. - for (const r of results) { - expect(r.status, `loop ${loop}: resolves, never throws`).toBe("fulfilled"); - } - const outcomes = results.map((r) => - r.status === "fulfilled" ? (r.value.ok ? "ok" : r.value.reason) : "threw", - ); - const applied = outcomes.filter((o) => o === "ok"); - // At least one has to land — refusing both would be the rule deadlocking - // itself out of two legal first pricings. - expect(applied.length, `loop ${loop}: ${outcomes.join("/")}`).toBeGreaterThanOrEqual(1); - if (outcomes.includes("currency_mismatch")) mismatches++; - - // THE ASSERTION THAT BITES: whatever the schedule, the product ends - // holding ONE currency. Two would give it no honest total, no honest - // picker and no honest cart. - expect(await h.currencies(id), `loop ${loop}: one currency per product`).toHaveLength(1); - } - - // The refusal genuinely fired: without it every loop would have ended with - // two currencies, and the assertion above would already have caught it — but - // this pins that the race really was raced rather than serialized by luck. - expect(mismatches, "the currency refusal fired at least once").toBeGreaterThan(0); - }, 120_000); - - // -- across the pair: a product write and a variant write, at once --------- - // - // Uniqueness and currency integrity both span two tables, and no index spans - // two tables, so both directions are app-level checks inside a transaction. - // That makes the CROSSING race the one that decides whether the pair is one - // rule or two half-rules that happen to agree when run apart. - - test("a PRODUCT and a VARIANT reaching for one free sku at once: never both, and the loser refuses typed", async () => { - const LOOPS = 20; - const h = await freshPg(8); - let tookIt = 0; - - for (let loop = 0; loop < LOOPS; loop++) { - const varProd = `xp-v-${loop}`; - const plainProd = `xp-p-${loop}`; - const target = `XP-T-${loop}`; - await h.seedProduct(varProd); - const wmV = await h.declareVariant(varProd, "large"); - // BOTH sides are FIRST-sku assignments, deliberately: a rename onto an - // occupied row would be refused by the rename rule before the cross-table - // check was ever consulted, and the case would pass while proving nothing. - // A first sku ADOPTS an existing row, so both writes are legal and the - // cross-table rule is the ONLY thing that can arbitrate them. - const wmP = await h.seedProduct(plainProd); - // The target already has a stock row — the state every sku that has ever - // been stocked, restocked or renamed onto is in, and the row the two - // halves of the cross-table rule serialize on (see - // `#lockSkuRowIfPresent`, which also records the never-used-sku bound). - await h.db.insertInto("inventory").values({ sku: target, on_hand: 0 }).execute(); - - // One free sku, two KINDS of sellable unit reaching for it on independent - // connections. Neither side's unique index can see the other's table, so - // if the cross-table checks were merely advisory both would land and one - // `inventory` row would be named by two units — the state a later rename - // of either one silently drains. - const results = await Promise.allSettled([ - h.products.updateVariantFields( - { productId: productId(varProd), variantKey: "large", sku: sku(target) }, - idempotencyKey(`xp-v-${loop}`), - wmV, - ), - h.products.updateCommerceFields( - { productId: productId(plainProd), sku: sku(target) }, - idempotencyKey(`xp-p-${loop}`), - wmP, - ), - ]); - - const landed = results.filter((r) => r.status === "fulfilled" && r.value.ok); - expect(landed.length, `loop ${loop}: at most one unit takes the sku`).toBeLessThanOrEqual(1); - - const variantHas = (await h.skuOfVariant(varProd, "large")) === target; - const productHas = (await h.skuOfProduct(plainProd)) === target; - // THE ASSERTION THAT BITES: never both. One sku, one live sellable unit. - expect( - variantHas && productHas, - `loop ${loop}: a sku may not name two live sellable units`, - ).toBe(false); - if (variantHas || productHas) tookIt++; - - // A loser refuses TYPED, never a raw constraint violation surfacing as a - // 500 — and never with a half-applied write behind it. - for (const r of results) { - if (r.status === "rejected") { - expect(r.reason, `loop ${loop}: typed refusal`).toBeInstanceOf(SkuConflictError); - } - } - // The loser is left exactly as it arrived: still sku-less, never half-way - // into an assignment it was refused. - if (!productHas) expect(await h.skuOfProduct(plainProd), `loop ${loop}`).toBeNull(); - if (!variantHas) expect(await h.skuOfVariant(varProd, "large"), `loop ${loop}`).toBeNull(); - } - - // Somebody won every loop: refusing both sides would be the pair deadlocking - // itself out of a legal write rather than arbitrating one. Both sides are - // FIRST assignments onto a sku that already has a stock row, so both adopt - // and neither can be turned away by the rename rule — the cross-table check - // is the only thing that decides, which is the point of the fixture. - expect(tookIt, "the sku was claimed by exactly one kind of unit").toBe(LOOPS); - }, 120_000); - - test("a PRODUCT repricing racing a VARIANT pricing: the product never ends in a currency its live sizes do not share", async () => { - const LOOPS = 25; - const h = await freshPg(8); - let refusals = 0; - let productSideRefused = 0; - let variantSideRefused = 0; - - // Warm the pool: a first use of a connection pays for the TCP connect and - // session setup, enough to decide which writer reaches the parent row first. - await Promise.all(Array.from({ length: 8 }, () => h.onHand("warm-up"))); - - for (let loop = 0; loop < LOOPS; loop++) { - const id = `xc-${loop}`; - // UNPRICED, deliberately. A product that already carries a price refuses - // the variant side at guard 4b — the variant's own "match the parent" - // check — every single loop, so 4c, the reciprocal this case exists for, - // is never reached and the case passes while proving nothing. With no - // product-level price BOTH sides are FIRST pricings: each reads "no - // currency yet" and only the lock order decides. - const wmP = await h.seedProduct(id); - const wmV = await h.declareVariant(id, "large"); - - // Both directions of the currency rule fired at one instant. The - // product-side guard reads the live variants and the variant-side guard - // reads the product, so without ONE lock ordering each reads the other's - // "before" state and both apply — leaving a product priced in GBP beside a - // size priced in EUR, which has no honest total and no honest cart. - // ALTERNATED, and with a real head start rather than a bare issue order. - // The product side takes the parent's lock as its FIRST statement while the - // variant side reads its own row before reaching for it, so simply issuing - // the variant first is not enough to make it win — measured, it never did, - // and guard 4c went unexercised for all twenty-five loops. A few - // milliseconds is enough to decide which transaction holds the parent when - // the other arrives; the transactions still OVERLAP, which is the whole - // point, and the loser genuinely blocks on the lock rather than finding the - // work already finished. - const productFirst = loop % 2 === 0; - const repriceProduct = () => - h.products.updateCommerceFields( - { productId: productId(id), price: money(cents(4000), currency("GBP")) }, - idempotencyKey(`xc-p-${loop}`), - wmP, - ); - const priceVariant = () => - h.products.updateVariantFields( - { - productId: productId(id), - variantKey: "large", - price: money(cents(2500), currency("EUR")), - }, - idempotencyKey(`xc-v-${loop}`), - wmV, - ); - const lead = productFirst ? repriceProduct() : priceVariant(); - await headStart(); - const trail = productFirst ? priceVariant() : repriceProduct(); - const [first, second] = await Promise.allSettled([lead, trail]); - const productResult = productFirst ? first : second; - const variantResult = productFirst ? second : first; - - // A currency disagreement is a reported outcome, never an exception. - for (const r of [productResult, variantResult]) { - expect(r?.status, `loop ${loop}: resolves, never throws`).toBe("fulfilled"); - } - const outcomes = [outcomeOf(productResult), outcomeOf(variantResult)]; - if (outcomes.includes("currency_mismatch")) refusals++; - // WHICH side refused tells us WHICH guard fired: the product side is 4c - // (it read the live variants), the variant side is 4b (it read the - // parent). Counted separately so the case cannot quietly degrade into - // exercising only the pre-existing direction again. - if (outcomes[0] === "currency_mismatch") productSideRefused++; - if (outcomes[1] === "currency_mismatch") variantSideRefused++; - - // THE ASSERTION THAT BITES: every currency under this product agrees. - const productCurrency = await h.currencyOfProduct(id); - const variantCurrencies = await h.currencies(id); - const all = new Set([ - ...(productCurrency === null ? [] : [productCurrency]), - ...variantCurrencies, - ]); - expect( - [...all], - `loop ${loop}: one currency per product (${outcomes.join("/")})`, - ).toHaveLength(1); - } - - // The refusal genuinely fired rather than the schedule sparing it — and 4c, - // the direction this increment added, fired on its own account. - expect(refusals, "the cross-table currency refusal fired at least once").toBeGreaterThan(0); - expect(productSideRefused, "guard 4c (the product side) fired at least once").toBeGreaterThan( - 0, - ); - expect(variantSideRefused + productSideRefused, "every loop was arbitrated").toBe(LOOPS); - }, 120_000); - - test("the same two first-pricings with NO head start: overlapping, and still one currency", async () => { - const LOOPS = 40; - const h = await freshPg(8); - let refusals = 0; - - await Promise.all(Array.from({ length: 8 }, () => h.onHand("warm-up"))); - - for (let loop = 0; loop < LOOPS; loop++) { - const id = `xc0-${loop}`; - const wmP = await h.seedProduct(id); - const wmV = await h.declareVariant(id, "large"); - - // THE COMPANION TO THE CASE ABOVE, and the one that actually discriminates - // the lock ORDER. A head start decides which transaction holds the parent, - // which is what makes guard 4c reachable — but at roughly a millisecond a - // statement it also lets the leader COMMIT before the follower reads, so a - // follower that read the live variants BEFORE taking the parent's lock - // would still see committed data and still refuse. Issued in the same tick, - // the two genuinely overlap: the follower's read lands inside the leader's - // open transaction, and only the lock makes it wait for the answer. - const results = await Promise.allSettled([ - h.products.updateCommerceFields( - { productId: productId(id), price: money(cents(4000), currency("GBP")) }, - idempotencyKey(`xc0-p-${loop}`), - wmP, - ), - h.products.updateVariantFields( - { - productId: productId(id), - variantKey: "large", - price: money(cents(2500), currency("EUR")), - }, - idempotencyKey(`xc0-v-${loop}`), - wmV, - ), - ]); - - for (const r of results) { - expect(r.status, `loop ${loop}: resolves, never throws`).toBe("fulfilled"); - } - const outcomes = results.map((r) => - r.status === "fulfilled" ? (r.value.ok ? "ok" : r.value.reason) : "threw", - ); - if (outcomes.includes("currency_mismatch")) refusals++; - - const productCurrency = await h.currencyOfProduct(id); - const all = new Set([ - ...(productCurrency === null ? [] : [productCurrency]), - ...(await h.currencies(id)), - ]); - expect( - [...all], - `loop ${loop}: one currency per product (${outcomes.join("/")})`, - ).toHaveLength(1); - } - - expect(refusals, "the overlap was arbitrated at least once").toBeGreaterThan(0); - }, 120_000); - - // -- the lock order itself ------------------------------------------------- - // - // Both cases below deadlock (Postgres `40P01`, an unmapped raw error reaching - // the caller) against an implementation whose locks are individually correct - // but ordered differently in two writers. Neither can fail on better-sqlite3, - // which serializes every writer onto one connection — which is exactly why - // they live here and not in the contract suite. - - test("CROSSING RENAMES X→Y and Y→X, both stocked: one refuses typed, and neither deadlocks", async () => { - const LOOPS = 300; - const h = await freshPg(8); - - // Warm the pool: a cold connection's setup cost dwarfs the window these two - // writers actually overlap in, and a pair that never overlaps proves nothing. - await Promise.all(Array.from({ length: 8 }, () => h.onHand("warm-up"))); - - for (let loop = 0; loop < LOOPS; loop++) { - const id = `cross-${loop}`; - const skuX = `CR-X-${loop}`; - const skuY = `CR-Y-${loop}`; - await h.seedProduct(id); - const wmX = await h.seedVariant(id, "large", skuX, 11); - const wmY = await h.seedVariant(id, "small", skuY, 5); - - // Each rename's SOURCE is the other's TARGET. Locking the target before the - // source makes these two writers take X,Y and Y,X — the textbook ABBA — and - // Postgres breaks it with a deadlock, which is a raw 40P01 where the port - // promises a typed refusal. Source-before-target makes both take the same - // order, so one waits and then refuses on the occupied row. - const results = await Promise.allSettled([ - h.products.updateVariantFields( - { productId: productId(id), variantKey: "large", sku: sku(skuY) }, - idempotencyKey(`cross-l-${loop}`), - wmX, - ), - h.products.updateVariantFields( - { productId: productId(id), variantKey: "small", sku: sku(skuX) }, - idempotencyKey(`cross-s-${loop}`), - wmY, - ), - ]); - - for (const r of results) { - if (r.status === "rejected") { - const err = r.reason as Error & { code?: string }; - // NEVER a deadlock: `40P01` is unmapped and would surface to a - // merchant as a 500 on a legal edit. - expect(err.code, `loop ${loop}: never a deadlock — ${err.message}`).not.toBe("40P01"); - expect( - ["SkuConflictError", "SkuStockConflictError"], - `loop ${loop}: typed refusal, got ${err.name}: ${err.message}`, - ).toContain(err.name); - } - } - - // Both targets are occupied, so neither rename can honestly land: the pair - // is refused and every unit stays where it was. - expect(await h.onHand(skuX), `loop ${loop}: X untouched`).toBe(11); - expect(await h.onHand(skuY), `loop ${loop}: Y untouched`).toBe(5); - expect(await h.skuOfVariant(id, "large"), `loop ${loop}`).toBe(skuX); - expect(await h.skuOfVariant(id, "small"), `loop ${loop}`).toBe(skuY); - } - }, 120_000); - - test("a RESURRECT racing a PRICE EDIT of a sibling size: no deadlock, and the product still holds one currency", async () => { - const LOOPS = 25; - const h = await freshPg(8); - - await Promise.all(Array.from({ length: 8 }, () => h.onHand("warm-up"))); - - for (let loop = 0; loop < LOOPS; loop++) { - const id = `rvp-${loop}`; - await h.seedProduct(id); - // A priced orphan: the resurrect will have to resolve the product currency - // to decide whether its price survives, which reaches the parent row. - const wmL = await h.declareVariant(id, "large"); - const priced = await h.products.updateVariantFields( - { - productId: productId(id), - variantKey: "large", - price: money(cents(3000), currency("GBP")), - }, - idempotencyKey(`rvp-price-${loop}`), - wmL, - ); - expect(priced.ok, `loop ${loop}: the orphan was priced`).toBe(true); - await h.products.deactivateVariant( - productId(id), - "large", - idempotencyKey(`rvp-orphan-${loop}`), - "2026-07-10T01:00:00.000Z", - ); - const wmS = await h.declareVariant(id, "small"); - - // The declare walks parent → variant row; the price edit walks the same - // two. Reverse either one and this is a clean ABBA between the CMS sync and - // the console — the worst pairing available, because the sync has no - // merchant to show an error to. - const [declared, edited] = await Promise.allSettled([ - h.products.upsertVariant( - { - productId: productId(id), - variantKey: "large", - title: "Large", - contentUpdatedAt: "2026-07-10T02:00:00.000Z", - }, - idempotencyKey(`rvp-back-${loop}`), - ), - h.products.updateVariantFields( - { - productId: productId(id), - variantKey: "small", - price: money(cents(2500), currency("USD")), - }, - idempotencyKey(`rvp-edit-${loop}`), - wmS, - ), - ]); - - // The CMS channel NEVER fails: not on a constraint, not on a deadlock. - expect(declared?.status, `loop ${loop}: the declare resolves`).toBe("fulfilled"); - if (edited?.status === "rejected") { - const err = edited.reason as Error & { code?: string }; - expect(err.code, `loop ${loop}: never a deadlock — ${err.message}`).not.toBe("40P01"); - } - - // Whichever order they landed in, the product holds ONE currency: either - // the resurrect kept GBP and the USD edit was refused, or the edit landed - // first and the resurrect handed its GBP price back as absent. - const all = new Set(await h.currencies(id)); - expect([...all], `loop ${loop}: one currency per product`).toHaveLength(1); - } - }, 120_000); - - test("CROSSING RENAMES ACROSS TWO PARENTS: P1's size X→Y against P2's size Y→X, both stocked", async () => { - const LOOPS = 300; - const h = await freshPg(8); - - await Promise.all(Array.from({ length: 8 }, () => h.onHand("warm-up"))); - - for (let loop = 0; loop < LOOPS; loop++) { - const p1 = `xpar-1-${loop}`; - const p2 = `xpar-2-${loop}`; - const skuX = `XPAR-X-${loop}`; - const skuY = `XPAR-Y-${loop}`; - await h.seedProduct(p1); - await h.seedProduct(p2); - const wm1 = await h.seedVariant(p1, "large", skuX, 13); - const wm2 = await h.seedVariant(p2, "large", skuY, 6); - - // THE CASE THE SORTED PAIR LOCK EXISTS FOR. Two DIFFERENT parents, so the - // parent lock — which makes every intra-product cycle unreachable — has - // nothing to say here: these two writers never contend on a product row or - // on a variant row, only on the two `inventory` rows they share. Their - // roles are mirrored, so ordering those by role (source, then target) sends - // them round the cycle in opposite directions; ordering by SKU sends both - // the same way. - const results = await Promise.allSettled([ - h.products.updateVariantFields( - { productId: productId(p1), variantKey: "large", sku: sku(skuY) }, - idempotencyKey(`xpar-1-${loop}`), - wm1, - ), - h.products.updateVariantFields( - { productId: productId(p2), variantKey: "large", sku: sku(skuX) }, - idempotencyKey(`xpar-2-${loop}`), - wm2, - ), - ]); - - for (const r of results) { - if (r.status === "rejected") { - const err = r.reason as Error & { code?: string }; - expect(err.code, `loop ${loop}: never a deadlock — ${err.message}`).not.toBe("40P01"); - expect( - ["SkuConflictError", "SkuStockConflictError"], - `loop ${loop}: typed refusal, got ${err.name}: ${err.message}`, - ).toContain(err.name); - } - } - - // Both targets are held by a live unit, so neither rename can land, and - // every unit stays where it was. - expect(await h.skuOfVariant(p1, "large"), `loop ${loop}`).toBe(skuX); - expect(await h.skuOfVariant(p2, "large"), `loop ${loop}`).toBe(skuY); - expect(await h.onHand(skuX), `loop ${loop}`).toBe(13); - expect(await h.onHand(skuY), `loop ${loop}`).toBe(6); - } - }, 180_000); - - test("a RESURRECT racing a PRODUCT claiming THE ORPHAN'S OWN SKU: no deadlock, and exactly one live unit ends up holding it", async () => { - const LOOPS = 150; - const h = await freshPg(8); - let resurrectKept = 0; - let editTookIt = 0; - - await Promise.all(Array.from({ length: 8 }, () => h.onHand("warm-up"))); - - for (let loop = 0; loop < LOOPS; loop++) { - const id = `rvs-${loop}`; - const orphanSku = `RVS-S-${loop}`; - await h.seedProduct(id); - // An orphan carrying a SKU WITH A STOCK ROW — the only state in which the - // declare reaches its third stage and takes an `inventory` lock at all. An - // orphan that is merely priced never gets there, so a race built on one - // would exercise the exception's comment rather than the exception. - await h.seedVariant(id, "large", orphanSku, 9); - await h.products.deactivateVariant( - productId(id), - "large", - idempotencyKey(`rvs-orphan-${loop}`), - "2026-07-10T01:00:00.000Z", - ); - // The claimant is a PRODUCT of its own, and that is deliberate: a sibling - // VARIANT reaching for the same sku is arbitrated by - // `product_variants_live_sku_unique` whatever the declare does, so a race - // built on one would pass with the declare's stage-3 lock deleted. No index - // spans the two tables, so the product claimant is the case where that lock - // is the only thing standing between a stale read and two live sellable - // units on one sku. - // - // The same VARIANT cannot serve as the competitor either: while it is - // orphaned every edit of it is `not_found`, and the moment the declare - // revives it the edit's watermark is stale — so a literal same-variant pair - // can never both hold locks, and the contention worth racing is over the - // SKU rather than over the row. - const claimant = `rvs-claimant-${loop}`; - - // THE PAIR THE HEADER'S ONE DELIBERATE INVERSION TURNS ON. The declare goes - // parent → variant row → inventory(orphan's sku); every other writer goes - // parent → inventory → variant row. Here they meet on the same stock row - // from opposite directions: the declare holds `large` and wants the sku, - // the edit holds the sku and wants `small`. Nobody waits on a row the other - // holds, which is exactly the argument — and this is where it is checked. - const [declared, edited] = await Promise.allSettled([ - h.products.upsertVariant( - { - productId: productId(id), - variantKey: "large", - title: "Large", - contentUpdatedAt: "2026-07-10T02:00:00.000Z", - }, - idempotencyKey(`rvs-back-${loop}`), - ), - h.products.upsert( - { productId: productId(claimant), sku: sku(orphanSku) }, - idempotencyKey(`rvs-claim-${loop}`), - ), - ]); - - // The CMS channel never fails — not on a constraint, not on a deadlock. - expect(declared?.status, `loop ${loop}: the declare resolves`).toBe("fulfilled"); - if (edited?.status === "rejected") { - const err = edited.reason as Error & { code?: string }; - expect(err.code, `loop ${loop}: never a deadlock — ${err.message}`).not.toBe("40P01"); - expect(err.name, `loop ${loop}: typed refusal — ${err.message}`).toBe("SkuConflictError"); - } - - // However they interleaved: the variant is live again, and the sku names - // exactly ONE live unit. Either the resurrect got there first and kept its - // sku (so the edit was refused), or the edit got there first and the - // revalidation handed the sku back as absent. - const rows = await h.products.listVariants(productId(id)); - const large = rows.find((v) => v.variantKey === "large"); - const claimed = await h.skuOfProduct(claimant); - expect(large?.orphanedAt, `loop ${loop}: the declare won presence`).toBeNull(); - const holders = [large?.sku, claimed].filter((x) => x === orphanSku); - // THE ASSERTION THAT BITES: never both. No index spans the two tables, so - // this holds only because the two writers met on the sku's stock row. - expect(holders, `loop ${loop}: exactly one live unit holds the sku`).toHaveLength(1); - if (large?.sku === orphanSku) { - resurrectKept++; - // Kept, units and all — the resurrect never touches `inventory`. - expect(large?.onHand, `loop ${loop}`).toBe(9); - } else { - editTookIt++; - } - } - - // Both interleavings occurred, so both branches above were genuinely - // asserted rather than merely written down. - expect(resurrectKept, "the resurrect-first branch fired").toBeGreaterThan(0); - expect(editTookIt, "the claimant-first branch fired").toBeGreaterThan(0); - }, 180_000); -}); diff --git a/packages/store-postgres/tsconfig.json b/packages/store-postgres/tsconfig.json deleted file mode 100644 index 47a63d63..00000000 --- a/packages/store-postgres/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist/tsc", - "emitDeclarationOnly": true, - "rootDir": "." - }, - "include": ["src", "test", "tsdown.config.ts", "vitest.config.ts"], - "references": [{ "path": "../domain" }] -} diff --git a/packages/store-postgres/tsdown.config.ts b/packages/store-postgres/tsdown.config.ts deleted file mode 100644 index 4a4e148d..00000000 --- a/packages/store-postgres/tsdown.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { defineConfig } from "tsdown"; - -export default defineConfig({ - entry: ["src/index.ts", "src/pg.ts", "src/testing.ts"], - format: ["esm"], - dts: true, -}); diff --git a/packages/store-postgres/vitest.config.ts b/packages/store-postgres/vitest.config.ts deleted file mode 100644 index b8ac13f9..00000000 --- a/packages/store-postgres/vitest.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - name: "store-postgres", - include: ["test/**/*.test.ts"], - // Mirror the root config's guard so BOTH invocation paths (aggregated root - // run AND `pnpm -C packages/store-postgres exec vitest`) serialize pg test - // FILES when Postgres is enabled: every pg file opens schema-isolated pools - // against ONE database, and the multiline no-oversell race alone needs a - // large pool — fully parallel files can spike past max_connections and flake - // with "sorry, too many clients already". The sqlite/fake tier (no - // PG_CONNECTION_STRING) keeps full parallelism for the fast local loop. - fileParallelism: process.env.PG_CONNECTION_STRING === undefined, - }, -}); diff --git a/scripts/pg-test-files.sh b/scripts/pg-test-files.sh index e211ae05..b72cd647 100755 --- a/scripts/pg-test-files.sh +++ b/scripts/pg-test-files.sh @@ -1,9 +1,10 @@ #!/usr/bin/env bash # Lists test files that actually need a live Postgres connection: they either # read process.env.PG_CONNECTION_STRING directly, or go through the -# describe-each-dialect harness (each store package's own -# test/describe-each-dialect.ts — store-postgres and store-emdash both have one), -# which does. Verified equivalent to a full import-graph walk as of 2026-08-04. +# describe-each-dialect harness (`store-emdash`'s own +# test/describe-each-dialect.ts — @otta-sh/store-postgres, which had its own +# copy, is gone), which does. Verified equivalent to a full import-graph walk +# as of 2026-08-04. # # `test:pg` filters `vitest run` down to this list so the integration job # doesn't re-run the ~150 sqlite/fake-only files the unit job already covered. diff --git a/tsconfig.json b/tsconfig.json index 98176d01..aa54c130 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,11 +3,9 @@ "references": [ { "path": "packages/domain" }, { "path": "packages/admin-presentation" }, - { "path": "packages/store-postgres" }, { "path": "packages/store-emdash" }, { "path": "packages/payments-stripe" }, { "path": "packages/payments-x402" }, - { "path": "packages/service" }, { "path": "packages/plugin" }, { "path": "packages/admin-react" } ] From e6f6c7ddcd4e777df288439943b69db860376505 Mon Sep 17 00:00:00 2001 From: Vedanshu Date: Sun, 20 Sep 2026 12:32:33 +0000 Subject: [PATCH 2/5] [Plugin][Test] Delete the HTTP transport: clients, wire tests, live-service harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit INC-D3b. INC-D3a collapsed `makeCommerceClient`/`makeAdminClients` to the in-process tier unconditionally, which orphaned every HTTP client behind them. They go here, with the tests that only ever exercised the wire. Deleted: `HttpCommerceClient` (whole module), and the four admin HTTP clients — `AdminOrdersClient`, `AdminProductsClient`, `AdminRulesClient`, `ReportingSettingsClient` — with their `*Options` types; the six `http-commerce-client*.test.ts` suites; `commerce-client-contract.http.test.ts`; `test/helpers/start-live-service.ts`; and `@hono/node-server`, which had no other user. `commerceClientContract` now has one tier, the in-process one. What did NOT go: the wire types and the four `*Surface` ports, which the console's surviving in-process code depends on throughout. Each Surface was `Pick`, so deleting the class would have taken the port with it. Each is now an explicit interface with the signatures lifted verbatim — Orders 12 methods, Products 6, Rules 25, ReportingSettings 6. That is a strictly better home for them: the `Pick`-over-a-nominal-class idiom existed only because `#`-private fields made the class unassignable, and the property it was protecting ("every method is listed, so adding one without deciding what the in-process tier does is a compile error") is preserved. The four `InProcess*Client` classes now `implements` their Surface, so tsc checks the lifted signatures structurally rather than taking them on trust. The files are renamed to say what they now are: `admin-*-client.ts` → `admin-*-surface.ts`, `reporting-client.ts` → `reporting-settings-surface.ts`. `test/helpers/stub-commerce-server.ts` is KEPT, renamed to `stub-http-server.ts`. The plan had it deleted as a stand-in for the HTTP transport, but it is not one any more: it is a generic recording HTTP server, and three surviving sandbox suites use it for things that have nothing to do with commerce — `sandbox-harness.test.ts` and `in-process-egress.sandbox.test.ts` as an email-API endpoint proving `allowedHosts` egress control, `stripe-settle-route.sandbox.test.ts` to back a Settings re-render while asserting no secret leaks. Deleting it would have silently dropped that coverage. `playwright.config.ts` was a live break, not just stale prose: its webServer stack still booted `packages/service/src/index.ts` and waited on its `/health`. The stack is one process now, so that entry and the dead `E2E_SERVICE_URL` knob are gone; §0.3's rule that no e2e surface may name port 5432 is untouched and still enforced by `harness.spec.ts`. The rest is prose: comments across the plugin, domain, store-emdash, admin-react and the staging site that cited `@otta-sh/service` or `@otta-sh/store-postgres` as present tense now read as history. The synthetic fixture strings in `depcruise-boundary.test.ts` are deliberately left — they test unresolved-specifier handling and must name packages that do not resolve. On the plan's open question for INC-D4: `packages/plugin/src/types.ts` is unchanged, because the premise was a misreading. It holds no commerce wire types at all — every hand-mirrored block in it mirrors the HOST (EmDash's Block Kit contract) plus `HttpAccess`, the `ctx.http` capability surface, which stays for the email API and the payment gateways. The types that did mirror the service's wire format are the `*Wire` interfaces in the admin surfaces and `commerce-client.ts`; the call to keep them, and the narrower question left for D4, is recorded at the top of `commerce-client.ts`. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CQbJYWWm8tf8owshm7XRp8 --- .../admin-react/src/orders/order-detail.tsx | 8 +- .../admin-react/src/orders/orders-list.tsx | 3 +- .../src/ports/product-commerce-store.ts | 21 +- packages/domain/src/ports/reporting-store.ts | 11 +- .../domain/src/product-commerce/errors.ts | 22 +- .../domain/src/product-commerce/use-cases.ts | 20 +- .../src/testing/order-notes-store-contract.ts | 5 +- .../src/testing/order-timeline-contract.ts | 5 +- .../cart/cart-store.contract.fake.test.ts | 4 +- .../order-cancellation-contract.fake.test.ts | 5 +- .../order-fulfillment-contract.fake.test.ts | 3 +- .../order-timeline-contract.fake.test.ts | 7 +- .../order-transition-contract.fake.test.ts | 6 +- .../domain/test/product-commerce.type-test.ts | 7 +- packages/plugin/package.json | 3 - .../plugin/src/admin/admin-orders-client.ts | 844 ------------------ .../plugin/src/admin/admin-orders-surface.ts | 506 +++++++++++ .../plugin/src/admin/admin-products-client.ts | 562 ------------ .../src/admin/admin-products-surface.ts | 292 ++++++ .../plugin/src/admin/admin-rules-client.ts | 646 -------------- .../plugin/src/admin/admin-rules-surface.ts | 348 ++++++++ packages/plugin/src/admin/coupons-page.ts | 9 +- .../admin/in-process-admin-orders-client.ts | 17 +- .../admin/in-process-admin-products-client.ts | 8 +- .../admin/in-process-admin-rules-client.ts | 6 +- .../in-process-reporting-settings-client.ts | 8 +- .../plugin/src/admin/make-admin-clients.ts | 8 +- packages/plugin/src/admin/orders-actions.ts | 2 +- .../plugin/src/admin/orders-console-route.ts | 2 +- packages/plugin/src/admin/orders-read.ts | 2 +- packages/plugin/src/admin/products-actions.ts | 2 +- .../src/admin/products-console-route.ts | 8 +- packages/plugin/src/admin/products-read.ts | 4 +- packages/plugin/src/admin/reporting-client.ts | 284 ------ .../src/admin/reporting-settings-surface.ts | 160 ++++ packages/plugin/src/admin/reports-page.ts | 2 +- packages/plugin/src/admin/settings-form.ts | 25 +- packages/plugin/src/admin/shipping-page.ts | 2 +- packages/plugin/src/admin/tax-page.ts | 2 +- .../src/commerce/make-commerce-client.ts | 10 +- packages/plugin/src/cron/sweeps.ts | 5 +- packages/plugin/src/index.ts | 22 +- .../src/product-commerce/commerce-client.ts | 106 ++- .../product-commerce/http-commerce-client.ts | 696 --------------- .../plugin/src/storefront/account-routes.ts | 7 +- packages/plugin/src/storefront/cart-routes.ts | 11 +- .../src/storefront/checkout-route-input.ts | 11 +- .../plugin/src/sync/parse-product-title.ts | 7 +- packages/plugin/src/sync/variants.ts | 9 +- .../src/webhooks/stripe-settle-route.ts | 5 +- .../commerce-client-contract.http.test.ts | 276 ------ .../contracts/commerce-client-contract.ts | 88 +- .../plugin/test/helpers/start-live-service.ts | 168 ---- ...commerce-server.ts => stub-http-server.ts} | 20 +- ...http-commerce-client-cart-order-id.test.ts | 84 -- .../test/http-commerce-client-cart.test.ts | 165 ---- .../http-commerce-client-checkout.test.ts | 265 ------ .../http-commerce-client-entitlement.test.ts | 74 -- ...commerce-client-service-token.live.test.ts | 63 -- ...http-commerce-client-service-token.test.ts | 112 --- .../plugin/test/http-commerce-client.test.ts | 47 - .../test/in-process-egress.sandbox.test.ts | 9 +- .../plugin/test/orders-refund-key.test.ts | 2 +- packages/plugin/test/payment-secrets.test.ts | 8 +- .../test/reports-refunded-fallback.test.ts | 2 +- .../plugin/test/resolve-stock-context.test.ts | 2 +- packages/plugin/test/sandbox-harness.test.ts | 9 +- .../test/stripe-settle-route.sandbox.test.ts | 11 +- .../store-emdash/src/emdash-order-store.ts | 6 +- packages/store-emdash/src/id-gen.ts | 9 +- .../test/cart-fence.dialects.test.ts | 4 +- .../test/describe-each-dialect.ts | 4 +- .../test/hold-expiry.dialects.test.ts | 4 +- .../test/no-oversell-cart.pg.test.ts | 4 +- .../no-oversell-checkout-multiline.pg.test.ts | 7 +- .../test/no-oversell-checkout.pg.test.ts | 5 +- .../test/order-flow.dialects.test.ts | 5 +- .../store-emdash/test/refund-race.pg.test.ts | 8 +- .../reserve-cart-line-crash.dialects.test.ts | 5 +- .../resolve-reconciliation-race.pg.test.ts | 8 +- .../test/storage-access.dialects.test.ts | 4 +- pnpm-lock.yaml | 99 -- sites/staging/e2e/harness.spec.ts | 9 +- sites/staging/e2e/harness.ts | 41 +- sites/staging/e2e/registry.ts | 5 +- sites/staging/src/lib/cart-view.ts | 33 +- sites/staging/src/lib/email.ts | 5 +- sites/staging/test/cart-page.test.ts | 42 +- 88 files changed, 1721 insertions(+), 4759 deletions(-) delete mode 100644 packages/plugin/src/admin/admin-orders-client.ts create mode 100644 packages/plugin/src/admin/admin-orders-surface.ts delete mode 100644 packages/plugin/src/admin/admin-products-client.ts create mode 100644 packages/plugin/src/admin/admin-products-surface.ts delete mode 100644 packages/plugin/src/admin/admin-rules-client.ts create mode 100644 packages/plugin/src/admin/admin-rules-surface.ts delete mode 100644 packages/plugin/src/admin/reporting-client.ts create mode 100644 packages/plugin/src/admin/reporting-settings-surface.ts delete mode 100644 packages/plugin/src/product-commerce/http-commerce-client.ts delete mode 100644 packages/plugin/test/commerce-client-contract.http.test.ts delete mode 100644 packages/plugin/test/helpers/start-live-service.ts rename packages/plugin/test/helpers/{stub-commerce-server.ts => stub-http-server.ts} (71%) delete mode 100644 packages/plugin/test/http-commerce-client-cart-order-id.test.ts delete mode 100644 packages/plugin/test/http-commerce-client-cart.test.ts delete mode 100644 packages/plugin/test/http-commerce-client-checkout.test.ts delete mode 100644 packages/plugin/test/http-commerce-client-entitlement.test.ts delete mode 100644 packages/plugin/test/http-commerce-client-service-token.live.test.ts delete mode 100644 packages/plugin/test/http-commerce-client-service-token.test.ts delete mode 100644 packages/plugin/test/http-commerce-client.test.ts diff --git a/packages/admin-react/src/orders/order-detail.tsx b/packages/admin-react/src/orders/order-detail.tsx index 7449ac2e..467c0da0 100644 --- a/packages/admin-react/src/orders/order-detail.tsx +++ b/packages/admin-react/src/orders/order-detail.tsx @@ -262,8 +262,9 @@ const PILLED_ORDER_STATE = "failed"; * with a visible ellipsis so an operator can SEE that it was cut. * * `buyerRef` is unverified free text up to 320 characters - * (`min(1).max(320)`, format unchecked — `packages/service/src/schemas.ts`, - * `packages/plugin/src/storefront/checkout-route-input.ts`) landing inside a + * (`min(1).max(320)`, format unchecked — the bound now lives solely in + * `packages/plugin/src/storefront/checkout-route-input.ts`, the standalone + * service's copy of it having been deleted with that package) landing inside a * ~200-character sentence (`order-refund-copy.ts`'s `CONFIRM_BUDGET`). That * function already refuses to overflow the budget, but its own answer to * overflow is to DROP the recipient silently and say "this order's buyer" — @@ -772,7 +773,8 @@ export function OrderDetail({ // meant to stay fully selectable and copy-pasteable, which a // clamp inside the DOM cannot honestly promise. `buyerRef` is // caller-supplied free text up to 320 characters with no format - // check (`packages/service/src/schemas.ts`), so the heading's ONE + // check (`plugin/src/storefront/checkout-route-input.ts`), so the + // heading's ONE // unbroken token has to be able to WRAP rather than push the rest // of the line — including the date — off the viewport. THE // PRINCIPLE: layout containment via CSS wherever the full value diff --git a/packages/admin-react/src/orders/orders-list.tsx b/packages/admin-react/src/orders/orders-list.tsx index df635237..ef56c073 100644 --- a/packages/admin-react/src/orders/orders-list.tsx +++ b/packages/admin-react/src/orders/orders-list.tsx @@ -1574,7 +1574,8 @@ export function OrdersList({ // LAYOUT CONTAINMENT, NOT STRING CLAMPING (review finding N1, // director ruling). `buyerRef` is caller-supplied free text up // to 320 characters with no format check - // (`packages/service/src/schemas.ts`), and this column has no + // (`plugin/src/storefront/checkout-route-input.ts`), and this + // column has no // bound of its own under the table's `table-layout: auto`: one // unbroken long token would otherwise widen this column and // push every column to its right — Status, Order #, Total — off diff --git a/packages/domain/src/ports/product-commerce-store.ts b/packages/domain/src/ports/product-commerce-store.ts index 644b7036..222dd6a1 100644 --- a/packages/domain/src/ports/product-commerce-store.ts +++ b/packages/domain/src/ports/product-commerce-store.ts @@ -67,9 +67,10 @@ export interface ProductListFilter { * threshold and passes the number through, exactly like every other value * on this filter. * - * DOMAIN: a NON-NEGATIVE INTEGER — mirrors the HTTP boundary's own - * validation (`packages/service/src/schemas.ts`'s `lowStockQuery` / - * `settingsBody`: `z.number().int().nonnegative()`), and the ONLY domain + * DOMAIN: a NON-NEGATIVE INTEGER — mirrors the plugin's own boundary + * validation (`requireLowStockThreshold` in + * `in-process-admin-products-client.ts`, and its sibling bound in + * `in-process-reporting-settings-client.ts`), and the ONLY domain * every adapter agrees on. A value outside it (fractional, negative, * `NaN`, `±Infinity`) throws `InvalidLowStockThresholdError` — checked by * EVERY adapter via the shared `isValidLowStockThreshold` guard, BEFORE @@ -81,15 +82,11 @@ export interface ProductListFilter { * different answers to one input, which is what the shared guard exists * to make unreachable. Contract-pinned so the three can never drift apart. * - * NOT YET ENFORCED AT THIS FILTER'S OWN HTTP BOUNDARY: `lowStockQuery` and - * `settingsBody` (above) constrain the OTHER two `lowStockThreshold` - * call sites, but `productListFilterSchema` in `packages/service/src/ - * schemas.ts` — the schema this filter's own list/count query param would - * parse through — has no `lowStockThreshold` field at all yet, so it - * cannot reject a bad one before this port does. Whichever increment wires - * a query param to this field MUST add the same `z.number().int() - * .nonnegative()` there and map `InvalidLowStockThresholdError` to a 400, - * or a bad value 500s instead of 400s. + * ALSO ENFORCED AT THIS FILTER'S OWN BOUNDARY: + * `in-process-admin-products-client.ts`'s list/count filter now runs + * `lowStockThreshold` through the same `requireLowStockThreshold` bound + * before it reaches this port, so a bad value is a typed input refusal + * rather than a 500. * * A row matches iff BOTH hold: * - its sku resolves to a KNOWN `inventory` row — the same LEFT JOIN diff --git a/packages/domain/src/ports/reporting-store.ts b/packages/domain/src/ports/reporting-store.ts index 25bde018..23352e51 100644 --- a/packages/domain/src/ports/reporting-store.ts +++ b/packages/domain/src/ports/reporting-store.ts @@ -3,11 +3,12 @@ import type { Cents, Currency } from "../money/cents.js"; /** * `ReportingStore` (Phase 7 §4/§6). A READ-ONLY port over the existing * orders / order_totals / order_items / inventory tables — it introduces no new - * write invariant. The port is dialect-agnostic intent only; the SQL (including - * the dialect-branched period-bucket expression and the revenue-counting state - * allow-list) lives entirely in the adapter (`store-postgres`), which is what - * buys the single dialect-parity contract suite this phase's headline test - * requires. + * write invariant. The port is dialect-agnostic intent only; the aggregation + * logic (including the revenue-counting state allow-list) lives entirely in + * the adapter — `store-emdash`'s `EmdashReportingStore` today, maintaining + * per-period bucket documents rather than the SQL `store-postgres` (now + * deleted) once branched by dialect — which is what buys the single + * dialect-parity contract suite this phase's headline test requires. * * Money in and out is integer minor units (`Cents`): every aggregate SUMs * integer `*_cents` columns and returns an integer — no float ever touches a diff --git a/packages/domain/src/product-commerce/errors.ts b/packages/domain/src/product-commerce/errors.ts index 8d1da607..34d23983 100644 --- a/packages/domain/src/product-commerce/errors.ts +++ b/packages/domain/src/product-commerce/errors.ts @@ -2,7 +2,8 @@ * Domain error for the "create then price" invariant (Phase 1 §1 case 3 / §5). * A commercial upsert with a missing/empty `product_id` is rejected before any * row is minted — enforced at every `ProductCommerceStore` adapter (fake, - * Kysely) and mapped to HTTP 400 by `@otta-sh/service`. + * store-emdash) and surfaced as a typed input refusal by the plugin's route + * layer. */ export class MissingProductIdError extends Error { constructor() { @@ -37,10 +38,9 @@ export class MissingVariantKeyError extends Error { * Domain error for a live-SKU uniqueness conflict (review F2): a merchant * assigning a SKU another LIVE (non-deleted) product already holds — the * most likely real merchant input error. Raised by every - * `ProductCommerceStore` adapter (the fake's live-sku check; the Kysely - * store's narrowly-scoped catch of the `product_commerce_live_sku_unique` - * partial-index violation) and mapped to a structured HTTP 409 `SKU_TAKEN` - * by `@otta-sh/service` — never an opaque 500. + * `ProductCommerceStore` adapter (the fake's live-sku check; the store-emdash + * adapter's own live-sku conflict check) and surfaced as a structured + * `SKU_TAKEN` refusal by the plugin's route layer — never an opaque 500. * * ALSO ARBITRATES VARIANT GRAIN, unchanged: a sku names exactly ONE live * sellable unit, and "live sellable unit" spans live `product_commerce` rows AND @@ -170,9 +170,9 @@ export class SkuHeldStockError extends Error { * Domain validation error for a standalone product EDIT (admin-UX Increment 2, * slice 2): a field the merchant supplied is out of the domain's bounds — a * price that is not strictly positive, or a negative weight/dimension. Thrown - * by `updateProductCommerceFields` BEFORE the guarded store write, mapped to - * HTTP 400 by `@otta-sh/service`. Defense-in-depth alongside the service's zod - * layer and the plugin's per-field validation; branded `Cents` already rejects + * by `updateProductCommerceFields` BEFORE the guarded store write, surfaced as + * a typed input refusal by the plugin's route layer. Defense-in-depth + * alongside the plugin's per-field validation; branded `Cents` already rejects * a float/negative/non-safe-integer price at the type + `cents()` boundary, so * this guard's job is the domain rule those layers cannot express: price > 0. * `field` names the offending input so the boundary can render it per-field. @@ -191,9 +191,9 @@ export class InvalidProductFieldError extends Error { * Domain validation error for `ProductListFilter.lowStockThreshold` (the * admin Products list/count low-stock predicate). The port's declared domain * is a NON-NEGATIVE INTEGER — the only domain every adapter agrees on, and - * the same domain the HTTP boundary already validates to - * (`packages/service/src/schemas.ts`'s `lowStockQuery`/`settingsBody`: - * `z.number().int().nonnegative()`). Outside that domain the raw adapters + * the same domain the plugin's own boundary validation already enforces + * (`z.number().int().nonnegative()`, restated there since no schema package + * survives to import it from). Outside that domain the raw adapters * silently DISAGREE, which is exactly what this error exists to prevent: * measured, a fractional threshold (e.g. `2.5`) filters cleanly in the fake * and SQLite but Postgres rejects it binding an `integer` column diff --git a/packages/domain/src/product-commerce/use-cases.ts b/packages/domain/src/product-commerce/use-cases.ts index 0be1227d..78e24592 100644 --- a/packages/domain/src/product-commerce/use-cases.ts +++ b/packages/domain/src/product-commerce/use-cases.ts @@ -82,8 +82,8 @@ export async function getProductCommerce( * Batch catalog read (Phase 2 §6) — a query, not a command (no idempotency * key). Straight pass-through: the semantics (missing ids omitted, * commerce-complete rows only, intra-store `inStock` join) are the PORT's - * contract; this wrapper exists so `@otta-sh/service` composes use-cases, not - * store methods, like its siblings. + * contract; this wrapper exists so the plugin's route/client layer composes + * use-cases, not store methods, like its siblings. */ export async function listProductCommerceByIds( store: ProductCommerceStore, @@ -98,8 +98,8 @@ export async function listProductCommerceByIds( * rules the branded types cannot express), then the store's optimistic * compare-and-set (`ProductCommerceStore.updateCommerceFields`) — the port doc * carries the guard semantics (replay dedupe, not_found, stale, currency - * integrity). Exists so `@otta-sh/service` composes a use-case, not a store - * method, like its siblings. + * integrity). Exists so the plugin's route/client layer composes a use-case, + * not a store method, like its siblings. * * Validation (throws `InvalidProductFieldError`, mapped to 400 upstream): * - `price.amount` must be STRICTLY POSITIVE — a $0 commerce price is not a @@ -228,8 +228,8 @@ export async function softDeleteProductCommerce( * (unknown/soft-deleted/already-active rows are no-ops; a soft-deleted * product is never resurrected by a publish; a stale `contentUpdatedAt` * watermark arriving after a newer lifecycle event is a no-op so out-of-order - * publish/unpublish delivery converges). Exists so `@otta-sh/service` composes - * use-cases, not store methods, like its siblings. + * publish/unpublish delivery converges). Exists so the plugin's route/client + * layer composes use-cases, not store methods, like its siblings. */ export async function activateProductCommerce( store: ProductCommerceStore, @@ -247,8 +247,8 @@ export async function activateProductCommerce( * (unknown/soft-deleted/already-inactive rows are no-ops; deactivation flips * only the publish gate and never touches `deletedAt`; a stale * `contentUpdatedAt` watermark is a no-op so out-of-order delivery converges). - * Exists so `@otta-sh/service` composes use-cases, not store methods, like its - * siblings. + * Exists so the plugin's route/client layer composes use-cases, not store + * methods, like its siblings. */ export async function deactivateProductCommerce( store: ProductCommerceStore, @@ -267,8 +267,8 @@ export async function deactivateProductCommerce( * cache and its presence — no sku, so there is nothing for an inventory row to * be seeded against and no second port to compose (the deliberate contrast with * `upsertProductCommerce`, which always attempts a seed precisely because it CAN - * carry a sku). Exists so `@otta-sh/service` composes a use-case, not a store - * method, like its siblings. + * carry a sku). Exists so the plugin's route/client layer composes a + * use-case, not a store method, like its siblings. */ export async function upsertProductVariant( store: ProductCommerceStore, diff --git a/packages/domain/src/testing/order-notes-store-contract.ts b/packages/domain/src/testing/order-notes-store-contract.ts index 92f11dfb..dd1323ca 100644 --- a/packages/domain/src/testing/order-notes-store-contract.ts +++ b/packages/domain/src/testing/order-notes-store-contract.ts @@ -19,8 +19,9 @@ export interface OrderNotesStoreContractOptions { * note, list notes in append order, per-order scoping, and once-only idempotent * replay. Append-only — no edit/delete surface exists in this slice. Runs against * the fake first, then each DB dialect. Money-free (a note is a plain merchant - * annotation), so there is no concurrency/no-oversell case HERE — the pg-backed - * concurrent-replay race lives in the store-postgres dialects test. + * annotation), so there is no concurrency/no-oversell case HERE. + * `@otta-sh/store-postgres` is gone; no pg-backed concurrent-replay race for + * this contract has been re-created in `store-emdash` yet. */ export function orderNotesStoreContract( makeHarness: () => Promise, diff --git a/packages/domain/src/testing/order-timeline-contract.ts b/packages/domain/src/testing/order-timeline-contract.ts index 4ac5e2e1..94940d85 100644 --- a/packages/domain/src/testing/order-timeline-contract.ts +++ b/packages/domain/src/testing/order-timeline-contract.ts @@ -91,8 +91,9 @@ function addNote( * (created / notes / fulfillment / cancellation / reconciliation resolution) into * one chronological view; and a historical order (no events) still yields a * useful partial timeline. Runs against the fake first, then each SQL dialect. - * The Postgres-required exactly-one-event-under-race cases live in the - * store-postgres dialects test (a fake/SQLite can't race). + * `@otta-sh/store-postgres` is gone; its Postgres-required + * exactly-one-event-under-race cases (a fake/SQLite can't race) have not been + * re-created against `store-emdash`'s `EmdashOrderStore` yet. */ export function orderTimelineContract( makeHarness: () => Promise, diff --git a/packages/domain/test/cart/cart-store.contract.fake.test.ts b/packages/domain/test/cart/cart-store.contract.fake.test.ts index c71ae7ee..7158caaa 100644 --- a/packages/domain/test/cart/cart-store.contract.fake.test.ts +++ b/packages/domain/test/cart/cart-store.contract.fake.test.ts @@ -2,5 +2,7 @@ import { cartStoreContract } from "@otta-sh/domain/testing"; import { makeFakeCartHarness } from "./fake-harness.js"; // The reusable cart behavioral spec (§1 cases 1–8) runs against its first -// adapter — the IO-free fake — before any DB dialect (re-run in store-postgres). +// adapter — the IO-free fake — before any DB dialect (re-run in +// store-emdash's cart-store-contract.dialects.test.ts; @otta-sh/store-postgres +// is gone). cartStoreContract(async () => makeFakeCartHarness(), { dialect: "fake" }); diff --git a/packages/domain/test/orders/order-cancellation-contract.fake.test.ts b/packages/domain/test/orders/order-cancellation-contract.fake.test.ts index 9021f966..40021af7 100644 --- a/packages/domain/test/orders/order-cancellation-contract.fake.test.ts +++ b/packages/domain/test/orders/order-cancellation-contract.fake.test.ts @@ -8,8 +8,9 @@ import { // The order-cancellation spec (admin-UX Increment 1, "cancel with reason") run // against the in-memory fake first. The pg/sqlite dialect runs — incl. the -// concurrent-cancel and cancel-vs-recordFulfillment races — live in -// @otta-sh/store-postgres. +// concurrent-cancel and cancel-vs-recordFulfillment races — now live in +// store-emdash's order-cancellation-contract.dialects.test.ts; +// @otta-sh/store-postgres is gone. orderCancellationContract( async () => { diff --git a/packages/domain/test/orders/order-fulfillment-contract.fake.test.ts b/packages/domain/test/orders/order-fulfillment-contract.fake.test.ts index 6363eb3f..68f8dff3 100644 --- a/packages/domain/test/orders/order-fulfillment-contract.fake.test.ts +++ b/packages/domain/test/orders/order-fulfillment-contract.fake.test.ts @@ -8,7 +8,8 @@ import { // The order-fulfillment spec (admin-UX Increment 1) run against the in-memory // fake first. The pg/sqlite dialect runs — incl. the concurrent record + the -// record-vs-cancel race — live in @otta-sh/store-postgres. +// record-vs-cancel race — now live in store-emdash's +// order-fulfillment-contract.dialects.test.ts; @otta-sh/store-postgres is gone. orderFulfillmentContract( async () => { diff --git a/packages/domain/test/orders/order-timeline-contract.fake.test.ts b/packages/domain/test/orders/order-timeline-contract.fake.test.ts index 9fc323c6..468b0cf0 100644 --- a/packages/domain/test/orders/order-timeline-contract.fake.test.ts +++ b/packages/domain/test/orders/order-timeline-contract.fake.test.ts @@ -7,9 +7,10 @@ import { } from "@otta-sh/domain/testing"; // The order timeline / audit spec (admin-UX Increment 1, timeline slice) run -// against the in-memory fake first. The pg/sqlite dialect runs — incl. the -// Postgres-required exactly-one-event-under-race cases — live in -// @otta-sh/store-postgres. +// against the in-memory fake first. The pg/sqlite dialect runs now live in +// store-emdash's order-timeline-contract.dialects.test.ts — @otta-sh/store-postgres +// is gone, and its Postgres-required exactly-one-event-under-race cases have +// not been re-created there yet. orderTimelineContract( async () => { diff --git a/packages/domain/test/orders/order-transition-contract.fake.test.ts b/packages/domain/test/orders/order-transition-contract.fake.test.ts index 0546e25f..ab857388 100644 --- a/packages/domain/test/orders/order-transition-contract.fake.test.ts +++ b/packages/domain/test/orders/order-transition-contract.fake.test.ts @@ -8,7 +8,11 @@ import { // Step 5.4: lift the order state-machine + exactly-once-email spec into the // shared contract suite, run against the in-memory fake first. (The pg/sqlite -// dialect runs, incl. the atomicity case, live in @otta-sh/store-postgres.) +// dialect runs now live in store-emdash's order-transition-contract.dialects.test.ts +// — @otta-sh/store-postgres is gone. The atomicity case moved with the store +// change: this document store has no transaction to roll back, so it is +// asserted instead in store-emdash's order-crash-seams.dialects.test.ts, which +// parks the single compare-and-set and reads the documents back.) orderTransitionContract( async () => { diff --git a/packages/domain/test/product-commerce.type-test.ts b/packages/domain/test/product-commerce.type-test.ts index 23a4c6b7..e9adc95e 100644 --- a/packages/domain/test/product-commerce.type-test.ts +++ b/packages/domain/test/product-commerce.type-test.ts @@ -39,9 +39,10 @@ const badAmount: UpsertProductCommerceInput = { * is the content sync's `upsert` (see `UpsertProductCommerceInput.title`, still * present above). The guarded admin edit must not carry it, so re-adding a Title * input to the admin form fails to COMPILE rather than failing silently at - * runtime. Rung 1 is the port type itself; rung 3 is the `.strict()`-backed HTTP - * test in `packages/service/test/admin-product-edit-http.test.ts`; rung 4 is the - * "Deliberately EXCLUDES" doc block on the port. + * runtime. Rung 1 is the port type itself; rung 3 is the "G2 / ADR-0013" case in + * `packages/plugin/test/products-actions.sandbox.test.ts` (the standalone + * `@otta-sh/service`'s `.strict()`-backed HTTP test of the same name is gone); + * rung 4 is the "Deliberately EXCLUDES" doc block on the port. * Reasoning: `adr/0013-product-title-is-cms-owned.md`. */ const badEditTitle: UpdateProductCommerceFieldsInput = { diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 25b4ad12..d7b4367f 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -48,9 +48,6 @@ "@otta-sh/store-emdash": "workspace:*" }, "devDependencies": { - "@hono/node-server": "catalog:", - "@otta-sh/service": "workspace:*", - "@otta-sh/store-postgres": "workspace:*", "@types/node": "catalog:", "tsdown": "catalog:", "typescript": "catalog:", diff --git a/packages/plugin/src/admin/admin-orders-client.ts b/packages/plugin/src/admin/admin-orders-client.ts deleted file mode 100644 index 8e5a7050..00000000 --- a/packages/plugin/src/admin/admin-orders-client.ts +++ /dev/null @@ -1,844 +0,0 @@ -import type { HttpAccess } from "../types.js"; -import { CURSOR_REFUSED, isCursorRefusal } from "./cursor-refusal.js"; - -/** - * A tiny `ctx.http`-only client for the admin Orders console service surface - * (view-only list + detail, plus the existing status transition). Same transport - * discipline as `ReportingSettingsClient` / `HttpCommerceClient` (no new - * primitive): the injected `ctx.http.fetch` is the ONLY egress, money is integer - * minor units + ISO-4217 currency on the wire, and the wire types are defined - * LOCALLY — this module NEVER imports `@otta-sh/domain`, keeping the plugin - * sandbox-clean (enforced by the dependency-cruiser rule, MOD-4). `#fetch` is - * `#`-prefixed so the sandbox-clean grep guard sees no bare fetch call. - */ - -export interface OrderSummaryWire { - id: string; - state: string; - currency: string; - buyerRef: string; - customerId: string | null; - paymentMethod: string | null; - createdAt: string; - totalCents: number; - reconciliationFlag: boolean; -} - -export interface OrderLineWire { - sku: string; - title: string; - unitPriceCents: number; - currency: string; - quantity: number; - fulfillmentKind: string; -} - -export interface OrderTotalsWire { - currency: string; - subtotalCents: number; - discountCents: number; - shippingCents: number; - taxCents: number; - totalCents: number; - appliedCouponCode: string | null; - /** The chosen shipping zone id (ADR-0009), or null when none was selected. - * DISPLAY-ONLY: rendered next to the captured ship-to country so a human can - * spot a "domestic zone / foreign country" mismatch — no matching/validation. */ - shippingZoneId?: string | null; -} - -/** The immutable shipping-address snapshot captured on an order at checkout - * (ADR-0009), or null when none was captured (a historical order predating - * capture, or a digital-only order). This IS the authoritative ship-to for the - * order — unlike {@link AddressWire} (the mutable profile book), it never changes - * after checkout. Optional contact fields are null when the buyer omitted them. */ -export interface OrderAddressWire { - name: string; - line1: string; - line2: string | null; - city: string; - region: string | null; - postalCode: string; - country: string; - email: string | null; - phone: string | null; -} - -/** The admin disposition recorded when an order's reconciliation flag was - * resolved (admin-UX Increment 1); null while unflagged/unresolved. */ -export interface ReconciliationResolutionWire { - outcome: string; - reason: string; - resolvedBy: string; - resolvedAt: string; -} - -/** The shipping fulfillment recorded on an order (admin-UX Increment 1); null - * until the order ships with tracking. `trackingUrl` is optional (null when the - * admin recorded none); `shippedAt` is the ship time, `recordedAt` the server - * stamp. */ -export interface OrderFulfillmentWire { - carrier: string; - trackingNumber: string; - trackingUrl: string | null; - shippedAt: string; - recordedBy: string; - recordedAt: string; -} - -/** The structured cancellation recorded on an order (admin-UX Increment 1, - * "cancel with reason"); null while never cancelled OR cancelled via the bare - * transition (no reason on file — an honest back-compat state). */ -export interface OrderCancellationWire { - reason: string; - detail: string | null; - cancelledBy: string; - cancelledAt: string; -} - -export interface OrderDetailWire { - id: string; - state: string; - currency: string; - paymentMethod: string | null; - buyerRef: string; - customerId: string | null; - holdExpiresAt: string; - createdAt: string; - reconciliationFlag: string | null; - reconciliationResolution: ReconciliationResolutionWire | null; - fulfillment: OrderFulfillmentWire | null; - cancellation: OrderCancellationWire | null; - /** The immutable ship-to snapshot captured at checkout (ADR-0009); null when - * the order predates capture or is digital-only. Authoritative — never the - * profile book (which is prefill/context, on the customer panel). */ - shippingAddress: OrderAddressWire | null; - totals: OrderTotalsWire; - lines: OrderLineWire[]; -} - -/** The list filter the console builds from its filter form. `states` is an OR set - * (serialized to a CSV `states=` param); the window is half-open `[from, to)`. */ -export interface OrdersListFilter { - states?: string[]; - from?: string; - to?: string; - search?: string; -} - -export interface OrdersListResult { - orders: OrderSummaryWire[]; - /** Opaque keyset cursor for the next page, or null on the last page. */ - nextCursor: string | null; - /** - * Exact number of orders matching the ACTIVE FILTER — the whole set, not - * this page (INC-23). - * - * OPTIONAL for one reason only: a service older than the field omits it, and - * a renderer must then fall back to the page-scoped count it always had - * ("25 orders on this page"). Never defaulted to `0` — that would caption a - * page of rows with a count of none. - */ - total?: number; - /** - * THIS IS PAGE ONE, and it is page one because the cursor the caller asked - * with was REFUSED — mismatched against these filters, or undecodable — and - * {@link AdminOrdersClient.listOrders} re-issued the request without it. - * - * ABSENT ON EVERY ORDINARY PAGE, including an ordinary first page: the flag - * means "you asked for a page you did not get", which is a thing a renderer - * must be able to say out loud (an address still naming that page has to be - * corrected, and an operator who followed a link to it deserves a sentence). - * A caller that ignores it renders a correct list, one page from where the - * caller meant — the safe direction, and the reason this is optional rather - * than a second result type. - */ - cursorRejected?: true; -} - -export interface OrderDetailResult { - order: OrderDetailWire; - /** The legal outbound transitions from the current state — the domain state - * machine, forwarded by the service (never re-derived plugin-side). */ - allowedTransitions: string[]; -} - -/** A saved profile address on the wire (admin-UX Increment 1). This is the - * customer's CURRENT address book — prefill/context only (ADR-0009). The order's - * own authoritative ship-to is {@link OrderAddressWire} on the order detail; this - * mutable book must never be presented as "where this order shipped". */ -export interface AddressWire { - id: string; - kind: string; - name: string; - line1: string; - line2: string | null; - city: string; - region: string | null; - postalCode: string; - country: string; - isDefault: boolean; - createdAt: string; -} - -/** Token-free session metadata on the wire (admin-UX Increment 1) — the service - * never serializes a token or hash into this shape. */ -export interface SessionSummaryWire { - id: string; - createdAt: string; - expiresAt: string; - revokedAt: string | null; -} - -/** Who the order's customer is (admin-UX Increment 1). `linkage` is the honest - * story: "claimed" (order linked to the account), "unclaimed" (an account - * exists for this email but the order predates its next login — links then), - * or "guest" (no account at all). */ -export interface CustomerIdentityWire { - customerId: string | null; - buyerRef: string; - email: string | null; - displayName: string | null; - emailVerifiedAt: string | null; - linkage: string; -} - -/** The customer-context panel payload (admin-UX Increment 1) — read-only. */ -export interface CustomerContextWire { - identity: CustomerIdentityWire; - addresses: AddressWire[]; - sessions: SessionSummaryWire[]; - orderCount: number; - recentOrders: OrderSummaryWire[]; -} - -/** A refund row on the wire (ADR-0008). `kind` is "gateway" (money moved via the - * provider — `refundRef` set) or "manual" (an out-of-band return the admin - * recorded — `refundRef` null, x402's honest path). Money is integer minor - * units + ISO-4217 currency. */ -export interface RefundWire { - id: string; - orderId: string; - amountCents: number; - currency: string; - kind: string; - gateway: string; - refundRef: string | null; - reason: string | null; - refundedBy: string; - createdAt: string; -} - -/** The refunds summary for an order (ADR-0008): the append-only ledger plus the - * derived ceiling / remaining-refundable and the gateway's HONEST `refundable` - * capability, so the panel shows the right action (a real Stripe refund vs a - * recorded manual refund) and never a button that silently no-ops. */ -export interface RefundsSummaryWire { - refunds: RefundWire[]; - currency: string; - capturedTotalCents: number; - refundedTotalCents: number; - ceilingCents: number; - remainingCents: number; - paymentMethod: string | null; - refundable: boolean; -} - -/** POST refund returns a discriminated result (like `transitionOrder`) so a - * failure surfaces a GENERIC inline banner rather than throwing into the host. - * `recorded:false` on a 2xx ⇒ an idempotent replay (`duplicate`). On a failure, - * `reason` carries the service's typed reason when one was returned (e.g. - * `REFUND_EXCEEDS_TOTAL`, `PROVIDER_ALREADY_REFUNDED`, `GATEWAY_UNVERIFIED`); the - * caller renders GENERIC copy keyed off it, never the raw status/URL. */ -export type RefundOrderResult = - | { ok: true; recorded: boolean; duplicate: boolean; fullyRefunded: boolean } - | { ok: false; status: number; reason?: string }; - -/** An append-only order note (admin-UX Increment 0) on the wire. */ -export interface OrderNoteWire { - id: string; - orderId: string; - author: string; - body: string; - createdAt: string; -} - -/** - * One entry in the order timeline (admin-UX Increment 1, timeline slice) on the - * wire. A discriminated union keyed by `kind`; every entry carries `at`, and the - * kind-specific fields are OPTIONAL here (the plugin reads only what a given - * `kind` populates), so an unknown/future kind degrades to a bare `at` row rather - * than throwing. Money-free — the timeline is an audit surface, not a totals one. - */ -export interface TimelineEntryWire { - kind: string; - at: string; - /** state_change */ - fromState?: string | null; - toState?: string | null; - actor?: string | null; - /** note */ - author?: string; - body?: string; - /** fulfillment */ - carrier?: string; - trackingNumber?: string; - trackingUrl?: string | null; - shippedAt?: string; - recordedBy?: string; - /** cancellation */ - reason?: string; - detail?: string | null; - cancelledBy?: string; - /** reconciliation_resolved */ - outcome?: string; - resolvedBy?: string; -} - -/** The order timeline payload (admin-UX Increment 1, timeline slice) — read-only. - * `stateChangesAudited` is false for a historical order whose transitions - * predate the audit table (a partial timeline). */ -export interface OrderTimelineWire { - orderId: string; - stateChangesAudited: boolean; - entries: TimelineEntryWire[]; -} - -/** POST add-note returns a discriminated result (like `transitionOrder`) so a - * failure surfaces a GENERIC inline banner rather than throwing into the host. */ -export type AddNoteResult = - | { ok: true; appended: boolean; note: OrderNoteWire } - | { ok: false; status: number }; - -/** POST transition returns a discriminated result (like `updateSettings`) so a - * failure surfaces a GENERIC inline banner rather than throwing into the host. */ -export type TransitionOrderResult = - | { ok: true; transitioned: boolean } - | { ok: false; status: number }; - -/** POST resolve-reconciliation returns a discriminated result (like `transitionOrder`) - * so a failure surfaces a GENERIC inline banner rather than throwing into the host. - * `resolved:false` on a 2xx ⇒ the guarded flip found nothing to resolve (already - * resolved / lost race) — a benign no-op, not a failure. On a failure, `reason` - * carries the service's typed reason when one was returned (e.g. - * `RECONCILIATION_FLAG_CHANGED` — the live flag differs from the one reviewed, the - * console should tell the merchant to reload); the caller renders GENERIC copy - * keyed off it, never the raw status/URL. */ -export type ResolveReconciliationResult = - | { ok: true; resolved: boolean } - | { ok: false; status: number; reason?: string }; - -/** POST record-fulfillment returns a discriminated result (like `transitionOrder`) - * so a failure surfaces a GENERIC inline banner rather than throwing into the host. - * `recorded:false` on a 2xx ⇒ the guarded flip found the order already shipped (a - * benign no-op, not a failure). On a failure, `reason` carries the service's typed - * reason when one was returned (e.g. `NOT_FULFILLABLE` — the order is not in - * `processing`); the caller renders GENERIC copy keyed off it, never the raw - * status/URL. */ -export type RecordFulfillmentResult = - | { ok: true; recorded: boolean } - | { ok: false; status: number; reason?: string }; - -/** POST cancel returns a discriminated result (like `transitionOrder`) so a - * failure surfaces a GENERIC inline banner rather than throwing into the host. - * `cancelled:false` on a 2xx ⇒ the guarded flip found the order already - * cancelled with a reason on file (a benign no-op, not a failure). On a - * failure, `reason` carries the service's typed reason when one was returned - * (e.g. `NOT_CANCELLABLE` — the order can no longer be cancelled); the caller - * renders GENERIC copy keyed off it, never the raw status/URL. */ -export type CancelOrderResult = - | { ok: true; cancelled: boolean } - | { ok: false; status: number; reason?: string }; - -interface HttpErrorEnvelope { - error?: string; - reason?: string; -} - -export interface AdminOrdersClientOptions { - fetch: HttpAccess["fetch"]; - baseUrl: string; - /** Admin token forwarded as `X-Internal-Token` on every guarded call. Sourced - * by the page handler from write-only `ctx.kv`. */ - adminToken?: string; - /** The machine write-gate token the service enforces as `X-Service-Token` - * (ADR-0007), sourced from write-only `ctx.kv`. - * `POST /admin/orders/:id/transition` is a NON-GET, so the gate blocks it - * without this when the service secret is set — hence it is attached to the - * transition (the list/detail GET reads are gate-exempt, so they carry only - * the admin token). Undefined ⇒ no header ⇒ byte-identical to today. */ - serviceToken?: string; -} - -/** - * THE ADMIN ORDERS SURFACE, structurally — what a caller may do, with no claim - * about how it gets done. - * - * Two implementations answer to this now (work order 02, INC-B10b-ii): the - * `ctx.http` client below, and `InProcessAdminOrdersClient`, which composes the - * same behaviour over the plugin's own document store. `AdminOrdersClient` - * itself cannot be that type — its `#`-private fields make it nominal, so no - * second class is ever assignable to it — hence a `Pick` over its methods, the - * same idiom `AdminProductsSurface` and the contract suite's surfaces use. - * - * EVERY METHOD IS LISTED. Written out rather than derived, so adding a method to - * the client without deciding what the in-process tier does about it is a compile - * error here rather than a surface that silently exists on one transport only. - */ -export type AdminOrdersSurface = Pick< - AdminOrdersClient, - | "listOrders" - | "getOrder" - | "transitionOrder" - | "resolveReconciliation" - | "recordFulfillment" - | "cancelOrder" - | "getCustomerContext" - | "getTimeline" - | "getRefunds" - | "refundOrder" - | "listNotes" - | "addNote" ->; - -export class AdminOrdersClient { - readonly #fetch: HttpAccess["fetch"]; - readonly #baseUrl: string; - readonly #adminToken: string | undefined; - readonly #serviceToken: string | undefined; - - constructor(options: AdminOrdersClientOptions) { - this.#fetch = options.fetch; - this.#baseUrl = options.baseUrl.replace(/\/$/, ""); - this.#adminToken = options.adminToken; - this.#serviceToken = options.serviceToken; - } - - /** - * THE FILTER TRAVELS BESIDE THE CURSOR, and it did not used to. - * - * The old rule was "send ONLY the cursor when paging, so the two never - * disagree", and it was the wrong half of a true observation. The cursor does - * embed the filter it was minted under — but the route, given both, took the - * predicate SOLELY from the token and never read the query's filter params at - * all. So a page-two request that meant "paid orders" while carrying an - * unfiltered token got the unfiltered set, 200, with nothing in the response - * admitting the substitution; upstream, a console deriving its filters from - * the address captions those rows "Paid". Sending only the cursor did not - * prevent the disagreement — it hid it. - * - * The route now compares the two as PREDICATES and answers - * `400 {"error":"cursor filter mismatch"}` when they differ, so stating the - * filter on every request is what turns an invisible divergence into an - * answerable one. Agreeing params are byte-identical to the cursor alone: they - * are redundant, not a second opinion. - * - * NO CASE FOLDING, HERE OR ANYWHERE BETWEEN THE URL AND THE WIRE. The - * comparison is deliberately case-SENSITIVE — the store's case-insensitivity - * is the store's business, and a token round-trips whatever the query said — - * so a client that helpfully lowercased a search term on one request and not - * on the other would manufacture mismatches out of nothing. - * - * WHAT THE CALLER OWES: for a filter derived from a RELATIVE period, the - * instants passed here must be the ones the cursor was minted under, not a - * fresh resolution of the same words. `orders-read.ts`'s `periodWindow` - * resolves presets to WHOLE-DAY bounds precisely so that holds — two requests - * on the same UTC day resolve identically, which is every request in a paging - * session bar one that crosses UTC midnight. That crossing describes a - * genuinely different window, so the 400 and the page-one recovery below are - * the correct answer to it rather than a defect to design around. - */ - async listOrders( - filter: OrdersListFilter, - opts: { cursor?: string; limit?: number } = {}, - ): Promise { - const paged = opts.cursor !== undefined && opts.cursor.length > 0; - const query = (withCursor: boolean): string => { - const q = new URLSearchParams(); - if (withCursor && opts.cursor !== undefined) q.set("cursor", opts.cursor); - if (filter.states !== undefined && filter.states.length > 0) { - q.set("states", filter.states.join(",")); - } - if (filter.from !== undefined && filter.from.length > 0) q.set("from", filter.from); - if (filter.to !== undefined && filter.to.length > 0) q.set("to", filter.to); - if (filter.search !== undefined && filter.search.length > 0) q.set("search", filter.search); - if (opts.limit !== undefined) q.set("limit", String(opts.limit)); - return q.toString(); - }; - - const first = await this.#getList(`/admin/orders?${query(paged)}`); - if (first === CURSOR_REFUSED && !paged) { - // A CURSOR REFUSAL FOR A REQUEST THAT CARRIED NO CURSOR is the service - // contradicting itself, and there is no recovery to attempt: re-issuing - // the identical cursor-less request would ask the same question again and - // get the same answer. It fails, like any other refusal this client - // cannot act on. - throw new Error(`GET /admin/orders failed (HTTP 400)`); - } - if (first === CURSOR_REFUSED) { - /* - * THE PRESCRIBED RECOVERY, PERFORMED HERE. A refused cursor means "drop - * the token and re-issue page one with these parameters", not "show the - * operator an error": the request is answerable, just not from that - * token, and the remedy is mechanical. - * - * IT BELONGS AT THIS TIER because this is the last one that can read the - * service's own error value, and the distinction it carries is the one the - * console needs most: a refused PAGE comes back as a first page with a - * flag, an unreachable SERVICE comes back as a thrown failure, and those - * two want opposite treatments of the address bar — the first is corrected - * to page one, the second must keep the page it names so a reload after - * recovery still restores it. Collapsing them into one "list failed" is - * what made the console guess. - * - * ONE retry, without the cursor, so it cannot loop: the second request - * carries no token to be refused. - * - * THE RETRY IS THE SHARED REMEDY, NOT ALWAYS THE ANSWER. A consumer is - * entitled to DISCARD these rows: a console refused mid-scan keeps the - * pages it already has and throws page one away unmerged, because showing - * it would destroy the scan to re-print rows the operator read first. That - * is why the request is still made — the flag needs a page behind it to be - * an honest answer to the caller that does want one — and why the - * discarded case must not be optimised away by skipping the retry when - * somebody guesses it will be unused. This tier cannot know. - */ - const retried = await this.#getList(`/admin/orders?${query(false)}`); - if (retried === CURSOR_REFUSED) throw new Error("GET /admin/orders failed (HTTP 400)"); - return { ...retried, cursorRejected: true }; - } - return first; - } - - async #getList(path: string): Promise { - const res = await this.#fetch(`${this.#baseUrl}${path}`, { - method: "GET", - headers: this.#authHeaders(), - }); - if (!res.ok) { - if (await isCursorRefusal(res)) return CURSOR_REFUSED; - throw new Error(`GET ${path} failed (HTTP ${res.status})`); - } - const body = (await res.json()) as { - orders?: OrderSummaryWire[]; - nextCursor?: string | null; - total?: unknown; - }; - return { - orders: body.orders ?? [], - nextCursor: body.nextCursor ?? null, - // ABSENT STAYS ABSENT (never `?? 0`) — see `OrdersListResult.total`. - ...(typeof body.total === "number" ? { total: body.total } : {}), - }; - } - - /** GET one order + its allowed transitions. A 404 resolves to `null` (the - * console renders a "not found" state, not an error banner). */ - async getOrder(orderId: string): Promise { - const res = await this.#fetch(`${this.#baseUrl}/admin/orders/${encodeURIComponent(orderId)}`, { - method: "GET", - headers: this.#authHeaders(), - }); - if (res.status === 404) return null; - if (!res.ok) throw new Error(`GET order failed (HTTP ${res.status})`); - const body = (await res.json()) as { - order: OrderDetailWire; - allowedTransitions?: string[]; - }; - return { order: body.order, allowedTransitions: body.allowedTransitions ?? [] }; - } - - async transitionOrder( - orderId: string, - toState: string, - opts: { idempotencyKey: string }, - ): Promise { - const headers: Record = { - "content-type": "application/json", - "Idempotency-Key": opts.idempotencyKey, - }; - if (this.#adminToken !== undefined) headers["X-Internal-Token"] = this.#adminToken; - // The transition POST is gated by BOTH the write gate (X-Service-Token) AND - // the route's admin token (X-Internal-Token) when both secrets are set. - if (this.#serviceToken !== undefined) headers["X-Service-Token"] = this.#serviceToken; - const res = await this.#fetch( - `${this.#baseUrl}/admin/orders/${encodeURIComponent(orderId)}/transition`, - { method: "POST", headers, body: JSON.stringify({ toState }) }, - ); - const parsed = (await res.json().catch(() => undefined)) as - | { ok?: boolean; transitioned?: boolean } - | HttpErrorEnvelope - | undefined; - if (res.ok && parsed !== undefined && "ok" in parsed && parsed.ok === true) { - return { ok: true, transitioned: parsed.transitioned ?? true }; - } - // Fail with the status only — the caller renders a GENERIC banner that never - // echoes a raw HTTP status/URL into the admin UI. - return { ok: false, status: res.status }; - } - - /** POST resolve an order's reconciliation flag (admin-UX Increment 1). The body - * carries `expectedFlag` — the flag detail AS DISPLAYED to the admin — and the - * service compare-and-clears against it, so a mid-review re-flag conflicts - * (`RECONCILIATION_FLAG_CHANGED`) instead of being cleared blind. Gated by - * BOTH the admin token (X-Internal-Token) AND the write gate (X-Service-Token) - * when both service secrets are set — a non-GET, same as the transition. Returns - * a discriminated result. */ - async resolveReconciliation( - orderId: string, - disposition: { expectedFlag: string; outcome: string; reason: string; resolvedBy: string }, - opts: { idempotencyKey: string }, - ): Promise { - const headers: Record = { - "content-type": "application/json", - "Idempotency-Key": opts.idempotencyKey, - }; - if (this.#adminToken !== undefined) headers["X-Internal-Token"] = this.#adminToken; - if (this.#serviceToken !== undefined) headers["X-Service-Token"] = this.#serviceToken; - const res = await this.#fetch( - `${this.#baseUrl}/admin/orders/${encodeURIComponent(orderId)}/resolve-reconciliation`, - { method: "POST", headers, body: JSON.stringify(disposition) }, - ); - const parsed = (await res.json().catch(() => undefined)) as - | { ok?: boolean; resolved?: boolean } - | HttpErrorEnvelope - | undefined; - if (res.ok && parsed !== undefined && "ok" in parsed && parsed.ok === true) { - return { ok: true, resolved: parsed.resolved ?? true }; - } - // Forward the service's typed reason (if any) so the console can pick the - // right GENERIC copy (e.g. "reload" on a flag-changed conflict) — never the - // raw status/URL. - const reason = - parsed !== undefined && "reason" in parsed && typeof parsed.reason === "string" - ? parsed.reason - : undefined; - return { ok: false, status: res.status, ...(reason !== undefined ? { reason } : {}) }; - } - - /** POST record shipping fulfillment on an order (admin-UX Increment 1). - * Recording fulfillment SHIPS the order (`processing → shipped`) and stores the - * tracking so the buyer's shipped email carries it. Gated by BOTH the admin - * token (X-Internal-Token) AND the write gate (X-Service-Token) when both - * service secrets are set — a non-GET, same as the transition. Returns a - * discriminated result; forwards the service's typed reason (e.g. - * `NOT_FULFILLABLE`) so the console can pick the right GENERIC copy. */ - async recordFulfillment( - orderId: string, - fulfillment: { - carrier: string; - trackingNumber: string; - trackingUrl?: string | null; - shippedAt?: string | null; - recordedBy: string; - }, - opts: { idempotencyKey: string }, - ): Promise { - const headers: Record = { - "content-type": "application/json", - "Idempotency-Key": opts.idempotencyKey, - }; - if (this.#adminToken !== undefined) headers["X-Internal-Token"] = this.#adminToken; - if (this.#serviceToken !== undefined) headers["X-Service-Token"] = this.#serviceToken; - const res = await this.#fetch( - `${this.#baseUrl}/admin/orders/${encodeURIComponent(orderId)}/fulfillment`, - { method: "POST", headers, body: JSON.stringify(fulfillment) }, - ); - const parsed = (await res.json().catch(() => undefined)) as - | { ok?: boolean; recorded?: boolean } - | HttpErrorEnvelope - | undefined; - if (res.ok && parsed !== undefined && "ok" in parsed && parsed.ok === true) { - return { ok: true, recorded: parsed.recorded ?? true }; - } - const reason = - parsed !== undefined && "reason" in parsed && typeof parsed.reason === "string" - ? parsed.reason - : undefined; - return { ok: false, status: res.status, ...(reason !== undefined ? { reason } : {}) }; - } - - /** POST cancel an order WITH a structured reason (admin-UX Increment 1, - * "cancel with reason"). Gated by BOTH the admin token (X-Internal-Token) AND - * the write gate (X-Service-Token) when both service secrets are set — a - * non-GET, same as the transition. Returns a discriminated result; forwards - * the service's typed reason (e.g. `NOT_CANCELLABLE`) so the console can pick - * the right GENERIC copy. */ - async cancelOrder( - orderId: string, - cancellation: { reason: string; detail?: string | null; cancelledBy: string }, - opts: { idempotencyKey: string }, - ): Promise { - const headers: Record = { - "content-type": "application/json", - "Idempotency-Key": opts.idempotencyKey, - }; - if (this.#adminToken !== undefined) headers["X-Internal-Token"] = this.#adminToken; - if (this.#serviceToken !== undefined) headers["X-Service-Token"] = this.#serviceToken; - const res = await this.#fetch( - `${this.#baseUrl}/admin/orders/${encodeURIComponent(orderId)}/cancel`, - { method: "POST", headers, body: JSON.stringify(cancellation) }, - ); - const parsed = (await res.json().catch(() => undefined)) as - | { ok?: boolean; cancelled?: boolean } - | HttpErrorEnvelope - | undefined; - if (res.ok && parsed !== undefined && "ok" in parsed && parsed.ok === true) { - return { ok: true, cancelled: parsed.cancelled ?? true }; - } - const cancelReason = - parsed !== undefined && "reason" in parsed && typeof parsed.reason === "string" - ? parsed.reason - : undefined; - return { - ok: false, - status: res.status, - ...(cancelReason !== undefined ? { reason: cancelReason } : {}), - }; - } - - /** GET an order's customer context (admin-token guarded read; admin-UX - * Increment 1). Mirrors `getOrder`'s shape: a 404 resolves to `null`; any - * other non-2xx throws — the caller degrades to an "unavailable" section, - * never a hard error (and never blanks the order detail). */ - async getCustomerContext(orderId: string): Promise { - const res = await this.#fetch( - `${this.#baseUrl}/admin/orders/${encodeURIComponent(orderId)}/customer-context`, - { method: "GET", headers: this.#authHeaders() }, - ); - if (res.status === 404) return null; - if (!res.ok) throw new Error(`GET customer context failed (HTTP ${res.status})`); - const body = (await res.json()) as { context?: CustomerContextWire }; - return body.context ?? null; - } - - /** GET an order's timeline (admin-token guarded read; admin-UX Increment 1). - * Mirrors `getCustomerContext`'s shape: a 404 resolves to `null`; any other - * non-2xx throws — the caller degrades to an "unavailable" timeline section, - * never a hard error (and never blanks the order detail). */ - async getTimeline(orderId: string): Promise { - const res = await this.#fetch( - `${this.#baseUrl}/admin/orders/${encodeURIComponent(orderId)}/timeline`, - { method: "GET", headers: this.#authHeaders() }, - ); - if (res.status === 404) return null; - if (!res.ok) throw new Error(`GET timeline failed (HTTP ${res.status})`); - const body = (await res.json()) as { timeline?: OrderTimelineWire }; - return body.timeline ?? null; - } - - /** GET an order's refunds summary (admin-token guarded read; ADR-0008): the - * ledger + derived ceiling/remaining + the gateway's honest capability. A 404 - * resolves to `null`; any other non-2xx throws — the caller degrades to an - * "unavailable" refunds section, never a hard error (and never blanks the - * order detail). */ - async getRefunds(orderId: string): Promise { - const res = await this.#fetch( - `${this.#baseUrl}/admin/orders/${encodeURIComponent(orderId)}/refunds`, - { method: "GET", headers: this.#authHeaders() }, - ); - if (res.status === 404) return null; - if (!res.ok) throw new Error(`GET refunds failed (HTTP ${res.status})`); - const body = (await res.json()) as Partial; - return { - refunds: body.refunds ?? [], - currency: body.currency ?? "", - capturedTotalCents: body.capturedTotalCents ?? 0, - refundedTotalCents: body.refundedTotalCents ?? 0, - ceilingCents: body.ceilingCents ?? 0, - remainingCents: body.remainingCents ?? 0, - paymentMethod: body.paymentMethod ?? null, - refundable: body.refundable ?? false, - }; - } - - /** POST issue/record a refund (ADR-0008). Gated by BOTH the admin token - * (X-Internal-Token) AND the write gate (X-Service-Token) when both service - * secrets are set — a non-GET, same as the transition. The `Idempotency-Key` - * is REQUIRED (refunds are additive — two deliberate refunds must not - * collapse). Returns a discriminated result; forwards the service's typed - * reason so the console can pick the right GENERIC copy. */ - async refundOrder( - orderId: string, - refund: { amountCents: number; currency: string; reason?: string | null; refundedBy: string }, - opts: { idempotencyKey: string }, - ): Promise { - const headers: Record = { - "content-type": "application/json", - "Idempotency-Key": opts.idempotencyKey, - }; - if (this.#adminToken !== undefined) headers["X-Internal-Token"] = this.#adminToken; - if (this.#serviceToken !== undefined) headers["X-Service-Token"] = this.#serviceToken; - const res = await this.#fetch( - `${this.#baseUrl}/admin/orders/${encodeURIComponent(orderId)}/refund`, - { method: "POST", headers, body: JSON.stringify(refund) }, - ); - const parsed = (await res.json().catch(() => undefined)) as - | { ok?: boolean; recorded?: boolean; duplicate?: boolean; fullyRefunded?: boolean } - | HttpErrorEnvelope - | undefined; - if (res.ok && parsed !== undefined && "ok" in parsed && parsed.ok === true) { - return { - ok: true, - recorded: parsed.recorded ?? true, - duplicate: parsed.duplicate ?? false, - fullyRefunded: parsed.fullyRefunded ?? false, - }; - } - const reason = - parsed !== undefined && "reason" in parsed && typeof parsed.reason === "string" - ? parsed.reason - : undefined; - return { ok: false, status: res.status, ...(reason !== undefined ? { reason } : {}) }; - } - - /** GET an order's append-only notes (admin-token guarded read). A non-2xx - * throws — the caller degrades to an empty notes surface, never a hard error. */ - async listNotes(orderId: string): Promise { - const body = await this.#getJson<{ notes?: OrderNoteWire[] }>( - `/admin/orders/${encodeURIComponent(orderId)}/notes`, - ); - return body.notes ?? []; - } - - /** POST a new note. Gated by BOTH the admin token (X-Internal-Token) AND the - * write gate (X-Service-Token) when both service secrets are set — a non-GET, - * same as the transition. Returns a discriminated result. */ - async addNote( - orderId: string, - note: { author: string; body: string }, - opts: { idempotencyKey: string }, - ): Promise { - const headers: Record = { - "content-type": "application/json", - "Idempotency-Key": opts.idempotencyKey, - }; - if (this.#adminToken !== undefined) headers["X-Internal-Token"] = this.#adminToken; - if (this.#serviceToken !== undefined) headers["X-Service-Token"] = this.#serviceToken; - const res = await this.#fetch( - `${this.#baseUrl}/admin/orders/${encodeURIComponent(orderId)}/notes`, - { method: "POST", headers, body: JSON.stringify(note) }, - ); - const parsed = (await res.json().catch(() => undefined)) as - | { ok?: boolean; appended?: boolean; note?: OrderNoteWire } - | HttpErrorEnvelope - | undefined; - if (res.ok && parsed !== undefined && "ok" in parsed && parsed.ok === true && parsed.note) { - return { ok: true, appended: parsed.appended ?? true, note: parsed.note }; - } - return { ok: false, status: res.status }; - } - - #authHeaders(): Record { - return this.#adminToken === undefined ? {} : { "X-Internal-Token": this.#adminToken }; - } - - async #getJson(path: string): Promise { - const res = await this.#fetch(`${this.#baseUrl}${path}`, { - method: "GET", - headers: this.#authHeaders(), - }); - if (!res.ok) throw new Error(`GET ${path} failed (HTTP ${res.status})`); - return (await res.json()) as T; - } -} diff --git a/packages/plugin/src/admin/admin-orders-surface.ts b/packages/plugin/src/admin/admin-orders-surface.ts new file mode 100644 index 00000000..f3408805 --- /dev/null +++ b/packages/plugin/src/admin/admin-orders-surface.ts @@ -0,0 +1,506 @@ +/** + * The admin Orders console surface — the port the console pages hold, plus the + * wire-shaped types that cross it (view-only list + detail, the status + * transition, and the Increment-1 write actions). + * + * These types are defined LOCALLY and deliberately: this module NEVER imports + * `@otta-sh/domain`, which keeps the plugin sandbox-clean (enforced by the + * dependency-cruiser rule, MOD-4). Money is integer minor units + ISO-4217 + * currency throughout. The "wire" in the names is historical — it was once the + * JSON shape of a separate commerce service — and it is still exactly the shape + * the admin route's JSON responses use, so the name stays accurate. + */ + +export interface OrderSummaryWire { + id: string; + state: string; + currency: string; + buyerRef: string; + customerId: string | null; + paymentMethod: string | null; + createdAt: string; + totalCents: number; + reconciliationFlag: boolean; +} + +export interface OrderLineWire { + sku: string; + title: string; + unitPriceCents: number; + currency: string; + quantity: number; + fulfillmentKind: string; +} + +export interface OrderTotalsWire { + currency: string; + subtotalCents: number; + discountCents: number; + shippingCents: number; + taxCents: number; + totalCents: number; + appliedCouponCode: string | null; + /** The chosen shipping zone id (ADR-0009), or null when none was selected. + * DISPLAY-ONLY: rendered next to the captured ship-to country so a human can + * spot a "domestic zone / foreign country" mismatch — no matching/validation. */ + shippingZoneId?: string | null; +} + +/** The immutable shipping-address snapshot captured on an order at checkout + * (ADR-0009), or null when none was captured (a historical order predating + * capture, or a digital-only order). This IS the authoritative ship-to for the + * order — unlike {@link AddressWire} (the mutable profile book), it never changes + * after checkout. Optional contact fields are null when the buyer omitted them. */ +export interface OrderAddressWire { + name: string; + line1: string; + line2: string | null; + city: string; + region: string | null; + postalCode: string; + country: string; + email: string | null; + phone: string | null; +} + +/** The admin disposition recorded when an order's reconciliation flag was + * resolved (admin-UX Increment 1); null while unflagged/unresolved. */ +export interface ReconciliationResolutionWire { + outcome: string; + reason: string; + resolvedBy: string; + resolvedAt: string; +} + +/** The shipping fulfillment recorded on an order (admin-UX Increment 1); null + * until the order ships with tracking. `trackingUrl` is optional (null when the + * admin recorded none); `shippedAt` is the ship time, `recordedAt` the server + * stamp. */ +export interface OrderFulfillmentWire { + carrier: string; + trackingNumber: string; + trackingUrl: string | null; + shippedAt: string; + recordedBy: string; + recordedAt: string; +} + +/** The structured cancellation recorded on an order (admin-UX Increment 1, + * "cancel with reason"); null while never cancelled OR cancelled via the bare + * transition (no reason on file — an honest back-compat state). */ +export interface OrderCancellationWire { + reason: string; + detail: string | null; + cancelledBy: string; + cancelledAt: string; +} + +export interface OrderDetailWire { + id: string; + state: string; + currency: string; + paymentMethod: string | null; + buyerRef: string; + customerId: string | null; + holdExpiresAt: string; + createdAt: string; + reconciliationFlag: string | null; + reconciliationResolution: ReconciliationResolutionWire | null; + fulfillment: OrderFulfillmentWire | null; + cancellation: OrderCancellationWire | null; + /** The immutable ship-to snapshot captured at checkout (ADR-0009); null when + * the order predates capture or is digital-only. Authoritative — never the + * profile book (which is prefill/context, on the customer panel). */ + shippingAddress: OrderAddressWire | null; + totals: OrderTotalsWire; + lines: OrderLineWire[]; +} + +/** The list filter the console builds from its filter form. `states` is an OR set + * (serialized to a CSV `states=` param); the window is half-open `[from, to)`. */ +export interface OrdersListFilter { + states?: string[]; + from?: string; + to?: string; + search?: string; +} + +export interface OrdersListResult { + orders: OrderSummaryWire[]; + /** Opaque keyset cursor for the next page, or null on the last page. */ + nextCursor: string | null; + /** + * Exact number of orders matching the ACTIVE FILTER — the whole set, not + * this page (INC-23). + * + * OPTIONAL for one reason only: a service older than the field omits it, and + * a renderer must then fall back to the page-scoped count it always had + * ("25 orders on this page"). Never defaulted to `0` — that would caption a + * page of rows with a count of none. + */ + total?: number; + /** + * THIS IS PAGE ONE, and it is page one because the cursor the caller asked + * with was REFUSED — mismatched against these filters, or undecodable — and + * {@link AdminOrdersSurface.listOrders} re-issued the request without it. + * + * ABSENT ON EVERY ORDINARY PAGE, including an ordinary first page: the flag + * means "you asked for a page you did not get", which is a thing a renderer + * must be able to say out loud (an address still naming that page has to be + * corrected, and an operator who followed a link to it deserves a sentence). + * A caller that ignores it renders a correct list, one page from where the + * caller meant — the safe direction, and the reason this is optional rather + * than a second result type. + */ + cursorRejected?: true; +} + +export interface OrderDetailResult { + order: OrderDetailWire; + /** The legal outbound transitions from the current state — the domain state + * machine, forwarded by the service (never re-derived plugin-side). */ + allowedTransitions: string[]; +} + +/** A saved profile address on the wire (admin-UX Increment 1). This is the + * customer's CURRENT address book — prefill/context only (ADR-0009). The order's + * own authoritative ship-to is {@link OrderAddressWire} on the order detail; this + * mutable book must never be presented as "where this order shipped". */ +export interface AddressWire { + id: string; + kind: string; + name: string; + line1: string; + line2: string | null; + city: string; + region: string | null; + postalCode: string; + country: string; + isDefault: boolean; + createdAt: string; +} + +/** Token-free session metadata on the wire (admin-UX Increment 1) — the service + * never serializes a token or hash into this shape. */ +export interface SessionSummaryWire { + id: string; + createdAt: string; + expiresAt: string; + revokedAt: string | null; +} + +/** Who the order's customer is (admin-UX Increment 1). `linkage` is the honest + * story: "claimed" (order linked to the account), "unclaimed" (an account + * exists for this email but the order predates its next login — links then), + * or "guest" (no account at all). */ +export interface CustomerIdentityWire { + customerId: string | null; + buyerRef: string; + email: string | null; + displayName: string | null; + emailVerifiedAt: string | null; + linkage: string; +} + +/** The customer-context panel payload (admin-UX Increment 1) — read-only. */ +export interface CustomerContextWire { + identity: CustomerIdentityWire; + addresses: AddressWire[]; + sessions: SessionSummaryWire[]; + orderCount: number; + recentOrders: OrderSummaryWire[]; +} + +/** A refund row on the wire (ADR-0008). `kind` is "gateway" (money moved via the + * provider — `refundRef` set) or "manual" (an out-of-band return the admin + * recorded — `refundRef` null, x402's honest path). Money is integer minor + * units + ISO-4217 currency. */ +export interface RefundWire { + id: string; + orderId: string; + amountCents: number; + currency: string; + kind: string; + gateway: string; + refundRef: string | null; + reason: string | null; + refundedBy: string; + createdAt: string; +} + +/** The refunds summary for an order (ADR-0008): the append-only ledger plus the + * derived ceiling / remaining-refundable and the gateway's HONEST `refundable` + * capability, so the panel shows the right action (a real Stripe refund vs a + * recorded manual refund) and never a button that silently no-ops. */ +export interface RefundsSummaryWire { + refunds: RefundWire[]; + currency: string; + capturedTotalCents: number; + refundedTotalCents: number; + ceilingCents: number; + remainingCents: number; + paymentMethod: string | null; + refundable: boolean; +} + +/** POST refund returns a discriminated result (like `transitionOrder`) so a + * failure surfaces a GENERIC inline banner rather than throwing into the host. + * `recorded:false` on a 2xx ⇒ an idempotent replay (`duplicate`). On a failure, + * `reason` carries the service's typed reason when one was returned (e.g. + * `REFUND_EXCEEDS_TOTAL`, `PROVIDER_ALREADY_REFUNDED`, `GATEWAY_UNVERIFIED`); the + * caller renders GENERIC copy keyed off it, never the raw status/URL. */ +export type RefundOrderResult = + | { ok: true; recorded: boolean; duplicate: boolean; fullyRefunded: boolean } + | { ok: false; status: number; reason?: string }; + +/** An append-only order note (admin-UX Increment 0) on the wire. */ +export interface OrderNoteWire { + id: string; + orderId: string; + author: string; + body: string; + createdAt: string; +} + +/** + * One entry in the order timeline (admin-UX Increment 1, timeline slice) on the + * wire. A discriminated union keyed by `kind`; every entry carries `at`, and the + * kind-specific fields are OPTIONAL here (the plugin reads only what a given + * `kind` populates), so an unknown/future kind degrades to a bare `at` row rather + * than throwing. Money-free — the timeline is an audit surface, not a totals one. + */ +export interface TimelineEntryWire { + kind: string; + at: string; + /** state_change */ + fromState?: string | null; + toState?: string | null; + actor?: string | null; + /** note */ + author?: string; + body?: string; + /** fulfillment */ + carrier?: string; + trackingNumber?: string; + trackingUrl?: string | null; + shippedAt?: string; + recordedBy?: string; + /** cancellation */ + reason?: string; + detail?: string | null; + cancelledBy?: string; + /** reconciliation_resolved */ + outcome?: string; + resolvedBy?: string; +} + +/** The order timeline payload (admin-UX Increment 1, timeline slice) — read-only. + * `stateChangesAudited` is false for a historical order whose transitions + * predate the audit table (a partial timeline). */ +export interface OrderTimelineWire { + orderId: string; + stateChangesAudited: boolean; + entries: TimelineEntryWire[]; +} + +/** POST add-note returns a discriminated result (like `transitionOrder`) so a + * failure surfaces a GENERIC inline banner rather than throwing into the host. */ +export type AddNoteResult = + | { ok: true; appended: boolean; note: OrderNoteWire } + | { ok: false; status: number }; + +/** POST transition returns a discriminated result (like `updateSettings`) so a + * failure surfaces a GENERIC inline banner rather than throwing into the host. */ +export type TransitionOrderResult = + | { ok: true; transitioned: boolean } + | { ok: false; status: number }; + +/** POST resolve-reconciliation returns a discriminated result (like `transitionOrder`) + * so a failure surfaces a GENERIC inline banner rather than throwing into the host. + * `resolved:false` on a 2xx ⇒ the guarded flip found nothing to resolve (already + * resolved / lost race) — a benign no-op, not a failure. On a failure, `reason` + * carries the service's typed reason when one was returned (e.g. + * `RECONCILIATION_FLAG_CHANGED` — the live flag differs from the one reviewed, the + * console should tell the merchant to reload); the caller renders GENERIC copy + * keyed off it, never the raw status/URL. */ +export type ResolveReconciliationResult = + | { ok: true; resolved: boolean } + | { ok: false; status: number; reason?: string }; + +/** POST record-fulfillment returns a discriminated result (like `transitionOrder`) + * so a failure surfaces a GENERIC inline banner rather than throwing into the host. + * `recorded:false` on a 2xx ⇒ the guarded flip found the order already shipped (a + * benign no-op, not a failure). On a failure, `reason` carries the service's typed + * reason when one was returned (e.g. `NOT_FULFILLABLE` — the order is not in + * `processing`); the caller renders GENERIC copy keyed off it, never the raw + * status/URL. */ +export type RecordFulfillmentResult = + | { ok: true; recorded: boolean } + | { ok: false; status: number; reason?: string }; + +/** POST cancel returns a discriminated result (like `transitionOrder`) so a + * failure surfaces a GENERIC inline banner rather than throwing into the host. + * `cancelled:false` on a 2xx ⇒ the guarded flip found the order already + * cancelled with a reason on file (a benign no-op, not a failure). On a + * failure, `reason` carries the service's typed reason when one was returned + * (e.g. `NOT_CANCELLABLE` — the order can no longer be cancelled); the caller + * renders GENERIC copy keyed off it, never the raw status/URL. */ +export type CancelOrderResult = + | { ok: true; cancelled: boolean } + | { ok: false; status: number; reason?: string }; + +/** + * THE ADMIN ORDERS SURFACE, structurally — what a caller may do, with no claim + * about how it gets done. + * + * ONE implementation answers to this now (work order 02, INC-D3b): + * `InProcessAdminOrdersClient`, which composes this behaviour over the plugin's + * own document store. The `ctx.http` client that used to be the second + * implementation is gone with the commerce service it talked to, and with it the + * reason this was a `Pick` over a nominal class rather than an interface — so it + * is written out as an interface now, which is what it always described. + * + * EVERY METHOD IS LISTED, and writing them out is still the point: a method + * added to the in-process client without being declared here is not part of the + * surface, and a method declared here that the client does not implement is a + * compile error. The surface stays a deliberate decision rather than whatever + * one class happens to expose. + */ +export interface AdminOrdersSurface { + /** + * THE FILTER TRAVELS BESIDE THE CURSOR, and it did not used to. + * + * The old rule was "send ONLY the cursor when paging, so the two never + * disagree", and it was the wrong half of a true observation. The cursor does + * embed the filter it was minted under — but the reader, given both, took the + * predicate SOLELY from the token and never looked at the filter passed + * alongside it. So a page-two request that meant "paid orders" while carrying + * an unfiltered token got the unfiltered set, successfully, with nothing in + * the result admitting the substitution; upstream, a console deriving its + * filters from the address captions those rows "Paid". Passing only the cursor + * did not prevent the disagreement — it hid it. + * + * The implementation now compares the two as PREDICATES and REFUSES the cursor + * when they differ, so stating the filter on every call is what turns an + * invisible divergence into an answerable one. Agreeing filters are redundant, + * not a second opinion. + * + * NO CASE FOLDING, HERE OR ANYWHERE BEFORE THE STORE. The comparison is + * deliberately case-SENSITIVE — the store's case-insensitivity is the store's + * business, and a token round-trips whatever it was minted with — so a caller + * that helpfully lowercased a search term on one call and not on the other + * would manufacture mismatches out of nothing. + * + * WHAT THE CALLER OWES: for a filter derived from a RELATIVE period, the + * instants passed here must be the ones the cursor was minted under, not a + * fresh resolution of the same words. `orders-read.ts`'s `periodWindow` + * resolves presets to WHOLE-DAY bounds precisely so that holds — two calls on + * the same UTC day resolve identically, which is every call in a paging + * session bar one that crosses UTC midnight. That crossing describes a + * genuinely different window, so the refusal and the page-one recovery are the + * correct answer to it rather than a defect to design around. + * + * A REFUSED CURSOR IS RECOVERED HERE, not reported: the implementation drops + * the token, re-issues page one with the same filter, and flags the result + * `cursorRejected` so a consumer can say out loud that it did not get the page + * it asked for — or discard the rows, which a console refused mid-scan does. + * An unreachable store still fails loudly; those two want opposite treatments + * of the address bar, and collapsing them into one "list failed" is what made + * the console guess. + */ + listOrders( + filter: OrdersListFilter, + opts?: { cursor?: string; limit?: number }, + ): Promise; + + /** Read one order + its allowed transitions. A missing order resolves to + * `null` (the console renders a "not found" state, not an error banner). */ + getOrder(orderId: string): Promise; + + /** Move an order to `toState`. Returns a discriminated result rather than + * throwing, so a failure surfaces a GENERIC inline banner instead of tearing + * through the host. */ + transitionOrder( + orderId: string, + toState: string, + opts: { idempotencyKey: string }, + ): Promise; + + /** Resolve an order's reconciliation flag (admin-UX Increment 1). The + * disposition carries `expectedFlag` — the flag detail AS DISPLAYED to the + * admin — and the implementation compare-and-clears against it, so a + * mid-review re-flag conflicts (`RECONCILIATION_FLAG_CHANGED`) instead of + * being cleared blind. Returns a discriminated result; `resolved:false` on + * an `ok` is the benign no-op (already resolved / lost race). */ + resolveReconciliation( + orderId: string, + disposition: { expectedFlag: string; outcome: string; reason: string; resolvedBy: string }, + opts: { idempotencyKey: string }, + ): Promise; + + /** Record shipping fulfillment on an order (admin-UX Increment 1). Recording + * fulfillment SHIPS the order (`processing → shipped`) and stores the tracking + * so the buyer's shipped email carries it. Returns a discriminated result; + * forwards a typed `reason` (e.g. `NOT_FULFILLABLE`) so the console can pick + * the right GENERIC copy. */ + recordFulfillment( + orderId: string, + fulfillment: { + carrier: string; + trackingNumber: string; + trackingUrl?: string | null; + shippedAt?: string | null; + recordedBy: string; + }, + opts: { idempotencyKey: string }, + ): Promise; + + /** Cancel an order WITH a structured reason (admin-UX Increment 1). Returns a + * discriminated result; forwards a typed `reason` (e.g. `NOT_CANCELLABLE`) so + * the console can pick the right GENERIC copy. */ + cancelOrder( + orderId: string, + cancellation: { reason: string; detail?: string | null; cancelledBy: string }, + opts: { idempotencyKey: string }, + ): Promise; + + /** Read an order's customer context (admin-UX Increment 1). Mirrors + * `getOrder`'s shape: a missing order resolves to `null`; a genuine failure + * throws — the caller degrades to an "unavailable" section, never a hard + * error (and never blanks the order detail). */ + getCustomerContext(orderId: string): Promise; + + /** Read an order's timeline (admin-UX Increment 1). Mirrors + * `getCustomerContext`'s shape: a missing order resolves to `null`; a genuine + * failure throws — the caller degrades to an "unavailable" timeline section, + * never a hard error (and never blanks the order detail). */ + getTimeline(orderId: string): Promise; + + /** Read an order's refunds summary (ADR-0008): the ledger + the derived + * ceiling/remaining + the gateway's honest capability. A missing order + * resolves to `null`; a genuine failure throws — the caller degrades to an + * "unavailable" refunds section, never a hard error. */ + getRefunds(orderId: string): Promise; + + /** Issue or record a refund (ADR-0008). The `idempotencyKey` is REQUIRED — + * refunds are additive, so two deliberate refunds must not collapse. Returns + * a discriminated result; forwards a typed `reason` so the console can pick + * the right GENERIC copy. */ + refundOrder( + orderId: string, + refund: { amountCents: number; currency: string; reason?: string | null; refundedBy: string }, + opts: { idempotencyKey: string }, + ): Promise; + + /** Read an order's append-only notes. A failure throws — the caller degrades + * to an empty notes surface, never a hard error. */ + listNotes(orderId: string): Promise; + + /** Append a note. Returns a discriminated result so a failure surfaces a + * GENERIC inline banner rather than throwing into the host. */ + addNote( + orderId: string, + note: { author: string; body: string }, + opts: { idempotencyKey: string }, + ): Promise; +} diff --git a/packages/plugin/src/admin/admin-products-client.ts b/packages/plugin/src/admin/admin-products-client.ts deleted file mode 100644 index ab05dd2f..00000000 --- a/packages/plugin/src/admin/admin-products-client.ts +++ /dev/null @@ -1,562 +0,0 @@ -import type { HttpAccess } from "../types.js"; -import { CURSOR_REFUSED, isCursorRefusal } from "./cursor-refusal.js"; - -/** - * A tiny `ctx.http`-only client for the admin Products console service surface - * (view-only list + detail — admin-UX Increment 2, the enumerate slice). Same - * transport discipline as `AdminOrdersClient` (no new primitive): the injected - * `ctx.http.fetch` is the ONLY egress, money is integer minor units + ISO-4217 - * currency on the wire, and the wire types are defined LOCALLY — this module - * NEVER imports `@otta-sh/domain`, keeping the plugin sandbox-clean (enforced by - * the dependency-cruiser rule, MOD-4). `#fetch` is `#`-prefixed so the - * sandbox-clean grep guard sees no bare fetch call. - * - * Read surface plus the guarded commerce EDIT (`updateProduct`, slice 2) and - * the merchant stock movements (`restock` / `removeStock`, slice 3 — admin-UX - * Increment 2). No product-CREATE method here (that stays in the CMS sync path). - */ - -/** A lightweight product row for the admin list. It DOES carry stock: the - * service sources `onHand` from ONE LEFT JOIN per page, so the list still - * never N+1s into inventory per row (see `AdminProductsClient.getProduct`'s - * doc for the detail leaf's separate single-sku read). */ -export interface ProductSummaryWire { - productId: string; - sku: string | null; - title: string | null; - priceCents: number | null; - currency: string | null; - productKind: string; - active: boolean; - /** Stock on hand — a COUNT, never money (no minor units, no currency). - * - * `null` means the sku has NO inventory record (or the product has no sku - * at all): "unknown", which is NOT `0` ("out of stock"). A renderer must - * keep the two apart — a dash for `null`, a literal `0` for zero — and - * never fold either into the other. */ - onHand: number | null; - /** Soft-delete tombstone (product lifecycle surfacing). Null on every row - * of a default (live) page; non-null only in the archive view - * (`ProductsListFilter.deleted: true`). */ - deletedAt: string | null; - createdAt: string; -} - -/** The full admin Product detail (read-only) — carries the single-sku stock - * read (`onHand`) the detail leaf fetches for the ONE product opened; the - * list gets the same field from its per-page join instead. */ -export interface ProductDetailWire { - productId: string; - sku: string | null; - title: string | null; - priceCents: number | null; - currency: string | null; - taxClass: string | null; - /** Increment 2 slice 5: compare-at / was-price (shares the product currency; - * display-only). Both halves null ⇒ unset. */ - compareAtCents: number | null; - compareAtCurrency: string | null; - /** Increment 2 slice 5: ADMIN-ONLY unit cost (shares the product currency). - * Present here because this is the internal-token admin detail — never on a - * storefront wire. Both halves null ⇒ unset. */ - unitCostCents: number | null; - unitCostCurrency: string | null; - /** Increment 2 slice 5: out-of-stock policy (always `"deny"` this slice). */ - inventoryPolicy: string; - weightGrams: number | null; - lengthMm: number | null; - widthMm: number | null; - heightMm: number | null; - productKind: string; - active: boolean; - /** Soft-delete tombstone (product lifecycle surfacing). Non-null ⇒ this IS - * the read-only archive view — the detail leaf renders it instead of the - * edit/stock forms (see `products-page.ts`'s `detailBlocks`). A 404 (never - * existed) is still `getProduct` returning `null`; a deleted row is a 200 - * with this field set. */ - deletedAt: string | null; - /** Stock on hand for this product's sku — a COUNT, never money. - * - * SAME SEMANTICS AS THE LIST's `ProductSummaryWire.onHand` (INC-23): `null` - * means the sku has NO inventory record (or the product has no sku at all) - * — "unknown" — and `0` means a known sku that is out of stock. This used to - * be a bare `number` with both cases collapsed to `0`, so one product read - * `—` in the list and `0` on its own detail page. A renderer keeps the two - * apart with the same helper the list column uses. */ - onHand: number | null; - createdAt: string; - updatedAt: string; -} - -/** The list filter the console builds from its filter form. `active` is a - * tri-state string ("" ⇒ both) so the wire query mirrors the service's - * `active=true|false` param exactly. `deleted` is the archive-view toggle - * (product lifecycle surfacing): omitted/false ⇒ the original default (live - * rows only); true ⇒ ONLY soft-deleted rows. */ -export interface ProductsListFilter { - active?: boolean; - deleted?: boolean; - productKind?: string; - search?: string; - /** The store's low-stock threshold, when the console has resolved one AND - * "Low stock only" is on — mirrors the domain port's `ProductListFilter. - * lowStockThreshold` one field at a time, same as every other - * axis here. OMITTED means "no stock-based filtering", not "threshold 0": - * the caller (the console route) only sets this once a settings read has - * actually resolved a number, never a raw checkbox state. */ - lowStockThreshold?: number; -} - -export interface ProductsListResult { - products: ProductSummaryWire[]; - /** Opaque keyset cursor for the next page, or null on the last page. */ - nextCursor: string | null; - /** - * Exact number of products matching the ACTIVE FILTER — the whole set, not - * this page (INC-23). - * - * OPTIONAL for one reason only: a service older than the field omits it, and - * a renderer must then fall back to the page-scoped count it always had - * ("25 products on this page"). Never defaulted to `0` — that would caption - * a page of rows with a count of none. - */ - total?: number; - /** - * THIS IS PAGE ONE, because the cursor the caller asked with was REFUSED — - * mismatched against these filters, or undecodable — and - * {@link AdminProductsClient.listProducts} re-issued without it. Absent on - * every ordinary page, first pages included: the flag means "you asked for a - * page you did not get", which the renderer has to be able to say out loud. - * Same contract, same reasoning, as the Orders client's. - */ - cursorRejected?: true; -} - -/** The commerce-owned fields a product edit may change (mirrors the service's - * `editProductCommerceBody`, which is `.strict()` — an extra key here is a 400, - * not a silent strip). `expectedUpdatedAt` is the optimistic-concurrency - * watermark the admin loaded; the service compare-and-sets on it. Money is an - * integer minor-units + ISO-4217 pair — never a float. NO `active` (the CMS - * publish gate is not edited here) and NO `title` (CMS-owned, written only by - * the content sync — `adr/0013-product-title-is-cms-owned.md`). */ -export interface ProductEditWire { - expectedUpdatedAt: string; - sku?: string; - price?: { amount: number; currency: string }; - taxClass?: string | null; - /** Increment 2 slice 5: compare-at / cost — money (integer minor units + - * ISO-4217), null to CLEAR. Must share the product's price currency (the - * service/domain enforce it; a mismatch is a per-field error). */ - compareAtPrice?: { amount: number; currency: string } | null; - unitCost?: { amount: number; currency: string } | null; - weightGrams?: number | null; - lengthMm?: number | null; - widthMm?: number | null; - heightMm?: number | null; - productKind?: string; - /** Out-of-stock policy — only `"deny"` is accepted this slice. */ - inventoryPolicy?: string; -} - -/** One tax-class registry entry (mirrors the domain `TaxClass`) — the edit - * form's tax-class select is sourced from these. */ -export interface TaxClassWire { - id: string; - name: string; -} - -/** Discriminated edit outcome — the plugin renders each without status-code-as- - * logic (stale → reload notice, currency/sku → per-field warning). - * - * THE TWO RENAME REFUSALS ARE THEIR OWN MEMBERS, not one "sku problem". They - * ask the operator for different things — pick another sku, versus wait for - * the carts to finish — so folding them together would cost the only sentence - * that helps, and each carries the operands its sentence names. */ -export type ProductEditResult = - | { ok: true; updatedAt: string | null } - | { ok: false; reason: "not_found" } - | { ok: false; reason: "stale"; currentUpdatedAt: string | null } - | { ok: false; reason: "currency_mismatch"; currency: string | null } - | { ok: false; reason: "sku_taken"; sku: string | null } - /** The rename's target sku already has an inventory row of its own; stock is - * never merged between skus, so the rename was refused whole. */ - | { ok: false; reason: "sku_stock_conflict"; fromSku: string | null; toSku: string | null } - /** Live held/adopted reservations still name the sku being renamed away - * from. `liveHolds` is `null` only if the service omitted the count. */ - | { ok: false; reason: "sku_held_stock"; sku: string | null; liveHolds: number | null } - | { ok: false; reason: "invalid"; field: string | null } - | { ok: false; reason: "error" }; - -/** Discriminated restock outcome (admin-UX Increment 2 slice 3). `not_found`/ - * `no_sku`/`no_inventory_row` are the productId → sku resolution failures; the - * panel renders each without treating a status code as logic. */ -export type RestockResult = - | { ok: true; onHand: number } - | { ok: false; reason: "not_found" } - | { ok: false; reason: "no_sku" } - | { ok: false; reason: "no_inventory_row" } - | { ok: false; reason: "invalid" } - | { ok: false; reason: "error" }; - -/** Discriminated stock-removal outcome (admin-UX Increment 2 slice 3). Adds - * `insufficient_stock` (carrying the current count) — the guarded floor that - * keeps a removal from ever driving on-hand below zero. */ -export type StockRemovalResult = - | { ok: true; onHand: number } - | { ok: false; reason: "not_found" } - | { ok: false; reason: "no_sku" } - | { ok: false; reason: "no_inventory_row" } - | { ok: false; reason: "insufficient_stock"; onHand: number } - | { ok: false; reason: "invalid" } - | { ok: false; reason: "error" }; - -export interface AdminProductsClientOptions { - fetch: HttpAccess["fetch"]; - baseUrl: string; - /** Admin token forwarded as `X-Internal-Token` on every guarded read. - * Sourced by the page handler from write-only plugin kv. */ - adminToken?: string; - /** The machine write-gate token (`X-Service-Token`, ADR-0007), sourced from - * write-only `ctx.kv`. Attached to the edit PATCH (a NON-GET the gate blocks - * without it); undefined ⇒ no header ⇒ identical to a deployment with the - * service secret unset. */ - serviceToken?: string; -} - -/** - * THE ADMIN PRODUCTS SURFACE, structurally — what a caller may do, with no claim - * about how it gets done. - * - * Two implementations answer to this now (work order 02, INC-B10b-i): the - * `ctx.http` client below, and `InProcessAdminProductsClient`, which composes the - * same behaviour over the plugin's own document store. `AdminProductsClient` - * itself cannot be that type — its `#`-private fields make it nominal, so no - * second class is ever assignable to it — hence a `Pick` over its methods, the - * same idiom the contract suite's surfaces already use. - * - * EVERY METHOD IS LISTED. Written out rather than derived, so adding a method to - * the client without deciding what the in-process tier does about it is a compile - * error here rather than a surface that silently exists on one transport only. - */ -export type AdminProductsSurface = Pick< - AdminProductsClient, - "updateProduct" | "restock" | "removeStock" | "listProducts" | "getProduct" | "getTaxClasses" ->; - -export class AdminProductsClient { - readonly #fetch: HttpAccess["fetch"]; - readonly #baseUrl: string; - readonly #adminToken: string | undefined; - readonly #serviceToken: string | undefined; - - constructor(options: AdminProductsClientOptions) { - this.#fetch = options.fetch; - this.#baseUrl = options.baseUrl.replace(/\/$/, ""); - this.#adminToken = options.adminToken; - this.#serviceToken = options.serviceToken; - } - - /** - * PATCH the commerce-owned fields of one product (admin-UX Increment 2 slice - * 2). Gated by BOTH the admin token (X-Internal-Token) AND the write gate - * (X-Service-Token) when both secrets are set. `key` is the stable - * idempotency key (a double-submit dedupes). The HTTP status maps 1:1 to the - * discriminated result so the caller never inspects a raw status. - */ - async updateProduct( - productId: string, - body: ProductEditWire, - key: string, - ): Promise { - const headers: Record = { - "Content-Type": "application/json", - "Idempotency-Key": key, - }; - if (this.#adminToken !== undefined) headers["X-Internal-Token"] = this.#adminToken; - if (this.#serviceToken !== undefined) headers["X-Service-Token"] = this.#serviceToken; - const res = await this.#fetch( - `${this.#baseUrl}/admin/products/${encodeURIComponent(productId)}`, - { method: "PATCH", headers, body: JSON.stringify(body) }, - ); - if (res.status === 200) { - const parsed = (await safeJson(res)) as { updatedAt?: string } | undefined; - return { ok: true, updatedAt: parsed?.updatedAt ?? null }; - } - if (res.status === 404) return { ok: false, reason: "not_found" }; - if (res.status === 400) { - const parsed = (await safeJson(res)) as { field?: string } | undefined; - return { ok: false, reason: "invalid", field: parsed?.field ?? null }; - } - if (res.status === 409) { - const parsed = (await safeJson(res)) as - | { - reason?: string; - currentUpdatedAt?: string; - currency?: string; - sku?: string; - fromSku?: string; - toSku?: string; - liveHolds?: unknown; - } - | undefined; - if (parsed?.reason === "STALE_EDIT") { - return { ok: false, reason: "stale", currentUpdatedAt: parsed.currentUpdatedAt ?? null }; - } - if (parsed?.reason === "CURRENCY_MISMATCH") { - return { ok: false, reason: "currency_mismatch", currency: parsed.currency ?? null }; - } - if (parsed?.reason === "SKU_TAKEN") { - return { ok: false, reason: "sku_taken", sku: parsed.sku ?? null }; - } - if (parsed?.reason === "SKU_STOCK_CONFLICT") { - return { - ok: false, - reason: "sku_stock_conflict", - fromSku: parsed.fromSku ?? null, - toSku: parsed.toSku ?? null, - }; - } - if (parsed?.reason === "SKU_HELD_STOCK") { - // A count that is not a whole number is NOT a count. `null` says "some, - // number unknown" and the copy says so too — it is never rendered as 0, - // which would read as "no holds" beside a refusal caused by holds. - const holds = parsed.liveHolds; - return { - ok: false, - reason: "sku_held_stock", - sku: parsed.sku ?? null, - liveHolds: - typeof holds === "number" && Number.isInteger(holds) && holds > 0 ? holds : null, - }; - } - return { ok: false, reason: "error" }; - } - return { ok: false, reason: "error" }; - } - - /** - * POST a merchant RESTOCK — ADD `qty` units to the product's stock (admin-UX - * Increment 2 slice 3). Gated by BOTH the admin token AND the write gate - * (X-Service-Token) when both secrets are set. `key` is REQUIRED and must be - * stable per submission: a restock is additive (not idempotent by nature), so - * the service has no safe content-only fallback — a double-submit dedupes only - * when the SAME key is sent. The HTTP status maps 1:1 to the discriminated - * result so the caller never inspects a raw status. - */ - async restock(productId: string, qty: number, key: string): Promise { - const res = await this.#postStockMovement(productId, "restock", qty, key); - if (res.status === 200) { - const parsed = (await safeJson(res)) as { onHand?: number } | undefined; - return { ok: true, onHand: parsed?.onHand ?? 0 }; - } - if (res.status === 404) return { ok: false, reason: "not_found" }; - if (res.status === 400) return { ok: false, reason: "invalid" }; - if (res.status === 409) { - const reason = ((await safeJson(res)) as { reason?: string } | undefined)?.reason; - if (reason === "NO_SKU") return { ok: false, reason: "no_sku" }; - if (reason === "NO_INVENTORY_ROW") return { ok: false, reason: "no_inventory_row" }; - return { ok: false, reason: "error" }; - } - return { ok: false, reason: "error" }; - } - - /** - * POST a merchant STOCK REMOVAL — remove `qty` damaged/shrinkage units - * (admin-UX Increment 2 slice 3). Same double-gate + required-key discipline - * as {@link restock}. The service applies a GUARDED decrement, so an over- - * removal is a clean `insufficient_stock` (409, carrying the current count), - * never a negative stock or a throw. - */ - async removeStock(productId: string, qty: number, key: string): Promise { - const res = await this.#postStockMovement(productId, "remove-stock", qty, key); - if (res.status === 200) { - const parsed = (await safeJson(res)) as { onHand?: number } | undefined; - return { ok: true, onHand: parsed?.onHand ?? 0 }; - } - if (res.status === 404) return { ok: false, reason: "not_found" }; - if (res.status === 400) return { ok: false, reason: "invalid" }; - if (res.status === 409) { - const parsed = (await safeJson(res)) as { reason?: string; onHand?: number } | undefined; - if (parsed?.reason === "NO_SKU") return { ok: false, reason: "no_sku" }; - if (parsed?.reason === "NO_INVENTORY_ROW") return { ok: false, reason: "no_inventory_row" }; - if (parsed?.reason === "INSUFFICIENT_STOCK") { - return { ok: false, reason: "insufficient_stock", onHand: parsed.onHand ?? 0 }; - } - return { ok: false, reason: "error" }; - } - return { ok: false, reason: "error" }; - } - - #postStockMovement( - productId: string, - verb: "restock" | "remove-stock", - qty: number, - key: string, - ): Promise { - const headers: Record = { - "Content-Type": "application/json", - "Idempotency-Key": key, - }; - if (this.#adminToken !== undefined) headers["X-Internal-Token"] = this.#adminToken; - if (this.#serviceToken !== undefined) headers["X-Service-Token"] = this.#serviceToken; - return this.#fetch(`${this.#baseUrl}/admin/products/${encodeURIComponent(productId)}/${verb}`, { - method: "POST", - headers, - body: JSON.stringify({ qty }), - }); - } - - /** - * THE FILTER TRAVELS BESIDE THE CURSOR, and it did not used to — the same - * correction, for the same reason, as `AdminOrdersClient.listOrders`, whose - * doc carries the argument in full. In short: the route used to take the - * predicate solely from the token and never read the query's filter params, so - * an unfiltered token sent beside `?lowStockThreshold=5` answered 200 with the - * unfiltered catalog and a console captioned those rows "low-stock". It now - * compares the two as predicates and 400s a disagreement, which is only useful - * if the request states both. - * - * EVERY AXIS PARTICIPATES, the threshold included — `0` is a real threshold - * and is compared as one, never read as "absent". So a paged low-stock request - * must carry the threshold page one was filtered by; the console route - * resolves it before paging for exactly that reason. - * - * NO CASE FOLDING between the URL and the wire: the comparison is - * case-sensitive by design, and a client that normalised a search term on one - * request but not the other would manufacture mismatches. - */ - async listProducts( - filter: ProductsListFilter, - opts: { cursor?: string; limit?: number } = {}, - ): Promise { - const paged = opts.cursor !== undefined && opts.cursor.length > 0; - const query = (withCursor: boolean): string => { - const q = new URLSearchParams(); - if (withCursor && opts.cursor !== undefined) q.set("cursor", opts.cursor); - if (filter.active !== undefined) q.set("active", filter.active ? "true" : "false"); - if (filter.deleted !== undefined) q.set("deleted", filter.deleted ? "true" : "false"); - if (filter.productKind !== undefined && filter.productKind.length > 0) { - q.set("productKind", filter.productKind); - } - if (filter.search !== undefined && filter.search.length > 0) q.set("search", filter.search); - // ASSUMED, AND WORTH WRITING DOWN: that the service on the other end - // UNDERSTANDS this parameter. A service predating the low-stock - // predicate ignores the unknown key, answers with an unfiltered page - // and an unfiltered `total`, and says nothing about having done so — so - // the console would caption every product as "N low-stock products". - // The wire carries no capability handshake to check it against, and the - // plugin and the service ship from this repo together, which is what - // makes the assumption safe rather than merely convenient. A build that - // ever pairs them independently needs a version signal here. - if (filter.lowStockThreshold !== undefined) { - q.set("lowStockThreshold", String(filter.lowStockThreshold)); - } - if (opts.limit !== undefined) q.set("limit", String(opts.limit)); - return q.toString(); - }; - - const first = await this.#getList(`/admin/products?${query(paged)}`); - if (first === CURSOR_REFUSED && !paged) { - // A CURSOR REFUSAL FOR A REQUEST THAT CARRIED NO CURSOR is the service - // contradicting itself, and there is no recovery to attempt: re-issuing - // the identical cursor-less request would ask the same question again and - // get the same answer. It fails, like any other refusal this client - // cannot act on. - throw new Error(`GET /admin/products failed (HTTP 400)`); - } - if (first === CURSOR_REFUSED) { - // THE PRESCRIBED RECOVERY — drop the token, re-issue page one with the - // same parameters, once. See `AdminOrdersClient.listOrders` for why this - // tier is the right one to do it at, and why the flag on the way back - // matters as much as the rows. - // - // THE RETRY IS THE SHARED REMEDY, NOT ALWAYS THE ANSWER: a consumer may - // DISCARD these rows — a console refused mid-scan keeps the pages it - // already has rather than destroying the scan to re-print page one — so - // the request is still made (the flag needs a page behind it for the - // caller that does want one) and the discard must not be optimised away - // by skipping it. This tier cannot know which caller it has. - const retried = await this.#getList(`/admin/products?${query(false)}`); - if (retried === CURSOR_REFUSED) throw new Error("GET /admin/products failed (HTTP 400)"); - return { ...retried, cursorRejected: true }; - } - return first; - } - - async #getList(path: string): Promise { - const res = await this.#fetch(`${this.#baseUrl}${path}`, { - method: "GET", - headers: this.#authHeaders(), - }); - if (!res.ok) { - if (await isCursorRefusal(res)) return CURSOR_REFUSED; - throw new Error(`GET ${path} failed (HTTP ${res.status})`); - } - const body = (await res.json()) as { - products?: ProductSummaryWire[]; - nextCursor?: string | null; - total?: unknown; - }; - return { - products: body.products ?? [], - nextCursor: body.nextCursor ?? null, - // ABSENT STAYS ABSENT (never `?? 0`): a service that predates `total` - // leaves the renderer on the page-scoped count it always had, and a zero - // would caption a page of rows as an empty set. The renderer applies the - // remaining sanity check (`rowCountLine`), the same split as `onHand`: - // the transport passes the wire through, the consumer decides what a - // value it cannot use means. - ...(typeof body.total === "number" ? { total: body.total } : {}), - }; - } - - /** GET one product's full detail (incl. stock). A 404 resolves to `null` - * (the console renders a "not found" state, not an error banner). */ - async getProduct(productId: string): Promise { - const res = await this.#fetch( - `${this.#baseUrl}/admin/products/${encodeURIComponent(productId)}`, - { method: "GET", headers: this.#authHeaders() }, - ); - if (res.status === 404) return null; - if (!res.ok) throw new Error(`GET product failed (HTTP ${res.status})`); - const body = (await res.json()) as { product: ProductDetailWire }; - return body.product; - } - - /** - * GET the tax-class registry (Increment 2 slice 5) — the source for the edit - * form's tax-class select. A SECONDARY, best-effort read: the caller wraps it - * in try/catch and falls back to a static default set, so a registry read - * failure degrades the select (fewer options) rather than failing the whole - * product detail. Reads `GET /admin/tax/classes` (the rules-admin surface; - * GET is not write-gated). The forward admin token is attached like every - * other read. - */ - async getTaxClasses(): Promise { - const body = await this.#getJson<{ classes?: TaxClassWire[] }>("/admin/tax/classes"); - return body.classes ?? []; - } - - #authHeaders(): Record { - return this.#adminToken === undefined ? {} : { "X-Internal-Token": this.#adminToken }; - } - - async #getJson(path: string): Promise { - const res = await this.#fetch(`${this.#baseUrl}${path}`, { - method: "GET", - headers: this.#authHeaders(), - }); - if (!res.ok) throw new Error(`GET ${path} failed (HTTP ${res.status})`); - return (await res.json()) as T; - } -} - -/** Parse a response body as JSON, tolerating an empty/invalid body (a structured - * error the plugin still classifies by status). */ -async function safeJson(res: Response): Promise { - try { - return await res.json(); - } catch { - return undefined; - } -} diff --git a/packages/plugin/src/admin/admin-products-surface.ts b/packages/plugin/src/admin/admin-products-surface.ts new file mode 100644 index 00000000..977e9622 --- /dev/null +++ b/packages/plugin/src/admin/admin-products-surface.ts @@ -0,0 +1,292 @@ +/** + * The admin Products console surface — the port the console pages hold, plus the + * wire-shaped types that cross it. A read surface (list + detail) plus the + * guarded commerce EDIT (`updateProduct`) and the merchant stock movements + * (`restock` / `removeStock`). No product-CREATE method here — that stays in the + * CMS sync path. + * + * These types are defined LOCALLY and deliberately: this module NEVER imports + * `@otta-sh/domain`, which keeps the plugin sandbox-clean (enforced by the + * dependency-cruiser rule, MOD-4). Money is integer minor units + ISO-4217 + * currency throughout. The "wire" in the names is historical — it was once the + * JSON shape of a separate commerce service — and it is still exactly the shape + * the admin route's JSON responses use, so the name stays accurate. + */ + +/** A lightweight product row for the admin list. It DOES carry stock: the list + * read sources `onHand` in ONE batched pass per page, so it never N+1s into + * inventory per row (see {@link AdminProductsSurface.getProduct}'s doc for the + * detail leaf's separate single-sku read). */ +export interface ProductSummaryWire { + productId: string; + sku: string | null; + title: string | null; + priceCents: number | null; + currency: string | null; + productKind: string; + active: boolean; + /** Stock on hand — a COUNT, never money (no minor units, no currency). + * + * `null` means the sku has NO inventory record (or the product has no sku + * at all): "unknown", which is NOT `0` ("out of stock"). A renderer must + * keep the two apart — a dash for `null`, a literal `0` for zero — and + * never fold either into the other. */ + onHand: number | null; + /** Soft-delete tombstone (product lifecycle surfacing). Null on every row + * of a default (live) page; non-null only in the archive view + * (`ProductsListFilter.deleted: true`). */ + deletedAt: string | null; + createdAt: string; +} + +/** The full admin Product detail (read-only) — carries the single-sku stock + * read (`onHand`) the detail leaf fetches for the ONE product opened; the + * list gets the same field from its per-page join instead. */ +export interface ProductDetailWire { + productId: string; + sku: string | null; + title: string | null; + priceCents: number | null; + currency: string | null; + taxClass: string | null; + /** Increment 2 slice 5: compare-at / was-price (shares the product currency; + * display-only). Both halves null ⇒ unset. */ + compareAtCents: number | null; + compareAtCurrency: string | null; + /** Increment 2 slice 5: ADMIN-ONLY unit cost (shares the product currency). + * Present here because this is the internal-token admin detail — never on a + * storefront wire. Both halves null ⇒ unset. */ + unitCostCents: number | null; + unitCostCurrency: string | null; + /** Increment 2 slice 5: out-of-stock policy (always `"deny"` this slice). */ + inventoryPolicy: string; + weightGrams: number | null; + lengthMm: number | null; + widthMm: number | null; + heightMm: number | null; + productKind: string; + active: boolean; + /** Soft-delete tombstone (product lifecycle surfacing). Non-null ⇒ this IS + * the read-only archive view — the detail leaf renders it instead of the + * edit/stock forms (see `products-page.ts`'s `detailBlocks`). A 404 (never + * existed) is still `getProduct` returning `null`; a deleted row is a 200 + * with this field set. */ + deletedAt: string | null; + /** Stock on hand for this product's sku — a COUNT, never money. + * + * SAME SEMANTICS AS THE LIST's `ProductSummaryWire.onHand` (INC-23): `null` + * means the sku has NO inventory record (or the product has no sku at all) + * — "unknown" — and `0` means a known sku that is out of stock. This used to + * be a bare `number` with both cases collapsed to `0`, so one product read + * `—` in the list and `0` on its own detail page. A renderer keeps the two + * apart with the same helper the list column uses. */ + onHand: number | null; + createdAt: string; + updatedAt: string; +} + +/** The list filter the console builds from its filter form. `active` is a + * tri-state string ("" ⇒ both) so the wire query mirrors the service's + * `active=true|false` param exactly. `deleted` is the archive-view toggle + * (product lifecycle surfacing): omitted/false ⇒ the original default (live + * rows only); true ⇒ ONLY soft-deleted rows. */ +export interface ProductsListFilter { + active?: boolean; + deleted?: boolean; + productKind?: string; + search?: string; + /** The store's low-stock threshold, when the console has resolved one AND + * "Low stock only" is on — mirrors the domain port's `ProductListFilter. + * lowStockThreshold` one field at a time, same as every other + * axis here. OMITTED means "no stock-based filtering", not "threshold 0": + * the caller (the console route) only sets this once a settings read has + * actually resolved a number, never a raw checkbox state. */ + lowStockThreshold?: number; +} + +export interface ProductsListResult { + products: ProductSummaryWire[]; + /** Opaque keyset cursor for the next page, or null on the last page. */ + nextCursor: string | null; + /** + * Exact number of products matching the ACTIVE FILTER — the whole set, not + * this page (INC-23). + * + * OPTIONAL for one reason only: a service older than the field omits it, and + * a renderer must then fall back to the page-scoped count it always had + * ("25 products on this page"). Never defaulted to `0` — that would caption + * a page of rows with a count of none. + */ + total?: number; + /** + * THIS IS PAGE ONE, because the cursor the caller asked with was REFUSED — + * mismatched against these filters, or undecodable — and + * {@link AdminProductsSurface.listProducts} re-issued without it. Absent on + * every ordinary page, first pages included: the flag means "you asked for a + * page you did not get", which the renderer has to be able to say out loud. + * Same contract, same reasoning, as the Orders client's. + */ + cursorRejected?: true; +} + +/** The commerce-owned fields a product edit may change. The set is STRICT — an + * unknown key is refused as invalid, never silently stripped. `expectedUpdatedAt` is the optimistic-concurrency + * watermark the admin loaded; the service compare-and-sets on it. Money is an + * integer minor-units + ISO-4217 pair — never a float. NO `active` (the CMS + * publish gate is not edited here) and NO `title` (CMS-owned, written only by + * the content sync — `adr/0013-product-title-is-cms-owned.md`). */ +export interface ProductEditWire { + expectedUpdatedAt: string; + sku?: string; + price?: { amount: number; currency: string }; + taxClass?: string | null; + /** Increment 2 slice 5: compare-at / cost — money (integer minor units + + * ISO-4217), null to CLEAR. Must share the product's price currency (the + * service/domain enforce it; a mismatch is a per-field error). */ + compareAtPrice?: { amount: number; currency: string } | null; + unitCost?: { amount: number; currency: string } | null; + weightGrams?: number | null; + lengthMm?: number | null; + widthMm?: number | null; + heightMm?: number | null; + productKind?: string; + /** Out-of-stock policy — only `"deny"` is accepted this slice. */ + inventoryPolicy?: string; +} + +/** One tax-class registry entry (mirrors the domain `TaxClass`) — the edit + * form's tax-class select is sourced from these. */ +export interface TaxClassWire { + id: string; + name: string; +} + +/** Discriminated edit outcome — the plugin renders each without status-code-as- + * logic (stale → reload notice, currency/sku → per-field warning). + * + * THE TWO RENAME REFUSALS ARE THEIR OWN MEMBERS, not one "sku problem". They + * ask the operator for different things — pick another sku, versus wait for + * the carts to finish — so folding them together would cost the only sentence + * that helps, and each carries the operands its sentence names. */ +export type ProductEditResult = + | { ok: true; updatedAt: string | null } + | { ok: false; reason: "not_found" } + | { ok: false; reason: "stale"; currentUpdatedAt: string | null } + | { ok: false; reason: "currency_mismatch"; currency: string | null } + | { ok: false; reason: "sku_taken"; sku: string | null } + /** The rename's target sku already has an inventory row of its own; stock is + * never merged between skus, so the rename was refused whole. */ + | { ok: false; reason: "sku_stock_conflict"; fromSku: string | null; toSku: string | null } + /** Live held/adopted reservations still name the sku being renamed away + * from. `liveHolds` is `null` only if the service omitted the count. */ + | { ok: false; reason: "sku_held_stock"; sku: string | null; liveHolds: number | null } + | { ok: false; reason: "invalid"; field: string | null } + | { ok: false; reason: "error" }; + +/** Discriminated restock outcome (admin-UX Increment 2 slice 3). `not_found`/ + * `no_sku`/`no_inventory_row` are the productId → sku resolution failures; the + * panel renders each without treating a status code as logic. */ +export type RestockResult = + | { ok: true; onHand: number } + | { ok: false; reason: "not_found" } + | { ok: false; reason: "no_sku" } + | { ok: false; reason: "no_inventory_row" } + | { ok: false; reason: "invalid" } + | { ok: false; reason: "error" }; + +/** Discriminated stock-removal outcome (admin-UX Increment 2 slice 3). Adds + * `insufficient_stock` (carrying the current count) — the guarded floor that + * keeps a removal from ever driving on-hand below zero. */ +export type StockRemovalResult = + | { ok: true; onHand: number } + | { ok: false; reason: "not_found" } + | { ok: false; reason: "no_sku" } + | { ok: false; reason: "no_inventory_row" } + | { ok: false; reason: "insufficient_stock"; onHand: number } + | { ok: false; reason: "invalid" } + | { ok: false; reason: "error" }; + +/** + * THE ADMIN PRODUCTS SURFACE, structurally — what a caller may do, with no claim + * about how it gets done. + * + * ONE implementation answers to this now (work order 02, INC-D3b): + * `InProcessAdminProductsClient`, which composes this behaviour over the + * plugin's own document store. The `ctx.http` client that used to be the second + * implementation is gone with the commerce service it talked to, and with it the + * reason this was a `Pick` over a nominal class rather than an interface — so it + * is written out as an interface now, which is what it always described. + * + * EVERY METHOD IS LISTED, and writing them out is still the point: a method + * added to the in-process client without being declared here is not part of the + * surface, and a method declared here that the client does not implement is a + * compile error. The surface stays a deliberate decision rather than whatever + * one class happens to expose. + */ +export interface AdminProductsSurface { + /** + * Update the commerce-owned fields of one product (admin-UX Increment 2 slice + * 2). `key` is the stable idempotency key (a double-submit dedupes). Every + * failure mode is a named `reason` on the result, so the caller never + * inspects a status code and never has to guess which sentence to show. + */ + updateProduct(productId: string, body: ProductEditWire, key: string): Promise; + + /** + * RESTOCK — ADD `qty` units to the product's stock (admin-UX Increment 2 slice + * 3). `key` is REQUIRED and must be stable per submission: a restock is + * additive (not idempotent by nature), so there is no safe content-only + * fallback — a double-submit dedupes only when the SAME key is sent. + */ + restock(productId: string, qty: number, key: string): Promise; + + /** + * STOCK REMOVAL — remove `qty` damaged/shrinkage units (admin-UX Increment 2 + * slice 3). Same required-key discipline as {@link restock}. The decrement is + * GUARDED, so an over-removal is a clean `insufficient_stock` (carrying the + * current count), never a negative stock or a throw. + */ + removeStock(productId: string, qty: number, key: string): Promise; + + /** + * THE FILTER TRAVELS BESIDE THE CURSOR, and it did not used to — the same + * correction, for the same reason, as {@link AdminOrdersSurface.listOrders}, + * whose doc carries the argument in full. In short: the reader used to take + * the predicate solely from the token and never look at the filter passed + * alongside it, so an unfiltered token sent beside a low-stock threshold + * answered with the unfiltered catalog and a console captioned those rows + * "low-stock". The two are now compared as predicates and a disagreement + * refuses the cursor, which is only useful if the call states both. + * + * EVERY AXIS PARTICIPATES, the threshold included — `0` is a real threshold + * and is compared as one, never read as "absent". So a paged low-stock call + * must carry the threshold page one was filtered by; the console route + * resolves it before paging for exactly that reason. + * + * NO CASE FOLDING before the store: the comparison is case-sensitive by + * design, and a caller that normalised a search term on one call but not the + * other would manufacture mismatches. + * + * A REFUSED CURSOR IS RECOVERED HERE, not reported — page one is re-read with + * the same filter and flagged `cursorRejected`. See the Orders doc for why the + * flag matters as much as the rows. + */ + listProducts( + filter: ProductsListFilter, + opts?: { cursor?: string; limit?: number }, + ): Promise; + + /** Read one product's full detail (incl. stock). A product that does not + * exist resolves to `null` (the console renders a "not found" state, not an + * error banner); a soft-deleted one is a real row with `deletedAt` set. */ + getProduct(productId: string): Promise; + + /** + * Read the tax-class registry (Increment 2 slice 5) — the source for the edit + * form's tax-class select. A SECONDARY, best-effort read: the caller wraps it + * in try/catch and falls back to a static default set, so a registry read + * failure degrades the select (fewer options) rather than failing the whole + * product detail. + */ + getTaxClasses(): Promise; +} diff --git a/packages/plugin/src/admin/admin-rules-client.ts b/packages/plugin/src/admin/admin-rules-client.ts deleted file mode 100644 index c67167c6..00000000 --- a/packages/plugin/src/admin/admin-rules-client.ts +++ /dev/null @@ -1,646 +0,0 @@ -import type { HttpAccess } from "../types.js"; - -/** - * A tiny `ctx.http`-only client for the admin RULES service surface — shipping - * (zones → methods → rates), tax (classes, rates) and coupons (admin-UX - * Increment 3). Same transport discipline as `AdminOrdersClient` / - * `AdminProductsClient` (no new primitive): the injected `ctx.http.fetch` is the - * ONLY egress, money is integer minor units + ISO-4217 currency on the wire, and - * the wire types are defined LOCALLY — this module NEVER imports `@otta-sh/domain`, - * keeping the plugin sandbox-clean (enforced by the dependency-cruiser rule, - * MOD-4). `#fetch` is `#`-prefixed so the sandbox-clean grep guard sees no bare - * fetch call. - * - * Covers the FULL rules surface: the existing reads + creates AND the new - * UPDATE/DELETE capability this slice adds. No UI is built here (later slices - * consume this client). Every mutation forwards BOTH the admin token - * (`X-Internal-Token`) and, when set, the write-gate token (`X-Service-Token`); - * reads are gate-exempt GETs and carry only the admin token. - */ - -// -- Wire types (local; never `@otta-sh/domain`) -------------------------------- - -export interface ShippingZoneWire { - id: string; - name: string; - regions: unknown; -} - -export interface ShippingMethodWire { - id: string; - zoneId: string; - name: string; - /** 'flat_rate' | 'free_shipping'. */ - type: string; -} - -export interface ShippingRateWire { - methodId: string; - currency: string; - amountCents: number; - minSubtotalCents: number | null; -} - -export interface TaxClassWire { - id: string; - name: string; -} - -export interface TaxRateWire { - id: string; - taxClassId: string; - zoneId: string; - rateBps: number; - appliesToShipping: boolean; -} - -/** Mirrors the service `serializeCoupon` shape (start/expiry are intentionally - * not serialized by the service, so they are absent here). */ -export interface CouponWire { - id: string; - code: string; - type: string; - amountCents: number | null; - rateBps: number | null; - capCents: number | null; - currency: string | null; - minSubtotalCents: number | null; - maxUses: number | null; - maxUsesPerCustomer: number | null; - usesCount: number; -} - -/** One admin Coupons-list row (admin-UX Increment 3, view-only enumerate). - * The FULL coupon summary — every `CouponWire` field PLUS the validity - * window (`startsAt`/`expiresAt`, absent from `CouponWire` because - * `serializeCoupon` omits them) and `createdAt`: a small, header-only table - * has nothing expensive to trim off the list projection (unlike - * `ProductSummaryWire`, which deliberately narrows the full product row), - * and the console list renders the expiry column directly — no per-row - * detail fetch. `usesCount` doubles as the redeemed indicator (already a - * plain column, no join). */ -export interface CouponSummaryWire { - id: string; - code: string; - type: string; - amountCents: number | null; - rateBps: number | null; - capCents: number | null; - currency: string | null; - minSubtotalCents: number | null; - startsAt: string | null; - expiresAt: string | null; - maxUses: number | null; - maxUsesPerCustomer: number | null; - usesCount: number; - createdAt: string; -} - -/** The list filter the console builds from its filter form. `search` is the - * ONLY axis this slice ships (coupons have no soft-delete/publish-gate/kind - * axis to mirror `ProductsListFilter`'s `deleted`/`active`/`productKind`) — - * a case-insensitive EXACT match on `code`, never a substring. */ -export interface CouponsListFilter { - search?: string; -} - -export interface CouponsListResult { - coupons: CouponSummaryWire[]; - /** Opaque keyset cursor for the next page, or null on the last page. */ - nextCursor: string | null; - /** - * Exact number of coupons matching the ACTIVE FILTER — the whole set, not - * this page (INC-23). - * - * OPTIONAL for one reason only: a service older than the field omits it, and - * a renderer must then fall back to the page-scoped count it always had - * ("25 coupons on this page"). Never defaulted to `0` — that would caption a - * page of rows with a count of none. - */ - total?: number; -} - -// -- Discriminated results ---------------------------------------------------- -// A failure NEVER throws into the host; it surfaces a typed reason the caller -// renders as GENERIC copy, never a raw HTTP status/URL. - -/** Create outcome — a 2xx carries the created row; anything else is a status. */ -export type RulesCreateResult = { ok: true; value: T } | { ok: false; status: number }; - -/** LWW-update outcome (zones, methods, coupons) — no `stale` (no CAS). */ -export type RulesUpdateResult = - | { ok: true; value: T } - | { ok: false; reason: "not_found" } - | { ok: false; reason: "error"; status: number }; - -/** CAS-update outcome (shipping/tax rates) — `stale` carries the fresh row so - * the caller can reload rather than blind-retry a losing edit. */ -export type RulesCasUpdateResult = - | { ok: true; value: T } - | { ok: false; reason: "not_found" } - | { ok: false; reason: "stale"; current: T | null } - | { ok: false; reason: "error"; status: number }; - -/** Delete outcome. `in_use` is the referential-guard refusal (a zone with - * methods, a method with rates, a redeemed coupon); leaf-rate deletes never - * return it. `not_found` is the idempotent no-op. */ -export type RulesDeleteResult = - | { ok: true } - | { ok: false; reason: "not_found" } - | { ok: false; reason: "in_use" } - | { ok: false; reason: "error"; status: number }; - -/** - * Tax-class delete outcome (Increment 3 closeout). A DEDICATED result type, - * not the generic `RulesDeleteResult` — `deleteTaxClass`'s two in-use - * reasons (product vs. rate references) each carry a `count` (the service's - * 409 body), so the console can render an HONEST "N products/rates - * reference this class" instead of the generic screens' bare "in use, delete - * the children first" copy. - */ -export type TaxClassDeleteResult = - | { ok: true } - | { ok: false; reason: "not_found" } - | { ok: false; reason: "in_use_by_products"; count: number } - | { ok: false; reason: "in_use_by_rates"; count: number } - | { ok: false; reason: "error"; status: number }; - -// -- Input shapes ------------------------------------------------------------- - -export interface ShippingZoneInput { - id: string; - name: string; - regions?: unknown; -} -/** Full-replace edit — `regions` is REQUIRED (the service 400s an omitted key - * so an edit can never silently wipe the zone's match list); send `null` to - * clear deliberately. */ -export interface ShippingZoneEdit { - name: string; - regions: unknown; -} -export interface ShippingMethodInput { - id: string; - name: string; - type: string; -} -export interface ShippingMethodEdit { - name: string; - type: string; -} -export interface ShippingRateInput { - currency: string; - amountCents: number; - minSubtotalCents?: number | null; -} -/** Full-replace edit — `minSubtotalCents` is REQUIRED-nullable (the service - * 400s an omitted key so an edit can never silently clear the free-shipping - * threshold); send `null` to clear deliberately. */ -export interface ShippingRateEdit { - amountCents: number; - minSubtotalCents: number | null; - /** The money-bearing CAS token — the amount the admin read on the detail. */ - expectedAmountCents: number; -} -export interface TaxClassInput { - id: string; - name: string; -} -/** Full-replace rename (LWW, no CAS — a class carries no money); `id` is - * immutable identity and is never sent (the path param addresses it). */ -export interface TaxClassEdit { - name: string; -} -export interface TaxRateInput { - id: string; - taxClassId: string; - zoneId: string; - rateBps: number; - appliesToShipping?: boolean; -} -/** Full-replace edit — `appliesToShipping` is REQUIRED (the service 400s an - * omitted key so an edit can never silently flip the shipping-tax behavior). */ -export interface TaxRateEdit { - rateBps: number; - appliesToShipping: boolean; - /** The money-bearing CAS token — the rate the admin read on the detail. */ - expectedRateBps: number; -} -export interface CouponInput { - id: string; - code: string; - type: string; - amountCents?: number | null; - rateBps?: number | null; - capCents?: number | null; - currency?: string | null; - minSubtotalCents?: number | null; - startsAt?: string | null; - expiresAt?: string | null; - maxUses?: number | null; - maxUsesPerCustomer?: number | null; -} -/** Coupon edit — `id`/`code`/`type`/`currency` are immutable identity/kind and - * are NOT sent (the service rejects re-defining them). */ -export interface CouponEdit { - amountCents?: number | null; - rateBps?: number | null; - capCents?: number | null; - minSubtotalCents?: number | null; - startsAt?: string | null; - expiresAt?: string | null; - maxUses?: number | null; - maxUsesPerCustomer?: number | null; -} - -export interface AdminRulesClientOptions { - fetch: HttpAccess["fetch"]; - baseUrl: string; - /** Admin token forwarded as `X-Internal-Token` on every guarded call. */ - adminToken?: string; - /** Machine write-gate token forwarded as `X-Service-Token` on every NON-GET. */ - serviceToken?: string; -} - -/** - * THE ADMIN RULES SURFACE, structurally — what a caller may do to shipping - * zones/methods/rates, tax classes/rates and coupons, with no claim about how it - * gets done. - * - * Two implementations answer to this now (work order 02, INC-B10c-i): the - * `ctx.http` client below, and `InProcessAdminRulesClient`, which composes the - * same behaviour over the plugin's own document store. `AdminRulesClient` itself - * cannot be that type — its `#`-private fields make it nominal, so no second - * class is ever assignable to it — hence a `Pick` over its methods, the same - * idiom the contract suite's surfaces already use. - * - * EVERY METHOD IS LISTED, all twenty-five. Written out rather than derived, - * because this is much the widest surface in the console and one that listed - * fewer would let a method be forgotten SILENTLY: adding a method to the client - * without deciding what the in-process tier does about it has to be a compile - * error here, not a capability that quietly exists on one transport only. - */ -export type AdminRulesSurface = Pick< - AdminRulesClient, - | "listZones" - | "createZone" - | "updateZone" - | "deleteZone" - | "listMethods" - | "createMethod" - | "updateMethod" - | "deleteMethod" - | "getRate" - | "createRate" - | "updateRate" - | "deleteRate" - | "listTaxClasses" - | "createTaxClass" - | "updateTaxClass" - | "deleteTaxClass" - | "listTaxRates" - | "createTaxRate" - | "updateTaxRate" - | "deleteTaxRate" - | "listCoupons" - | "getCoupon" - | "createCoupon" - | "updateCoupon" - | "deleteCoupon" ->; - -export class AdminRulesClient { - readonly #fetch: HttpAccess["fetch"]; - readonly #baseUrl: string; - readonly #adminToken: string | undefined; - readonly #serviceToken: string | undefined; - - constructor(options: AdminRulesClientOptions) { - this.#fetch = options.fetch; - this.#baseUrl = options.baseUrl.replace(/\/$/, ""); - this.#adminToken = options.adminToken; - this.#serviceToken = options.serviceToken; - } - - // -- Shipping: zones ------------------------------------------------------- - - async listZones(): Promise { - const body = await this.#getJson<{ zones?: ShippingZoneWire[] }>("/admin/shipping/zones"); - return body.zones ?? []; - } - - async createZone(input: ShippingZoneInput): Promise> { - return this.#create("/admin/shipping/zones", input, "zone"); - } - - async updateZone( - zoneId: string, - edit: ShippingZoneEdit, - ): Promise> { - const res = await this.#write( - "PUT", - `/admin/shipping/zones/${encodeURIComponent(zoneId)}`, - edit, - ); - return this.#lwwResult(res, "zone"); - } - - async deleteZone(zoneId: string): Promise { - const res = await this.#write("DELETE", `/admin/shipping/zones/${encodeURIComponent(zoneId)}`); - return this.#deleteResult(res); - } - - // -- Shipping: methods ----------------------------------------------------- - - async listMethods(zoneId: string): Promise { - const body = await this.#getJson<{ methods?: ShippingMethodWire[] }>( - `/admin/shipping/zones/${encodeURIComponent(zoneId)}/methods`, - ); - return body.methods ?? []; - } - - async createMethod( - zoneId: string, - input: ShippingMethodInput, - ): Promise> { - return this.#create( - `/admin/shipping/zones/${encodeURIComponent(zoneId)}/methods`, - input, - "method", - ); - } - - async updateMethod( - methodId: string, - edit: ShippingMethodEdit, - ): Promise> { - const res = await this.#write( - "PUT", - `/admin/shipping/methods/${encodeURIComponent(methodId)}`, - edit, - ); - return this.#lwwResult(res, "method"); - } - - async deleteMethod(methodId: string): Promise { - const res = await this.#write( - "DELETE", - `/admin/shipping/methods/${encodeURIComponent(methodId)}`, - ); - return this.#deleteResult(res); - } - - // -- Shipping: rates ------------------------------------------------------- - - async getRate(methodId: string, currency: string): Promise { - const q = new URLSearchParams({ currency }); - const res = await this.#fetch( - `${this.#baseUrl}/admin/shipping/methods/${encodeURIComponent(methodId)}/rates?${q.toString()}`, - { method: "GET", headers: this.#authHeaders() }, - ); - if (res.status === 404) return null; - if (!res.ok) throw new Error(`GET shipping rate failed (HTTP ${res.status})`); - const body = (await res.json()) as { rate?: ShippingRateWire }; - return body.rate ?? null; - } - - async createRate( - methodId: string, - input: ShippingRateInput, - ): Promise> { - return this.#create( - `/admin/shipping/methods/${encodeURIComponent(methodId)}/rates`, - input, - "rate", - ); - } - - async updateRate( - methodId: string, - currency: string, - edit: ShippingRateEdit, - ): Promise> { - const res = await this.#write( - "PUT", - `/admin/shipping/methods/${encodeURIComponent(methodId)}/rates/${encodeURIComponent(currency)}`, - edit, - ); - return this.#casResult(res, "rate"); - } - - async deleteRate(methodId: string, currency: string): Promise { - const res = await this.#write( - "DELETE", - `/admin/shipping/methods/${encodeURIComponent(methodId)}/rates/${encodeURIComponent(currency)}`, - ); - return this.#deleteResult(res); - } - - // -- Tax: classes ---------------------------------------------------------- - - async listTaxClasses(): Promise { - const body = await this.#getJson<{ classes?: TaxClassWire[] }>("/admin/tax/classes"); - return body.classes ?? []; - } - - async createTaxClass(input: TaxClassInput): Promise> { - return this.#create("/admin/tax/classes", input, "taxClass"); - } - - async updateTaxClass( - classId: string, - edit: TaxClassEdit, - ): Promise> { - const res = await this.#write("PUT", `/admin/tax/classes/${encodeURIComponent(classId)}`, edit); - return this.#lwwResult(res, "taxClass"); - } - - /** - * Delete a tax class (Increment 3 closeout — wiring the `deleteTaxClass` - * use-case, contract-tested since Increment 2 slice 5 but never routed). - * A dedicated parser (not `#deleteResult`): the 409 body carries a `count` - * this method surfaces, unlike the generic zone/method/coupon deletes. - */ - async deleteTaxClass(classId: string): Promise { - const res = await this.#write("DELETE", `/admin/tax/classes/${encodeURIComponent(classId)}`); - if (res.status === 200) return { ok: true }; - if (res.status === 404) return { ok: false, reason: "not_found" }; - if (res.status === 409) { - const parsed = (await safeJson(res)) as { reason?: string; count?: number } | undefined; - const count = typeof parsed?.count === "number" ? parsed.count : 0; - if (parsed?.reason === "IN_USE_BY_PRODUCTS") { - return { ok: false, reason: "in_use_by_products", count }; - } - if (parsed?.reason === "IN_USE_BY_RATES") { - return { ok: false, reason: "in_use_by_rates", count }; - } - return { ok: false, reason: "error", status: res.status }; - } - return { ok: false, reason: "error", status: res.status }; - } - - // -- Tax: rates ------------------------------------------------------------ - - async listTaxRates(zoneId: string): Promise { - const q = new URLSearchParams({ zoneId }); - const body = await this.#getJson<{ rates?: TaxRateWire[] }>(`/admin/tax/rates?${q.toString()}`); - return body.rates ?? []; - } - - async createTaxRate(input: TaxRateInput): Promise> { - return this.#create("/admin/tax/rates", input, "rate"); - } - - async updateTaxRate( - rateId: string, - edit: TaxRateEdit, - ): Promise> { - const res = await this.#write("PUT", `/admin/tax/rates/${encodeURIComponent(rateId)}`, edit); - return this.#casResult(res, "rate"); - } - - async deleteTaxRate(rateId: string): Promise { - const res = await this.#write("DELETE", `/admin/tax/rates/${encodeURIComponent(rateId)}`); - return this.#deleteResult(res); - } - - // -- Coupons --------------------------------------------------------------- - - /** - * GET the admin Coupons console list (admin-UX Increment 3, view-only - * enumerate — the missing atomic primitive this slice adds). Mirrors - * `AdminProductsClient.listProducts`'s shape: pass EITHER a fresh `filter` - * OR a previous page's `opts.cursor` (never both — the cursor already - * embeds the active filter, so sending a filter alongside it could disagree - * with what the server re-derives from the token). - */ - async listCoupons( - filter: CouponsListFilter, - opts: { cursor?: string; limit?: number } = {}, - ): Promise { - const q = new URLSearchParams(); - if (opts.cursor !== undefined && opts.cursor.length > 0) { - q.set("cursor", opts.cursor); - } else if (filter.search !== undefined && filter.search.length > 0) { - q.set("search", filter.search); - } - if (opts.limit !== undefined) q.set("limit", String(opts.limit)); - const body = await this.#getJson<{ - coupons?: CouponSummaryWire[]; - nextCursor?: string | null; - total?: unknown; - }>(`/admin/coupons?${q.toString()}`); - return { - coupons: body.coupons ?? [], - nextCursor: body.nextCursor ?? null, - // ABSENT STAYS ABSENT (never `?? 0`) — see `CouponsListResult.total`. - ...(typeof body.total === "number" ? { total: body.total } : {}), - }; - } - - async getCoupon(code: string): Promise { - const res = await this.#fetch(`${this.#baseUrl}/admin/coupons/${encodeURIComponent(code)}`, { - method: "GET", - headers: this.#authHeaders(), - }); - if (res.status === 404) return null; - if (!res.ok) throw new Error(`GET coupon failed (HTTP ${res.status})`); - const body = (await res.json()) as { coupon?: CouponWire }; - return body.coupon ?? null; - } - - async createCoupon(input: CouponInput): Promise> { - return this.#create("/admin/coupons", input, "coupon"); - } - - async updateCoupon(couponId: string, edit: CouponEdit): Promise> { - const res = await this.#write("PUT", `/admin/coupons/${encodeURIComponent(couponId)}`, edit); - return this.#lwwResult(res, "coupon"); - } - - async deleteCoupon(couponId: string): Promise { - const res = await this.#write("DELETE", `/admin/coupons/${encodeURIComponent(couponId)}`); - return this.#deleteResult(res); - } - - // -- internals ------------------------------------------------------------- - - async #create(path: string, body: unknown, field: string): Promise> { - const res = await this.#write("POST", path, body); - if (res.status >= 200 && res.status < 300) { - const parsed = (await safeJson(res)) as Record | undefined; - const value = parsed?.[field] as T | undefined; - if (value !== undefined) return { ok: true, value }; - } - return { ok: false, status: res.status }; - } - - async #lwwResult(res: Response, field: string): Promise> { - if (res.status === 200) { - const parsed = (await safeJson(res)) as Record | undefined; - const value = parsed?.[field] as T | undefined; - if (value !== undefined) return { ok: true, value }; - return { ok: false, reason: "error", status: res.status }; - } - if (res.status === 404) return { ok: false, reason: "not_found" }; - return { ok: false, reason: "error", status: res.status }; - } - - async #casResult(res: Response, field: string): Promise> { - if (res.status === 200) { - const parsed = (await safeJson(res)) as Record | undefined; - const value = parsed?.[field] as T | undefined; - if (value !== undefined) return { ok: true, value }; - return { ok: false, reason: "error", status: res.status }; - } - if (res.status === 404) return { ok: false, reason: "not_found" }; - if (res.status === 409) { - const parsed = (await safeJson(res)) as { reason?: string; current?: T } | undefined; - if (parsed?.reason === "STALE") { - return { ok: false, reason: "stale", current: parsed.current ?? null }; - } - return { ok: false, reason: "error", status: res.status }; - } - return { ok: false, reason: "error", status: res.status }; - } - - async #deleteResult(res: Response): Promise { - if (res.status === 200) return { ok: true }; - if (res.status === 404) return { ok: false, reason: "not_found" }; - if (res.status === 409) return { ok: false, reason: "in_use" }; - return { ok: false, reason: "error", status: res.status }; - } - - #write(method: "POST" | "PUT" | "DELETE", path: string, body?: unknown): Promise { - const headers: Record = {}; - if (body !== undefined) headers["Content-Type"] = "application/json"; - if (this.#adminToken !== undefined) headers["X-Internal-Token"] = this.#adminToken; - if (this.#serviceToken !== undefined) headers["X-Service-Token"] = this.#serviceToken; - return this.#fetch(`${this.#baseUrl}${path}`, { - method, - headers, - ...(body !== undefined ? { body: JSON.stringify(body) } : {}), - }); - } - - #authHeaders(): Record { - return this.#adminToken === undefined ? {} : { "X-Internal-Token": this.#adminToken }; - } - - async #getJson(path: string): Promise { - const res = await this.#fetch(`${this.#baseUrl}${path}`, { - method: "GET", - headers: this.#authHeaders(), - }); - if (!res.ok) throw new Error(`GET ${path} failed (HTTP ${res.status})`); - return (await res.json()) as T; - } -} - -async function safeJson(res: Response): Promise { - try { - return await res.json(); - } catch { - return undefined; - } -} diff --git a/packages/plugin/src/admin/admin-rules-surface.ts b/packages/plugin/src/admin/admin-rules-surface.ts new file mode 100644 index 00000000..8ecfe19a --- /dev/null +++ b/packages/plugin/src/admin/admin-rules-surface.ts @@ -0,0 +1,348 @@ +/** + * The admin RULES surface — shipping (zones → methods → rates), tax (classes, + * rates) and coupons (admin-UX Increment 3) — plus the wire-shaped types that + * cross it. Covers the FULL rules surface: reads, creates, updates and deletes. + * No UI is built here (the console pages consume this port). + * + * These types are defined LOCALLY and deliberately: this module NEVER imports + * `@otta-sh/domain`, which keeps the plugin sandbox-clean (enforced by the + * dependency-cruiser rule, MOD-4). Money is integer minor units + ISO-4217 + * currency throughout. The "wire" in the names is historical — it was once the + * JSON shape of a separate commerce service — and it is still exactly the shape + * the admin route's JSON responses use, so the name stays accurate. + */ + +// -- Wire types (local; never `@otta-sh/domain`) -------------------------------- + +export interface ShippingZoneWire { + id: string; + name: string; + regions: unknown; +} + +export interface ShippingMethodWire { + id: string; + zoneId: string; + name: string; + /** 'flat_rate' | 'free_shipping'. */ + type: string; +} + +export interface ShippingRateWire { + methodId: string; + currency: string; + amountCents: number; + minSubtotalCents: number | null; +} + +export interface TaxClassWire { + id: string; + name: string; +} + +export interface TaxRateWire { + id: string; + taxClassId: string; + zoneId: string; + rateBps: number; + appliesToShipping: boolean; +} + +/** The serialized coupon shape the admin routes emit (start/expiry are + * intentionally not serialized there, so they are absent here — the list row + * {@link CouponSummaryWire} carries them instead). */ +export interface CouponWire { + id: string; + code: string; + type: string; + amountCents: number | null; + rateBps: number | null; + capCents: number | null; + currency: string | null; + minSubtotalCents: number | null; + maxUses: number | null; + maxUsesPerCustomer: number | null; + usesCount: number; +} + +/** One admin Coupons-list row (admin-UX Increment 3, view-only enumerate). + * The FULL coupon summary — every `CouponWire` field PLUS the validity + * window (`startsAt`/`expiresAt`, absent from `CouponWire` because + * `serializeCoupon` omits them) and `createdAt`: a small, header-only table + * has nothing expensive to trim off the list projection (unlike + * `ProductSummaryWire`, which deliberately narrows the full product row), + * and the console list renders the expiry column directly — no per-row + * detail fetch. `usesCount` doubles as the redeemed indicator (already a + * plain column, no join). */ +export interface CouponSummaryWire { + id: string; + code: string; + type: string; + amountCents: number | null; + rateBps: number | null; + capCents: number | null; + currency: string | null; + minSubtotalCents: number | null; + startsAt: string | null; + expiresAt: string | null; + maxUses: number | null; + maxUsesPerCustomer: number | null; + usesCount: number; + createdAt: string; +} + +/** The list filter the console builds from its filter form. `search` is the + * ONLY axis this slice ships (coupons have no soft-delete/publish-gate/kind + * axis to mirror `ProductsListFilter`'s `deleted`/`active`/`productKind`) — + * a case-insensitive EXACT match on `code`, never a substring. */ +export interface CouponsListFilter { + search?: string; +} + +export interface CouponsListResult { + coupons: CouponSummaryWire[]; + /** Opaque keyset cursor for the next page, or null on the last page. */ + nextCursor: string | null; + /** + * Exact number of coupons matching the ACTIVE FILTER — the whole set, not + * this page (INC-23). + * + * OPTIONAL for one reason only: a service older than the field omits it, and + * a renderer must then fall back to the page-scoped count it always had + * ("25 coupons on this page"). Never defaulted to `0` — that would caption a + * page of rows with a count of none. + */ + total?: number; +} + +// -- Discriminated results ---------------------------------------------------- +// A failure NEVER throws into the host; it surfaces a typed reason the caller +// renders as GENERIC copy, never a raw HTTP status/URL. + +/** Create outcome — success carries the created row; a failure carries the + * status the console keys its GENERIC copy off (never rendered raw). */ +export type RulesCreateResult = { ok: true; value: T } | { ok: false; status: number }; + +/** LWW-update outcome (zones, methods, coupons) — no `stale` (no CAS). */ +export type RulesUpdateResult = + | { ok: true; value: T } + | { ok: false; reason: "not_found" } + | { ok: false; reason: "error"; status: number }; + +/** CAS-update outcome (shipping/tax rates) — `stale` carries the fresh row so + * the caller can reload rather than blind-retry a losing edit. */ +export type RulesCasUpdateResult = + | { ok: true; value: T } + | { ok: false; reason: "not_found" } + | { ok: false; reason: "stale"; current: T | null } + | { ok: false; reason: "error"; status: number }; + +/** Delete outcome. `in_use` is the referential-guard refusal (a zone with + * methods, a method with rates, a redeemed coupon); leaf-rate deletes never + * return it. `not_found` is the idempotent no-op. */ +export type RulesDeleteResult = + | { ok: true } + | { ok: false; reason: "not_found" } + | { ok: false; reason: "in_use" } + | { ok: false; reason: "error"; status: number }; + +/** + * Tax-class delete outcome (Increment 3 closeout). A DEDICATED result type, + * not the generic `RulesDeleteResult` — `deleteTaxClass`'s two in-use + * reasons (product vs. rate references) each carry a `count`, so the console + * can render an HONEST "N products/rates reference this class" instead of the + * generic screens' bare "in use, delete the children first" copy. + */ +export type TaxClassDeleteResult = + | { ok: true } + | { ok: false; reason: "not_found" } + | { ok: false; reason: "in_use_by_products"; count: number } + | { ok: false; reason: "in_use_by_rates"; count: number } + | { ok: false; reason: "error"; status: number }; + +// -- Input shapes ------------------------------------------------------------- + +export interface ShippingZoneInput { + id: string; + name: string; + regions?: unknown; +} +/** Full-replace edit — `regions` is REQUIRED (an omitted key is refused, so an + * edit can never silently wipe the zone's match list); send `null` to clear + * deliberately. */ +export interface ShippingZoneEdit { + name: string; + regions: unknown; +} +export interface ShippingMethodInput { + id: string; + name: string; + type: string; +} +export interface ShippingMethodEdit { + name: string; + type: string; +} +export interface ShippingRateInput { + currency: string; + amountCents: number; + minSubtotalCents?: number | null; +} +/** Full-replace edit — `minSubtotalCents` is REQUIRED-nullable (an omitted key + * is refused, so an edit can never silently clear the free-shipping + * threshold); send `null` to clear deliberately. */ +export interface ShippingRateEdit { + amountCents: number; + minSubtotalCents: number | null; + /** The money-bearing CAS token — the amount the admin read on the detail. */ + expectedAmountCents: number; +} +export interface TaxClassInput { + id: string; + name: string; +} +/** Full-replace rename (LWW, no CAS — a class carries no money); `id` is + * immutable identity and is never sent (the path param addresses it). */ +export interface TaxClassEdit { + name: string; +} +export interface TaxRateInput { + id: string; + taxClassId: string; + zoneId: string; + rateBps: number; + appliesToShipping?: boolean; +} +/** Full-replace edit — `appliesToShipping` is REQUIRED (an omitted key is + * refused, so an edit can never silently flip the shipping-tax behavior). */ +export interface TaxRateEdit { + rateBps: number; + appliesToShipping: boolean; + /** The money-bearing CAS token — the rate the admin read on the detail. */ + expectedRateBps: number; +} +export interface CouponInput { + id: string; + code: string; + type: string; + amountCents?: number | null; + rateBps?: number | null; + capCents?: number | null; + currency?: string | null; + minSubtotalCents?: number | null; + startsAt?: string | null; + expiresAt?: string | null; + maxUses?: number | null; + maxUsesPerCustomer?: number | null; +} +/** Coupon edit — `id`/`code`/`type`/`currency` are immutable identity/kind and + * are NOT sent (re-defining them is refused). */ +export interface CouponEdit { + amountCents?: number | null; + rateBps?: number | null; + capCents?: number | null; + minSubtotalCents?: number | null; + startsAt?: string | null; + expiresAt?: string | null; + maxUses?: number | null; + maxUsesPerCustomer?: number | null; +} + +/** + * THE ADMIN RULES SURFACE, structurally — what a caller may do to shipping + * zones/methods/rates, tax classes/rates and coupons, with no claim about how it + * gets done. + * + * ONE implementation answers to this now (work order 02, INC-D3b): + * `InProcessAdminRulesClient`, which composes this behaviour over the plugin's + * own document store. The `ctx.http` client that used to be the second + * implementation is gone with the commerce service it talked to, and with it the + * reason this was a `Pick` over a nominal class rather than an interface — so it + * is written out as an interface now, which is what it always described. + * + * EVERY METHOD IS LISTED, all twenty-five, and writing them out is still the + * point: this is much the widest surface in the console, and one that listed + * fewer would let a method be forgotten SILENTLY. A method added to the + * in-process client without being declared here is not part of the surface, and + * a method declared here that the client does not implement is a compile error. + * + * A failure NEVER throws into the host on a mutation; it surfaces a typed reason + * the caller renders as GENERIC copy, never a raw status. Reads that cannot + * answer still throw — the caller degrades that section rather than the page. + */ +export interface AdminRulesSurface { + // -- Shipping: zones ------------------------------------------------------- + + listZones(): Promise; + createZone(input: ShippingZoneInput): Promise>; + updateZone(zoneId: string, edit: ShippingZoneEdit): Promise>; + deleteZone(zoneId: string): Promise; + + // -- Shipping: methods ----------------------------------------------------- + + listMethods(zoneId: string): Promise; + createMethod( + zoneId: string, + input: ShippingMethodInput, + ): Promise>; + updateMethod( + methodId: string, + edit: ShippingMethodEdit, + ): Promise>; + deleteMethod(methodId: string): Promise; + + // -- Shipping: rates ------------------------------------------------------- + + /** Read one method's rate in a currency; a rate that does not exist resolves + * to `null` rather than throwing. */ + getRate(methodId: string, currency: string): Promise; + createRate( + methodId: string, + input: ShippingRateInput, + ): Promise>; + /** CAS on the money the admin read (`edit.expectedAmountCents`) — a losing + * edit comes back `stale` WITH the fresh row, never applied blind. */ + updateRate( + methodId: string, + currency: string, + edit: ShippingRateEdit, + ): Promise>; + deleteRate(methodId: string, currency: string): Promise; + + // -- Tax: classes ---------------------------------------------------------- + + listTaxClasses(): Promise; + createTaxClass(input: TaxClassInput): Promise>; + updateTaxClass(classId: string, edit: TaxClassEdit): Promise>; + /** Delete a tax class. A DEDICATED result type, not the generic + * `RulesDeleteResult`: the two in-use refusals each carry a `count` this + * method surfaces, unlike the generic zone/method/coupon deletes. */ + deleteTaxClass(classId: string): Promise; + + // -- Tax: rates ------------------------------------------------------------ + + listTaxRates(zoneId: string): Promise; + createTaxRate(input: TaxRateInput): Promise>; + /** CAS on the rate the admin read (`edit.expectedRateBps`) — a losing edit + * comes back `stale` WITH the fresh row, never applied blind. */ + updateTaxRate(rateId: string, edit: TaxRateEdit): Promise>; + deleteTaxRate(rateId: string): Promise; + + // -- Coupons --------------------------------------------------------------- + + /** + * Read the admin Coupons console list (admin-UX Increment 3, view-only + * enumerate). Pass EITHER a fresh `filter` OR a previous page's `opts.cursor` + * — never both: the cursor already embeds the active filter, so a filter + * alongside it could disagree with what the token re-derives. + */ + listCoupons( + filter: CouponsListFilter, + opts?: { cursor?: string; limit?: number }, + ): Promise; + /** Read one coupon by CODE; a coupon that does not exist resolves to `null`. */ + getCoupon(code: string): Promise; + createCoupon(input: CouponInput): Promise>; + updateCoupon(couponId: string, edit: CouponEdit): Promise>; + deleteCoupon(couponId: string): Promise; +} diff --git a/packages/plugin/src/admin/coupons-page.ts b/packages/plugin/src/admin/coupons-page.ts index daad3534..0a8335c6 100644 --- a/packages/plugin/src/admin/coupons-page.ts +++ b/packages/plugin/src/admin/coupons-page.ts @@ -23,7 +23,7 @@ import { type RulesCreateResult, type RulesDeleteResult, type RulesUpdateResult, -} from "./admin-rules-client.js"; +} from "./admin-rules-surface.js"; import { formatMinorUnitsInput, parseMinorUnitsInput } from "./money-input.js"; import { formatBpsAsPercent, parsePercentToBps } from "./percent-input.js"; import { @@ -89,10 +89,9 @@ import { * needs last, below the picker, rendered as a link the eye reads as another * row affordance. Nothing about what a create SUBMITS changed. * - * THE F-5a TRAP THIS SCREEN IS BUILT TO AVOID. `updateCoupon` sends a PUT - * (`admin-rules-client.ts:493-496`) and the service coerces every omitted key - * to `null` unconditionally (`rules-admin.ts:434-443`) — there is no partial - * update on the wire. So the edit form is NEVER split into sibling forms + * THE F-5a TRAP THIS SCREEN IS BUILT TO AVOID. `updateCoupon` is a FULL + * REPLACE: every omitted key is coerced to `null` unconditionally, so there is + * no partial update. So the edit form is NEVER split into sibling forms * (F-5a forbids it here: splitting would let an operator saving a "Discount" * form silently wipe `startsAt`/`expiresAt`/`maxUses`/`maxUsesPerCustomer`). * It stays ONE form, kept inside budget by `condition`-gating the type- diff --git a/packages/plugin/src/admin/in-process-admin-orders-client.ts b/packages/plugin/src/admin/in-process-admin-orders-client.ts index ae6aa101..f18a5a0f 100644 --- a/packages/plugin/src/admin/in-process-admin-orders-client.ts +++ b/packages/plugin/src/admin/in-process-admin-orders-client.ts @@ -2,9 +2,9 @@ * `InProcessAdminOrdersClient` — the admin Orders console surface with commerce * truth held on the plugin's own document store (work order 02, INC-B10b-ii). * - * WHAT THIS CLASS IS. The in-process twin of `AdminOrdersClient`: the same + * WHAT THIS CLASS IS. The sole implementation of `AdminOrdersSurface`: the same * twelve methods, the same argument shapes, the same RETURN VALUES — including - * every field the HTTP wire carries — with the `@otta-sh/domain` use-cases + * every field the `*Wire` types carry — with the `@otta-sh/domain` use-cases * composed over the `@otta-sh/store-emdash` adapters bound to `ctx.storage` * instead of a commerce service. Nothing here reaches for egress; `ctx.http` is * never touched. @@ -41,9 +41,10 @@ * 409 for a state-machine/ceiling conflict, 400 for a refused input) rather than * inventing a code of its own. * - * WHAT WAS PORTED, AND FROM WHERE. Four pieces of the service's route layer - * (`packages/service/src/routes/admin.ts`) are behaviour rather than framing, so - * they are mirrored here and named so the two can be compared by eye: + * WHAT WAS PORTED, AND FROM WHERE. Four pieces of the standalone + * `@otta-sh/service` package's admin route layer (now deleted) are behaviour + * rather than framing, so they are mirrored here and named so the two could be + * compared by eye: * - the orders list's opaque cursor (position + filter + limit, base64url JSON), * its RE-VALIDATION on decode, and the fail-closed disagreement check between * a token's filter/limit and the caller's — plus the client-side recovery that @@ -153,7 +154,7 @@ import type { RefundWire, ResolveReconciliationResult, TransitionOrderResult, -} from "./admin-orders-client.js"; +} from "./admin-orders-surface.js"; /** The page-size bounds the list query schema enforced (`ordersListQuery`: * `min(1).max(100)`, default 25). Mirrored, not imported — the service package @@ -257,8 +258,8 @@ export class InProcessAdminOrdersClient implements AdminOrdersSurface { { orderId: toOrderId(orderId), toState: target, idempotencyKey: toIdempotencyKey(key) }, ); if (res.ok) return { ok: true, transitioned: res.transitioned }; - // The transition surface carries no `reason` on the wire — only the status, - // exactly as `AdminOrdersClient.transitionOrder` returns it. + // `TransitionOrderResult` carries no `reason` on its failure arm — only the + // status, which is the shape `AdminOrdersSurface.transitionOrder` declares. return { ok: false, status: res.reason === "ORDER_NOT_FOUND" ? 404 : 409 }; } diff --git a/packages/plugin/src/admin/in-process-admin-products-client.ts b/packages/plugin/src/admin/in-process-admin-products-client.ts index cee325f1..951fdcc0 100644 --- a/packages/plugin/src/admin/in-process-admin-products-client.ts +++ b/packages/plugin/src/admin/in-process-admin-products-client.ts @@ -3,9 +3,9 @@ * commerce truth held on the plugin's own document store (work order 02, * INC-B10b-i). * - * WHAT THIS CLASS IS. The in-process twin of `AdminProductsClient`: the same six - * methods, the same argument shapes, the same RETURN VALUES — including every - * field the HTTP wire carries — with the `@otta-sh/domain` use-cases composed + * WHAT THIS CLASS IS. The sole implementation of `AdminProductsSurface`: the same + * six methods, the same argument shapes, the same RETURN VALUES — including every + * field the `*Wire` types carry — with the `@otta-sh/domain` use-cases composed * over the `@otta-sh/store-emdash` adapters bound to `ctx.storage` instead of a * commerce service. Nothing here reaches for egress; `ctx.http` is never * touched. @@ -94,7 +94,7 @@ import type { RestockResult, StockRemovalResult, TaxClassWire, -} from "./admin-products-client.js"; +} from "./admin-products-surface.js"; /** The page-size bounds the list query schema enforced (`productsListQuery`: * `min(1).max(100)`, default 25). Mirrored, not imported — the service package diff --git a/packages/plugin/src/admin/in-process-admin-rules-client.ts b/packages/plugin/src/admin/in-process-admin-rules-client.ts index 04a5e639..d6be1869 100644 --- a/packages/plugin/src/admin/in-process-admin-rules-client.ts +++ b/packages/plugin/src/admin/in-process-admin-rules-client.ts @@ -3,9 +3,9 @@ * → methods → rates, tax classes → rates, coupons) with commerce truth held on * the plugin's own document store (work order 02, INC-B10c-i). * - * WHAT THIS CLASS IS. The in-process twin of `AdminRulesClient`: the same + * WHAT THIS CLASS IS. The sole implementation of `AdminRulesSurface`: the same * twenty-five methods, the same argument shapes, the same RETURN VALUES — every - * field the HTTP wire carries — with the `@otta-sh/domain` ports composed over + * field the `*Wire` types carry — with the `@otta-sh/domain` ports composed over * the `@otta-sh/store-emdash` adapters bound to `ctx.storage` instead of a * commerce service. Nothing here reaches for egress; `ctx.http` is never * touched. @@ -128,7 +128,7 @@ import type { TaxRateEdit, TaxRateInput, TaxRateWire, -} from "./admin-rules-client.js"; +} from "./admin-rules-surface.js"; /** The coupon-list page bounds (`couponsListQuery`: `min(1).max(100)`, default * 25). Mirrored, not imported — the service package goes away. */ diff --git a/packages/plugin/src/admin/in-process-reporting-settings-client.ts b/packages/plugin/src/admin/in-process-reporting-settings-client.ts index af2eafd7..f1f61dbc 100644 --- a/packages/plugin/src/admin/in-process-reporting-settings-client.ts +++ b/packages/plugin/src/admin/in-process-reporting-settings-client.ts @@ -4,9 +4,9 @@ * settings tier) with commerce truth held on the plugin's own document store * (work order 02, INC-B10c-ii). * - * WHAT THIS CLASS IS. The in-process twin of `ReportingSettingsClient`: the same - * six methods, the same argument shapes, the same RETURN VALUES — every field the - * HTTP wire carries — with the `@otta-sh/domain` reporting/settings use-cases + * WHAT THIS CLASS IS. The sole implementation of `ReportingSettingsSurface`: the + * same six methods, the same argument shapes, the same RETURN VALUES — every field + * the `*Wire` types carry — with the `@otta-sh/domain` reporting/settings use-cases * composed over the `@otta-sh/store-emdash` adapters bound to `ctx.storage` * instead of a commerce service. Nothing here reaches for egress; `ctx.http` is * never touched. @@ -96,7 +96,7 @@ import type { StatusCountWire, TopProductWire, UpdateSettingsResult, -} from "./reporting-client.js"; +} from "./reporting-settings-surface.js"; /** `topProductsQuery.limit`: `z.coerce.number().int().positive().max(1000)`. * Mirrored, not imported — the service package goes away. */ diff --git a/packages/plugin/src/admin/make-admin-clients.ts b/packages/plugin/src/admin/make-admin-clients.ts index 06ffc1c4..97457dde 100644 --- a/packages/plugin/src/admin/make-admin-clients.ts +++ b/packages/plugin/src/admin/make-admin-clients.ts @@ -19,14 +19,14 @@ */ import type { PluginContext } from "../types.js"; -import type { AdminOrdersSurface } from "./admin-orders-client.js"; -import type { AdminProductsSurface } from "./admin-products-client.js"; -import type { AdminRulesSurface } from "./admin-rules-client.js"; +import type { AdminOrdersSurface } from "./admin-orders-surface.js"; +import type { AdminProductsSurface } from "./admin-products-surface.js"; +import type { AdminRulesSurface } from "./admin-rules-surface.js"; import { InProcessAdminOrdersClient } from "./in-process-admin-orders-client.js"; import { InProcessAdminProductsClient } from "./in-process-admin-products-client.js"; import { InProcessAdminRulesClient } from "./in-process-admin-rules-client.js"; import { InProcessReportingSettingsClient } from "./in-process-reporting-settings-client.js"; -import type { ReportingSettingsSurface } from "./reporting-client.js"; +import type { ReportingSettingsSurface } from "./reporting-settings-surface.js"; /** * The admin surfaces a console route may ask for. diff --git a/packages/plugin/src/admin/orders-actions.ts b/packages/plugin/src/admin/orders-actions.ts index ae4b2f8c..657a551d 100644 --- a/packages/plugin/src/admin/orders-actions.ts +++ b/packages/plugin/src/admin/orders-actions.ts @@ -72,7 +72,7 @@ import { fit, formatAmount as formatTotal, } from "@otta-sh/admin-presentation"; -import type { AdminOrdersSurface, RefundsSummaryWire } from "./admin-orders-client.js"; +import type { AdminOrdersSurface, RefundsSummaryWire } from "./admin-orders-surface.js"; import { readString, screenActions, startOfDay, type Notice } from "./scaffold/index.js"; import type { SelectOption } from "../types.js"; diff --git a/packages/plugin/src/admin/orders-console-route.ts b/packages/plugin/src/admin/orders-console-route.ts index 11dfc94d..1f7304c2 100644 --- a/packages/plugin/src/admin/orders-console-route.ts +++ b/packages/plugin/src/admin/orders-console-route.ts @@ -55,7 +55,7 @@ import { type OrderSummaryWire, type OrderTimelineWire, type RefundsSummaryWire, -} from "./admin-orders-client.js"; +} from "./admin-orders-surface.js"; import { makeAdminClients } from "./make-admin-clients.js"; import { CANCELLATION_REASONS, diff --git a/packages/plugin/src/admin/orders-read.ts b/packages/plugin/src/admin/orders-read.ts index 6799708b..1337fccc 100644 --- a/packages/plugin/src/admin/orders-read.ts +++ b/packages/plugin/src/admin/orders-read.ts @@ -22,7 +22,7 @@ import { type OrdersListFilter, type OrderTimelineWire, type RefundsSummaryWire, -} from "./admin-orders-client.js"; +} from "./admin-orders-surface.js"; import { DAY_MS, dayOf, endOfDay, startOfDay } from "./scaffold/index.js"; import { ORDER_STATE_SET } from "@otta-sh/admin-presentation"; diff --git a/packages/plugin/src/admin/products-actions.ts b/packages/plugin/src/admin/products-actions.ts index bf589503..f7926add 100644 --- a/packages/plugin/src/admin/products-actions.ts +++ b/packages/plugin/src/admin/products-actions.ts @@ -82,7 +82,7 @@ import { type ProductEditWire, type RestockResult, type StockRemovalResult, -} from "./admin-products-client.js"; +} from "./admin-products-surface.js"; import { parseMinorUnitsInput } from "./money-input.js"; import { readString, screenActions, type Notice } from "./scaffold/index.js"; diff --git a/packages/plugin/src/admin/products-console-route.ts b/packages/plugin/src/admin/products-console-route.ts index 21a61f5c..ef3201f0 100644 --- a/packages/plugin/src/admin/products-console-route.ts +++ b/packages/plugin/src/admin/products-console-route.ts @@ -53,7 +53,7 @@ import { type ProductsListResult, type ProductSummaryWire, type TaxClassWire, -} from "./admin-products-client.js"; +} from "./admin-products-surface.js"; import { PRODUCTS_UNAVAILABLE_DESCRIPTION, PRODUCTS_UNAVAILABLE_TITLE, @@ -83,7 +83,7 @@ import { toClientFilter, } from "./products-read.js"; import { makeAdminClients } from "./make-admin-clients.js"; -import type { ReportingSettingsSurface } from "./reporting-client.js"; +import type { ReportingSettingsSurface } from "./reporting-settings-surface.js"; import { readString } from "./scaffold/index.js"; /** The resources the console can read on this screen. One per SURFACE, not one @@ -153,8 +153,8 @@ export interface ProductsConsoleListPayload { /** * THE PAGE THE REQUEST ASKED FOR WAS REFUSED, and these are the first page's * rows instead — the cursor disagreed with the filters beside it, or would not - * decode, and `AdminProductsClient` performed the service's own prescribed - * remedy (drop the token, re-issue page one) before this route saw a result. + * decode, and `listProducts` performed the prescribed remedy (drop the token, + * re-issue page one) before this route saw a result. * On the SUCCESS payload because the request was answered; forwarded because * an address naming that page must be corrected and the merchant is owed a * sentence. Same contract as the Orders route's. diff --git a/packages/plugin/src/admin/products-read.ts b/packages/plugin/src/admin/products-read.ts index b418cb47..5edc59b8 100644 --- a/packages/plugin/src/admin/products-read.ts +++ b/packages/plugin/src/admin/products-read.ts @@ -19,8 +19,8 @@ import { type ProductsListFilter, type ProductSummaryWire, type TaxClassWire, -} from "./admin-products-client.js"; -import type { ReportingSettingsSurface } from "./reporting-client.js"; +} from "./admin-products-surface.js"; +import type { ReportingSettingsSurface } from "./reporting-settings-surface.js"; import { readString } from "./scaffold/index.js"; import { PRODUCT_KIND_LABELS } from "@otta-sh/admin-presentation"; import type { SelectOption } from "../types.js"; diff --git a/packages/plugin/src/admin/reporting-client.ts b/packages/plugin/src/admin/reporting-client.ts deleted file mode 100644 index 4f9d08d3..00000000 --- a/packages/plugin/src/admin/reporting-client.ts +++ /dev/null @@ -1,284 +0,0 @@ -import type { HttpAccess } from "../types.js"; - -/** - * A tiny `ctx.http`-only client for the Phase-7 reporting + settings service - * surface (plan §4.4/§5.3). Same transport discipline as `HttpCommerceClient` - * (no new primitive): the injected `ctx.http.fetch` is the ONLY egress, money is - * integer minor units + ISO-4217 currency on the wire, and the wire types are - * defined locally (never importing `@otta-sh/domain`, keeping the plugin - * sandbox-clean). `#fetch` is `#`-prefixed so the sandbox-clean grep guard sees - * no bare fetch call. - */ - -export interface RevenueBucketWire { - bucketStart: string; - currency: string; - revenueCents: number; - /** - * Money refunded on the orders in this bucket — integer minor units in the - * bucket's own `currency`, stated ALONGSIDE `revenueCents` and never netted - * into it. - * - * OPTIONAL ON THIS TYPE, AND ONLY FOR ONE REASON: a service older than the - * field omits the key. The current service emits it unconditionally, zero - * included — so `0` means "nothing came back", which is a FACT worth - * rendering as `$0.00`, and only the key's ABSENCE means "this service does - * not report refunds". A renderer must branch on presence, never on - * truthiness, and `?? 0` here would turn an unreportable period into a - * confident claim that nothing was refunded. - * - * Counts FINALIZED refunds (money that actually moved) against orders PLACED - * in the period — the same cohort `orders-by-status` counts, so the amount - * and the refunded-order count on one tile always describe the same set. - */ - refundedCents?: number; -} -export interface StatusCountWire { - status: string; - orderCount: number; -} -export interface TopProductWire { - productId: string; - titleSnapshot: string; - qtySold: number; - revenueCents: number; -} -export interface LowStockWire { - sku: string; - onHand: number; - /** The LIVE product's title for this sku. - * - * `null` when no live product claims the sku (never synced, soft-deleted, - * or its own title is genuinely null) — and null is the ONLY fallback. The - * service never substitutes the sku, which is already its own field on - * this row; doing so would make "named SKU-42" and "name unknown" - * indistinguishable and stop a renderer's `(untitled)` affordance from - * ever firing. */ - title: string | null; -} -export interface OperationalSettingsWire { - holdTtlMinutes: number; - lowStockThreshold: number; -} - -export interface DateRangeInput { - from: string; - to: string; -} - -/** - * WHY a settings save fails, STRUCTURALLY — the field a caller branches on - * (work order 02, INC-B10c-ii). - * - * - `validation` — the patch itself was refused. The `message` is the one worth - * showing inline beside the field. - * - `superseded` — the mutation lost a compare-and-set race against a - * concurrent save and was NOT applied. Not retryable under the same key: the - * key already decided, and the decision was "someone else got there first". - * Re-read and offer the fresh values rather than re-submitting. - * - `unavailable` — the store could not answer. Nothing is known about whether - * the patch applied; a re-read is the only honest next step. - */ -export type UpdateSettingsFailureReason = "validation" | "superseded" | "unavailable"; - -/** - * PUT /settings returns a discriminated result rather than throwing, so the - * form can surface a validation error INLINE instead of swallowing it into a - * generic failure (§5.3). - * - * `reason` IS THE FIELD TO BRANCH ON, and `status` is the LEGACY fallback the - * HTTP tier alone still carries. The in-process tier has no wire and therefore - * no status: it refuses to synthesize one, because a fabricated `409` would be - * indistinguishable from a real one and would teach a caller to read a transport - * artefact that does not exist on that transport (the ratified INC-B10a rule — - * a typed failure is represented structurally in-process, never mapped onto an - * invented HTTP status). So BOTH keys are optional: a caller branches on - * `reason` first and falls back to `status` only when `reason` is absent. - */ -export type UpdateSettingsResult = - | { ok: true; settings: OperationalSettingsWire } - | { - ok: false; - /** Present on every tier that can say WHY. Branch on this first. */ - reason?: UpdateSettingsFailureReason; - /** The HTTP status, on the HTTP tier only. Never synthesized elsewhere. */ - status?: number; - message: string; - }; - -export interface HttpErrorEnvelope { - error?: string; - message?: string; -} - -export interface ReportingSettingsClientOptions { - fetch: HttpAccess["fetch"]; - baseUrl: string; - /** Admin token forwarded as `X-Internal-Token` on EVERY guarded read this - * client makes — the `/reports/*` reads (review J5) AND `GET /settings`, - * which is admin surface too (ADR-0010). Received here as a constructor - * option; the handlers source it from write-only plugin kv. The client - * itself never persists it. The privileged `PUT /settings` write uses THIS - * token too: - * `updateSettings` attaches `opts.adminToken ?? this.#adminToken`, so a - * per-call token overrides it and the constructor's is the fallback — which is - * the only path production takes, because the sole caller passes none. */ - adminToken?: string; - /** The machine write-gate token the service enforces as `X-Service-Token` - * (ADR-0007), sourced from write-only `ctx.kv`. - * `PUT /settings` is a NON-GET, so the gate blocks it without this when the - * service secret is set — hence it is attached to the PUT. The `/reports/*` - * and `GET /settings` reads are exempt from THAT gate (it skips GET/HEAD), so - * they carry only the admin token — which, since ADR-0010, they genuinely - * need. Undefined ⇒ no header ⇒ byte-identical to the pre-gate wire. */ - serviceToken?: string; -} - -export class ReportingSettingsClient { - readonly #fetch: HttpAccess["fetch"]; - readonly #baseUrl: string; - readonly #adminToken: string | undefined; - readonly #serviceToken: string | undefined; - - constructor(options: ReportingSettingsClientOptions) { - this.#fetch = options.fetch; - this.#baseUrl = options.baseUrl.replace(/\/$/, ""); - this.#adminToken = options.adminToken; - this.#serviceToken = options.serviceToken; - } - - async getRevenue( - range: DateRangeInput, - interval: "day" | "week" | "month", - ): Promise { - const q = new URLSearchParams({ from: range.from, to: range.to, interval }); - const body = await this.#getJson<{ buckets: RevenueBucketWire[] }>(`/reports/revenue?${q}`); - return body.buckets; - } - - async getOrdersByStatus(range: DateRangeInput): Promise { - const q = new URLSearchParams({ from: range.from, to: range.to }); - const body = await this.#getJson<{ counts: StatusCountWire[] }>( - `/reports/orders-by-status?${q}`, - ); - return body.counts; - } - - async getTopProducts( - range: DateRangeInput, - metric: "revenue" | "quantity", - limit: number, - ): Promise { - const q = new URLSearchParams({ - from: range.from, - to: range.to, - metric, - limit: String(limit), - }); - const body = await this.#getJson<{ products: TopProductWire[] }>(`/reports/top-products?${q}`); - return body.products; - } - - async getLowStock(threshold?: number): Promise { - const path = - threshold === undefined ? "/reports/low-stock" : `/reports/low-stock?threshold=${threshold}`; - const body = await this.#getJson<{ rows: LowStockWire[] }>(path); - return body.rows; - } - - async getSettings(): Promise { - const body = await this.#getJson<{ settings: OperationalSettingsWire }>("/settings"); - return body.settings; - } - - async updateSettings( - patch: Partial, - opts: { idempotencyKey: string; adminToken?: string }, - ): Promise { - const headers: Record = { - "content-type": "application/json", - "Idempotency-Key": opts.idempotencyKey, - }; - // The per-call token wins, and the constructor's is the fallback — NOT the - // other way round, and not "per-call only", which is what this did before - // INC-B10c-ii. A client constructed WITH an admin token (as `makeAdminClients` - // constructs it) would otherwise send none on the one call that needs it most - // and take a 401 on a save whose reads all succeeded. - const adminToken = opts.adminToken ?? this.#adminToken; - if (adminToken !== undefined) headers["X-Internal-Token"] = adminToken; - // PUT /settings is gated by BOTH the write gate (X-Service-Token) AND the - // route's admin token (X-Internal-Token) when both service secrets are set. - if (this.#serviceToken !== undefined) headers["X-Service-Token"] = this.#serviceToken; - const res = await this.#fetch(`${this.#baseUrl}/settings`, { - method: "PUT", - headers, - body: JSON.stringify(patch), - }); - const parsed = (await res.json().catch(() => undefined)) as - | { settings?: OperationalSettingsWire } - | HttpErrorEnvelope - | undefined; - if ( - !res.ok || - parsed === undefined || - !("settings" in parsed) || - parsed.settings === undefined - ) { - // Surface the service's own message ONLY for a designed validation - // failure (400 + JSON message) — that inline text ("holdTtlMinutes must - // be a positive integer") is desirable and shown as-is. For any other - // non-ok case (401/403/5xx/non-JSON) fall back to a GENERIC message that - // never leaks a raw HTTP status or URL (Part 5 consistency). - const validationMessage = - res.status === 400 && - parsed !== undefined && - "message" in parsed && - typeof parsed.message === "string" - ? parsed.message - : undefined; - const message = - validationMessage ?? - // Deliberately names no credential. A gate 401 used to be worth - // attributing to a specific token, but INC-D3a deleted both the admin - // and the service token, so there is no provisioning step left to - // point an operator at — only "it did not take, try again". - "settings update failed — retry in a moment"; - return { ok: false, status: res.status, message }; - } - return { ok: true, settings: parsed.settings }; - } - - async #getJson(path: string): Promise { - const headers: Record = - this.#adminToken === undefined ? {} : { "X-Internal-Token": this.#adminToken }; - const res = await this.#fetch(`${this.#baseUrl}${path}`, { method: "GET", headers }); - if (!res.ok) { - throw new Error(`GET ${path} failed (HTTP ${res.status})`); - } - return (await res.json()) as T; - } -} - -/** - * The TIER-AGNOSTIC reporting + settings surface: what the Reports page, the - * Settings form and the Products console hold, whichever transport serves it - * (work order 02, INC-B10c-ii). - * - * A `Pick` rather than an `interface` the class implements, because the class is - * NOMINAL — its `#`-private fields mean a structurally identical in-process twin - * is not assignable to it — and a structural surface is what lets one page hold - * either. - * - * EVERY METHOD IS LISTED, and that is the point. Adding a method to this client - * without deciding what the in-process tier does about it has to be a compile - * error here, not a runtime gap on whichever screen reached for it first. - */ -export type ReportingSettingsSurface = Pick< - ReportingSettingsClient, - | "getRevenue" - | "getOrdersByStatus" - | "getTopProducts" - | "getLowStock" - | "getSettings" - | "updateSettings" ->; diff --git a/packages/plugin/src/admin/reporting-settings-surface.ts b/packages/plugin/src/admin/reporting-settings-surface.ts new file mode 100644 index 00000000..31f5736b --- /dev/null +++ b/packages/plugin/src/admin/reporting-settings-surface.ts @@ -0,0 +1,160 @@ +/** + * The reporting + settings surface (Phase-7, plan §4.4/§5.3) — the port the + * Reports page, the Settings form and the Products console hold, plus the + * wire-shaped types that cross it. + * + * These types are defined LOCALLY and deliberately: this module NEVER imports + * `@otta-sh/domain`, which keeps the plugin sandbox-clean. Money is integer + * minor units + ISO-4217 currency throughout. The "wire" in the names is + * historical — it was once the JSON shape of a separate commerce service — and + * it is still exactly the shape the admin route's JSON responses use, so the + * name stays accurate. + */ + +export interface RevenueBucketWire { + bucketStart: string; + currency: string; + revenueCents: number; + /** + * Money refunded on the orders in this bucket — integer minor units in the + * bucket's own `currency`, stated ALONGSIDE `revenueCents` and never netted + * into it. + * + * OPTIONAL ON THIS TYPE, AND ONLY FOR ONE REASON: a reader that predates the + * field omits the key. The current one emits it unconditionally, zero + * included — so `0` means "nothing came back", which is a FACT worth + * rendering as `$0.00`, and only the key's ABSENCE means "refunds are not + * reported here". A renderer must branch on presence, never on truthiness, + * and `?? 0` here would turn an unreportable period into a confident claim + * that nothing was refunded. + * + * Counts FINALIZED refunds (money that actually moved) against orders PLACED + * in the period — the same cohort `orders-by-status` counts, so the amount + * and the refunded-order count on one tile always describe the same set. + */ + refundedCents?: number; +} +export interface StatusCountWire { + status: string; + orderCount: number; +} +export interface TopProductWire { + productId: string; + titleSnapshot: string; + qtySold: number; + revenueCents: number; +} +export interface LowStockWire { + sku: string; + onHand: number; + /** The LIVE product's title for this sku. + * + * `null` when no live product claims the sku (never synced, soft-deleted, + * or its own title is genuinely null) — and null is the ONLY fallback. The + * read never substitutes the sku, which is already its own field on this + * row; doing so would make "named SKU-42" and "name unknown" + * indistinguishable and stop a renderer's `(untitled)` affordance from + * ever firing. */ + title: string | null; +} +export interface OperationalSettingsWire { + holdTtlMinutes: number; + lowStockThreshold: number; +} + +export interface DateRangeInput { + from: string; + to: string; +} + +/** + * WHY a settings save fails, STRUCTURALLY — the field a caller branches on + * (work order 02, INC-B10c-ii). + * + * - `validation` — the patch itself was refused. The `message` is the one worth + * showing inline beside the field. + * - `superseded` — the mutation lost a compare-and-set race against a + * concurrent save and was NOT applied. Not retryable under the same key: the + * key already decided, and the decision was "someone else got there first". + * Re-read and offer the fresh values rather than re-submitting. + * - `unavailable` — the store could not answer. Nothing is known about whether + * the patch applied; a re-read is the only honest next step. + */ +export type UpdateSettingsFailureReason = "validation" | "superseded" | "unavailable"; + +/** + * A settings save returns a discriminated result rather than throwing, so the + * form can surface a validation error INLINE instead of swallowing it into a + * generic failure (§5.3). + * + * `reason` IS THE FIELD TO BRANCH ON. `status` is a VESTIGIAL fallback from the + * era of an HTTP commerce service: the in-process implementation has no wire and + * therefore no status, and it refuses to synthesize one, because a fabricated + * `409` would be indistinguishable from a real one and would teach a caller to + * read a transport artefact that does not exist here (the ratified INC-B10a rule + * — a typed failure is represented structurally, never mapped onto an invented + * HTTP status). So BOTH keys are optional: a caller branches on `reason` first + * and falls back to `status` only when `reason` is absent. + */ +export type UpdateSettingsResult = + | { ok: true; settings: OperationalSettingsWire } + | { + ok: false; + /** Present on every tier that can say WHY. Branch on this first. */ + reason?: UpdateSettingsFailureReason; + /** The HTTP status, on the HTTP tier only. Never synthesized elsewhere. */ + status?: number; + message: string; + }; + +/** + * THE REPORTING + SETTINGS SURFACE, structurally — what the Reports page, the + * Settings form and the Products console may ask for, with no claim about how it + * gets done (work order 02, INC-B10c-ii). + * + * ONE implementation answers to this now (work order 02, INC-D3b): + * `InProcessReportingSettingsClient`, which composes this behaviour over the + * plugin's own document store. The `ctx.http` client that used to be the second + * implementation is gone with the commerce service it talked to, and with it the + * reason this was a `Pick` over a nominal class rather than an interface — so it + * is written out as an interface now, which is what it always described. + * + * EVERY METHOD IS LISTED, and writing them out is still the point: a method + * added to the in-process client without being declared here is not part of the + * surface, and a method declared here that the client does not implement is a + * compile error — not a runtime gap on whichever screen reached for it first. + */ +export interface ReportingSettingsSurface { + /** Revenue bucketed over a half-open range, in the requested interval. + * Refunds are reported ALONGSIDE revenue, never netted into it. */ + getRevenue( + range: DateRangeInput, + interval: "day" | "week" | "month", + ): Promise; + + /** Order counts by status over a half-open range. */ + getOrdersByStatus(range: DateRangeInput): Promise; + + /** The top `limit` products over a half-open range, ranked by `metric`. */ + getTopProducts( + range: DateRangeInput, + metric: "revenue" | "quantity", + limit: number, + ): Promise; + + /** Skus at or below the low-stock threshold — the store's configured one when + * `threshold` is omitted. */ + getLowStock(threshold?: number): Promise; + + /** The operational settings (hold TTL, low-stock threshold). */ + getSettings(): Promise; + + /** Apply a settings patch under `opts.idempotencyKey`. Returns a + * discriminated result rather than throwing so a validation failure can be + * shown INLINE beside the field; branch on `reason` (see + * {@link UpdateSettingsResult}). */ + updateSettings( + patch: Partial, + opts: { idempotencyKey: string; adminToken?: string }, + ): Promise; +} diff --git a/packages/plugin/src/admin/reports-page.ts b/packages/plugin/src/admin/reports-page.ts index 963d4c99..34a47e4d 100644 --- a/packages/plugin/src/admin/reports-page.ts +++ b/packages/plugin/src/admin/reports-page.ts @@ -31,7 +31,7 @@ import type { RevenueBucketWire, StatusCountWire, TopProductWire, -} from "./reporting-client.js"; +} from "./reporting-settings-surface.js"; /** The admin Reports page's `admin.pages` manifest entry (§4.1). The page * renders numbers and tables, NOT a chart. diff --git a/packages/plugin/src/admin/settings-form.ts b/packages/plugin/src/admin/settings-form.ts index 0317e6cd..2e448b39 100644 --- a/packages/plugin/src/admin/settings-form.ts +++ b/packages/plugin/src/admin/settings-form.ts @@ -20,7 +20,10 @@ import type { SettingsFieldSpec, } from "../types.js"; import { makeAdminClients } from "./make-admin-clients.js"; -import type { OperationalSettingsWire, ReportingSettingsSurface } from "./reporting-client.js"; +import type { + OperationalSettingsWire, + ReportingSettingsSurface, +} from "./reporting-settings-surface.js"; import { carriedForm, noticeBanner, type Notice } from "./scaffold/index.js"; /** @@ -42,10 +45,11 @@ import { carriedForm, noticeBanner, type Notice } from "./scaffold/index.js"; * `internalToken` (`X-Internal-Token`, the token the guarded `/reports/*` * reads and the privileged `PUT /settings` needed) and `serviceToken` * (`X-Service-Token`, ADR-0007's machine write-gate the service enforced on - * every non-GET). Both existed to authenticate THIS plugin to - * `@otta-sh/service` as a separate deployable. Now that the commerce service - * is folded into the plugin (ADR-0014/0015) there is nothing left on the - * other side of that call to authenticate to, so both tokens, their kv keys, + * every non-GET). Both existed to authenticate THIS plugin to the standalone + * `@otta-sh/service` package (now deleted) as a separate deployable. Now that + * the commerce service is folded into the plugin (ADR-0014/0015) there is + * nothing left on the other side of that call to authenticate to, so both + * tokens, their kv keys, * their save-generation counters, and the group that held their forms are * gone outright rather than kept as dead provisioning UI. The INC-09 * write-only, never-masked discipline they pioneered survives below in @@ -87,8 +91,9 @@ async function readSaveGen(ctx: PluginContext, key: string): Promise { * branches, the forms, the group label and the action-id set. One row per * secret, so adding a fifth cannot half-land. * - * Every `kvKey` is the in-process equivalent of a `@otta-sh/service` environment - * variable (see `payment-secrets.ts` for the env-var → kv-key table and the + * Every `kvKey` is the in-process equivalent of an environment variable the + * standalone `@otta-sh/service` used to read (see `payment-secrets.ts` for the + * env-var → kv-key table and the * source lines). `genKey` is this secret's own save generation, independent per * secret so saving one never blanks another's untouched field — see * {@link bumpSaveGen}. @@ -885,9 +890,9 @@ function checkoutGroup( /** * INC-C3 — the "Payments & email" group: the provisioning surface for the four - * credentials that used to be `wrangler secret put` entries on the commerce - * service (`packages/service/wrangler.jsonc`). With the service folded in there - * is no second deployable to hold them, so this screen is where they land. + * credentials that used to be `wrangler secret put` entries on the standalone + * `@otta-sh/service` package's own wrangler config. With the service folded in + * there is no second deployable to hold them, so this screen is where they land. * * Every field is a PLAIN, ALWAYS-EMPTY `text_input` — the INC-09 discipline * this screen's two now-retired connection tokens introduced (see the module diff --git a/packages/plugin/src/admin/shipping-page.ts b/packages/plugin/src/admin/shipping-page.ts index b7bd739d..5be2ea4a 100644 --- a/packages/plugin/src/admin/shipping-page.ts +++ b/packages/plugin/src/admin/shipping-page.ts @@ -22,7 +22,7 @@ import { type ShippingMethodWire, type ShippingRateWire, type ShippingZoneWire, -} from "./admin-rules-client.js"; +} from "./admin-rules-surface.js"; import { formatMinorUnitsInput, parseMinorUnitsInput } from "./money-input.js"; import { asRecord, diff --git a/packages/plugin/src/admin/tax-page.ts b/packages/plugin/src/admin/tax-page.ts index ecc25c34..f63748bb 100644 --- a/packages/plugin/src/admin/tax-page.ts +++ b/packages/plugin/src/admin/tax-page.ts @@ -21,7 +21,7 @@ import { type TaxClassDeleteResult, type TaxClassWire, type TaxRateWire, -} from "./admin-rules-client.js"; +} from "./admin-rules-surface.js"; import { formatBpsAsPercent, parsePercentToBps } from "./percent-input.js"; import { asRecord, diff --git a/packages/plugin/src/commerce/make-commerce-client.ts b/packages/plugin/src/commerce/make-commerce-client.ts index 629e0af1..f9ed9349 100644 --- a/packages/plugin/src/commerce/make-commerce-client.ts +++ b/packages/plugin/src/commerce/make-commerce-client.ts @@ -9,11 +9,11 @@ * chances to miss one. It was a one-line diff in this file instead, and * INC-D3a has now deleted the other arm outright. * - * NOT ROUTED THROUGH HERE, deliberately: the four admin HTTP clients - * (`admin-orders-client`, `admin-products-client`, `admin-rules-client`, - * `reporting-client`). They are function-export modules over a bare - * `{ fetch, baseUrl }` transport rather than implementations of this port, so - * folding them in is its own change — INC-D3b deletes them. + * NOT ROUTED THROUGH HERE, deliberately: the four admin surfaces + * (`admin-orders-surface`, `admin-products-surface`, `admin-rules-surface`, + * `reporting-settings-surface`). They are their own ports rather than + * implementations of this one, and `makeAdminClients` constructs them; INC-D3b + * deleted the HTTP arm of each, leaving one in-process implementation apiece. */ import { IN_PROCESS_EGRESS_URLS } from "../manifest.js"; diff --git a/packages/plugin/src/cron/sweeps.ts b/packages/plugin/src/cron/sweeps.ts index 112fbf97..9946a0e7 100644 --- a/packages/plugin/src/cron/sweeps.ts +++ b/packages/plugin/src/cron/sweeps.ts @@ -19,8 +19,9 @@ * cron executor does not persist it, so a leg that fails forever would otherwise be * indistinguishable from a leg that has nothing to do — exactly the failure mode an * unattended path must not have. Each leg emits one `[otta] cron sweep …` line on - * success and one `console.error` with its own label on failure, matching - * `packages/service/src/worker.ts`'s `scheduled()` shape. An anomaly is louder + * success and one `console.error` with its own label on failure, matching the + * format the standalone commerce service's `scheduled()` handler used before it + * was folded into the plugin. An anomaly is louder * still: it is logged AND written to the order through `flagReconciliation`. * * EVERY LEG IS IDEMPOTENT, which is what makes running them every fifteen minutes diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index e1594160..e78a0078 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -28,11 +28,10 @@ export { type SettingsFormInput, } from "./admin/settings-form.js"; export { - ReportingSettingsClient, type LowStockWire, type OperationalSettingsWire, - // The TIER-AGNOSTIC surface the Reports page, the Settings form and the - // Products console now hold (work order 02, INC-B10c-ii). Exported from the + // The surface the Reports page, the Settings form and the Products console + // hold (work order 02, INC-B10c-ii). Exported from the // entry point because a parameter type a consumer cannot name is not a usable // signature. type ReportingSettingsSurface, @@ -43,7 +42,7 @@ export { // before falling back to the HTTP tier's legacy `status`. type UpdateSettingsFailureReason, type UpdateSettingsResult, -} from "./admin/reporting-client.js"; +} from "./admin/reporting-settings-surface.js"; // The Orders WRITE path (INC-R2, ADR-0015). It replaces the Block Kit Orders // page handler this barrel used to export alongside `ORDERS_PAGE`: that screen // was retired once the React console's writes moved off it, so `/orders` is @@ -59,7 +58,6 @@ export { type OrdersActionResult, } from "./admin/orders-actions.js"; export { - AdminOrdersClient, type OrderDetailResult, type OrderDetailWire, type OrderLineWire, @@ -68,7 +66,7 @@ export { type OrderSummaryWire, type OrderTotalsWire, type TransitionOrderResult, -} from "./admin/admin-orders-client.js"; +} from "./admin/admin-orders-surface.js"; // `PRODUCTS_PAGE`, `ProductsPageInput` and the page handler this barrel used to // export are gone (INC-R3, ADR-0015): that Block Kit screen was retired once the // React console's writes moved off it, so `/products` is served by the @@ -98,8 +96,7 @@ export { export { CONSOLE_ACT_INTERACTION, CONSOLE_READ_INTERACTION } from "./admin/console-transport.js"; export { PRODUCTS_CONSOLE_RESOURCE_PREFIX } from "./admin/products-console-route.js"; export { - AdminProductsClient, - // The TIER-AGNOSTIC surface, and the type `dispatchProductsAction`'s third + // The surface, and the type `dispatchProductsAction`'s third // parameter now has (work order 02, INC-B10b-i). Exported from the entry point // because a parameter type a consumer cannot name is not a usable signature. type AdminProductsSurface, @@ -107,7 +104,7 @@ export { type ProductsListFilter, type ProductsListResult, type ProductSummaryWire, -} from "./admin/admin-products-client.js"; +} from "./admin/admin-products-surface.js"; export { createTaxPageHandler, TAX_ACTION_IDS, @@ -132,9 +129,7 @@ export { } from "./admin/coupons-page.js"; export { formatMinorUnitsInput, parseMinorUnitsInput } from "./admin/money-input.js"; export { - AdminRulesClient, - type AdminRulesClientOptions, - // The TIER-AGNOSTIC surface the three rules console pages now hold (work + // The surface the three rules console pages hold (work // order 02, INC-B10c-i). Exported from the entry point because a parameter // type a consumer cannot name is not a usable signature. type AdminRulesSurface, @@ -159,7 +154,7 @@ export { type TaxRateEdit, type TaxRateInput, type TaxRateWire, -} from "./admin/admin-rules-client.js"; +} from "./admin/admin-rules-surface.js"; export { ALLOWED_HOSTS, IN_PROCESS_EGRESS_URLS, @@ -283,7 +278,6 @@ export { type ProductCommerceBatchItem, type UpsertProductCommerceInput, } from "./product-commerce/commerce-client.js"; -export { HttpCommerceClient } from "./product-commerce/http-commerce-client.js"; // ── Phase 2: catalog display (plan §7 steps 4–10, route shape per ADR-0003) ── export { CommerceBatchLoader, diff --git a/packages/plugin/src/product-commerce/commerce-client.ts b/packages/plugin/src/product-commerce/commerce-client.ts index ba7d2628..014830a3 100644 --- a/packages/plugin/src/product-commerce/commerce-client.ts +++ b/packages/plugin/src/product-commerce/commerce-client.ts @@ -1,12 +1,33 @@ /** - * The `CommerceClient` transport port (ADR-0002 §3 / plan §5): storefront - * routes and the widget's save route depend on this INTERFACE, never on - * `fetch` directly. `HttpCommerceClient` (http-commerce-client.ts) is the - * only adapter this phase builds — `InProcessCommerceClient` is deferred - * (ADR-0002 §6: no premature abstraction beyond a second real adapter). + * The `CommerceClient` port (ADR-0002 §3 / plan §5): storefront routes and the + * widget's save route depend on this INTERFACE, never on `fetch` directly. + * `InProcessCommerceClient` (`src/commerce/in-process-commerce-client.ts`) is + * now its only implementation — work order 02 folded the commerce service into + * the plugin, and INC-D3a/D3b deleted the `ctx.http` adapter that used to be the + * other one. * - * Wire types mirror `@otta-sh/service`'s `PUT/GET/DELETE /products/:id/commerce` - * 1:1 (money as an integer + ISO-4217 string, never a float). + * ── WHY THE `*Wire` TYPES STAY (the INC-D3b call, to cost out at INC-D4) ── + * + * These interfaces, and the matching ones in `src/admin/*-surface.ts`, were + * written to mirror the commerce service's JSON 1:1. That service is gone, so + * nothing here mirrors anything over a network any more and the word "wire" is + * HISTORICAL — it now just names the shape the plugin's own route handlers + * return and the Block Kit renderers and storefront routes consume. + * + * They stay as they are. They are deliberately decoupled from the domain's + * branded money (`Cents`) and its use-case result unions, and neither belongs in + * presentation code: a Block Kit renderer that had to unwrap a branded scalar, + * or a storefront route that had to narrow a domain result union, would be + * carrying the domain's vocabulary into a layer whose job is to format strings. + * The plugin's sandbox-cleanliness rule (no `@otta-sh/domain` import from these + * modules) points the same way. + * + * INC-D4 should cost out only the NARROWER question: de-duplicating these + * `*Wire` interfaces against the domain's READ MODELS, which are the shapes they + * actually restate field-for-field. That is a real duplication with a real + * maintenance cost, and it is a separate decision from "use domain types in the + * plugin", which the paragraph above rejects. Nothing about it is required for + * correctness today. */ export interface CommerceMoney { @@ -88,11 +109,11 @@ export interface ProductCommerceBatchItem { // ── end Phase 2 catalog batch read ─────────────────────────────────────── // ── Variants wire types ────────────────────────────────────────────────── -// Mirror `@otta-sh/service`'s `serializeVariant`/`serializeVariantSummary` 1:1. -// Money is an integer minor-unit amount + an ISO-4217 string, and ABSENT IS -// ABSENT: an unpriced size is `null`, never `0` and never a zero-amount object. +// The shape the plugin's own variant serialization returns. Money is an integer +// minor-unit amount + an ISO-4217 string, and ABSENT IS ABSENT: an unpriced size +// is `null`, never `0` and never a zero-amount object. -/** One sellable unit of a product, as the service serializes it. */ +/** One sellable unit of a product, as the plugin serializes it. */ export interface ProductVariantWire { productId: string; /** The CMS repeater row's stable, IMMUTABLE key — the variant's identity @@ -277,9 +298,9 @@ export interface CommerceClient { ): Promise; // ── end variants ────────────────────────────────────────────────────── - // ── Phase 3 group E: cart (plan §6, wire mirrors @otta-sh/service's ───── - // `/carts` routes 1:1, hand-rolled like the wire types above — the - // plugin declares no runtime dependency on @otta-sh/domain/service). ──── + // ── Phase 3 group E: cart (plan §6) ──────────────────────────────────── + // Hand-rolled like the wire types above: these modules declare no runtime + // dependency on @otta-sh/domain, which is what keeps them sandbox-clean. ── createCart(currency?: string): Promise<{ cartId: string }>; getCart(cartId: string): Promise>; addCartLine( @@ -305,10 +326,9 @@ export interface CommerceClient { ): Promise>>; // ── end Phase 3 group E: cart ───────────────────────────────────────── - // ── Phase 5: storefront customer account (plan §7, wire mirrors ─────── - // @otta-sh/service's /auth + /me routes 1:1; the bearer session token is - // passed through from the plugin's first-party cookie layer, never held - // by the sandboxed plugin itself). ──────────────────────────────────── + // ── Phase 5: storefront customer account (plan §7) ──────────────────── + // The bearer session token is passed through from the plugin's first-party + // cookie layer, never held by the sandboxed plugin itself. ───────────── requestLoginLink(email: string): Promise<{ ok: true }>; verifyLogin(challengeId: string, token: string): Promise; logout(sessionToken: string): Promise; @@ -330,12 +350,11 @@ export interface CommerceClient { * `buyerRef`: the raw-email scope is operator-only and its secret is one the * sandbox does not and must not hold. * - * DECLARED HERE, not only on `HttpCommerceClient`: `entitlements/download-route.ts` - * calls it through the client it is handed, so the PORT has to carry it. The - * declaration was missing while that route constructed the concrete class - * directly; INC-A6 routes it through `makeCommerceClient`, which returns the - * port. `HttpCommerceClient` already implements exactly this signature, so - * adding it changes no behaviour — only what the type system knows. + * DECLARED HERE, on the PORT: `entitlements/download-route.ts` calls it + * through the client it is handed, so the port has to carry it. The + * declaration was missing while that route constructed a concrete client + * directly; INC-A6 routed it through `makeCommerceClient`, which returns the + * port instead. */ checkEntitlement( scope: { orderId?: string }, @@ -345,9 +364,8 @@ export interface CommerceClient { // ── end delivery authorization ──────────────────────────────────────── // ── Phase 4: checkout (quote → order → public order read) ───────────── - // Wire mirrors @otta-sh/service's routes/orders.ts 1:1. Every typed failure - // rides the same `{ ok: false, reason }` envelope regardless of status - // (adapter rule #2 — 400/404/409/502 all carry one), so callers branch on + // Every typed failure rides the same `{ ok: false, reason }` envelope + // (adapter rule #2, "no status-code-as-logic"), so callers branch on // the token and never on an HTTP code. quoteCheckout(input: QuoteRequestWire): Promise; /** The `idempotencyKey` is the CALLER's — forwarded verbatim as @@ -363,8 +381,8 @@ export interface CommerceClient { } // ── Phase 4: checkout wire types ─────────────────────────────────────────── -// Mirror @otta-sh/service's `quoteBody`/`checkoutBody` (schemas.ts) and its -// quote/checkout/public-order serializations 1:1. Money is integer minor units +// The request shapes the plugin's checkout routes accept and the +// quote/checkout/public-order shapes they return. Money is integer minor units // + an ISO-4217 string, never a float. export interface QuoteRequestWire { @@ -384,8 +402,8 @@ export interface QuoteBreakdownWire { appliedCouponCode: string | null; } -/** `@otta-sh/service`'s quote rejections: the cart pre-checks it runs before - * `computeQuote` (`orders.ts`) plus `QuoteFailure`'s own union. */ +/** The quote rejections: the cart pre-checks run before `computeQuote`, plus + * the domain `QuoteFailure`'s own union. */ export type QuoteFailureReason = | "CART_NOT_FOUND" | "CART_EMPTY" @@ -502,8 +520,8 @@ export type PublicOrderResult = // ── end Phase 4 checkout wire types ──────────────────────────────────────── // ── Phase 5: customer account wire types (plan §7) ───────────────────────── -// Mirror @otta-sh/service's serializeOrder / serializeCustomer / serializeAddress -// 1:1. Money is integer minor units + ISO-4217 string, never a float. +// The order / customer / address shapes the account routes return. Money is +// integer minor units + ISO-4217 string, never a float. export interface OrderTotalsWire { currency: string; subtotalCents: number; @@ -555,8 +573,8 @@ export type AuthedResult = ({ ok: true } & T) | { ok: false; reason: "UNAUTHE // ── end Phase 5 customer account wire types ──────────────────────────────── // ── Phase 3 group E: cart wire types (plan §6) ───────────────────────────── -// Mirror `@otta-sh/service`'s `routes/carts.ts` serialization 1:1: NO price -// field on a line (a cart line snapshots no price — domain `CartStore`'s own +// The cart serialization: NO price field on a line (a cart line snapshots no +// price — domain `CartStore`'s own // documented invariant; the live price is read from `product_commerce` // elsewhere, at display/checkout, never stored on the line). export interface CartLineWire { @@ -576,9 +594,9 @@ export interface CartWire { * * REQUIRED, never optional: an optional field would let TypeScript's own * narrowing bless a bare `!== null` on a value that can still arrive - * `undefined` over a skewed wire. `HttpCommerceClient.getCart` NORMALIZES a - * missing, empty or non-string value to `null` before any consumer sees it, - * which is what makes this declaration honest at runtime too. + * `undefined`. `InProcessCommerceClient`'s `serializeCart` copies the domain + * `Cart.orderId`, which is itself `OrderId | null` and never absent, so the + * declaration is honest at runtime and not merely by assertion. * * Not a payment signal (it is stamped before the payment intent), and a null * does NOT prove that no order exists for the cart. @@ -590,13 +608,11 @@ export interface CartWire { /** * Typed cart-mutation failures — SEMANTIC TOKENS, never English (matches - * Phase 2's `AvailabilityToken` pattern): `@otta-sh/service`'s `CartFailure` - * union verbatim (adapter-architecture rule #2, "no status-code-as-logic" — - * `OUT_OF_STOCK` rides a 200, `CART_NOT_FOUND`/`LINE_NOT_FOUND` a 404, - * `CART_CHECKED_OUT`/`LINE_CHECKED_OUT`/`HOLD_EXPIRED` a 409 — the CLIENT - * normalizes all of these back to a uniform `{ ok: false; reason }` value, - * see `HttpCommerceClient`'s `#cartResult`, so callers branch on the token, - * never the HTTP status). + * Phase 2's `AvailabilityToken` pattern): the domain `CartFailure` union + * verbatim. Adapter-architecture rule #2, "no status-code-as-logic": every one + * of these rides the same uniform `{ ok: false; reason }` value, so callers + * branch on the token and there is no status to reach for even where the route + * layer picks one. */ export type CartFailureReason = | "OUT_OF_STOCK" diff --git a/packages/plugin/src/product-commerce/http-commerce-client.ts b/packages/plugin/src/product-commerce/http-commerce-client.ts deleted file mode 100644 index 698c9a5c..00000000 --- a/packages/plugin/src/product-commerce/http-commerce-client.ts +++ /dev/null @@ -1,696 +0,0 @@ -import type { HttpAccess } from "../types.js"; -import { - CommerceClientError, - type AddressWire, - type AuthedResult, - type CartLineWire, - type CartResult, - type CartWire, - type CheckoutFailureReason, - type CheckoutRequestWire, - type CheckoutResult, - type CommerceClient, - type LoginVerifyResult, - type OrderSummaryWire, - type PaymentIntentWire, - type ProductCommerce, - type ProductCommerceBatchItem, - type ProductVariantSummaryWire, - type ProductVariantWire, - type PublicOrderResult, - type PublicOrderWire, - type QuoteBreakdownWire, - type QuoteFailureReason, - type QuoteRequestWire, - type QuoteResult, - type UpdateProductVariantFieldsInput, - type UpsertProductCommerceInput, - type UpsertProductVariantInput, - type VariantUpdateResult, -} from "./commerce-client.js"; - -export interface HttpCommerceClientOptions { - /** `ctx.http.fetch` — the ONLY egress the sandbox grants (`network:request` - * + `allowedHosts`). Never the ambient global `fetch`. */ - fetch: HttpAccess["fetch"]; - baseUrl: string; - /** The machine write-gate token the service enforces as `X-Service-Token` - * (ADR-0007), sourced by the construction site from write-only `ctx.kv` - * from write-only plugin kv. Undefined ⇒ no header - * is attached ⇒ byte-identical to the pre-gate wire. Attached to EVERY - * request (incl. GET reads and `logout`) — see `#baseHeaders`. */ - serviceToken?: string; -} - -/** - * `CommerceClient` over `ctx.http` (plan §5/§6). Serializes each call as a - * straight 1:1 mirror of the service REST API — `Idempotency-Key` as a - * header, money as integer + ISO-4217 currency, no status-code-as-logic - * beyond the envelope the service already defines (adapter-architecture - * rule #2). - */ -export class HttpCommerceClient implements CommerceClient { - readonly #fetch: HttpAccess["fetch"]; - readonly #baseUrl: string; - readonly #serviceToken: string | undefined; - - constructor(options: HttpCommerceClientOptions) { - this.#fetch = options.fetch; - this.#baseUrl = options.baseUrl.replace(/\/$/, ""); - this.#serviceToken = options.serviceToken; - } - - /** Merge the `X-Service-Token` write-gate header (ADR-0007) into every - * request's headers when a token is configured. Attached uniformly — - * including GET reads (harmless: GET is gate-exempt) — so a future reader - * never has to reason about which verbs need it. NOTE `getCommerceBatch` is - * a POST *read* that genuinely requires the header (the write gate blocks - * ALL non-GET), so the header must NOT be "optimized off" storefront paths. */ - #baseHeaders(extra: Record = {}): Record { - return this.#serviceToken === undefined - ? extra - : { ...extra, "X-Service-Token": this.#serviceToken }; - } - - async upsertProductCommerce( - productId: string, - input: UpsertProductCommerceInput, - idempotencyKey: string, - ): Promise { - const res = await this.#fetch(this.#url(productId), { - method: "PUT", - headers: this.#baseHeaders({ - "content-type": "application/json", - "Idempotency-Key": idempotencyKey, - }), - body: JSON.stringify(input), - }); - return this.#json(res); - } - - async getProductCommerce(productId: string): Promise { - const res = await this.#fetch(this.#url(productId), { - method: "GET", - headers: this.#baseHeaders(), - }); - return this.#json(res); - } - - async softDeleteProductCommerce(productId: string, idempotencyKey: string): Promise { - const res = await this.#fetch(this.#url(productId), { - method: "DELETE", - headers: this.#baseHeaders({ "Idempotency-Key": idempotencyKey }), - }); - await this.#json<{ ok: true }>(res); - } - - async activateProductCommerce( - productId: string, - idempotencyKey: string, - contentUpdatedAt: string, - ): Promise { - const res = await this.#fetch(`${this.#url(productId)}/activate`, { - method: "POST", - headers: this.#baseHeaders({ - "content-type": "application/json", - "Idempotency-Key": idempotencyKey, - }), - body: JSON.stringify({ contentUpdatedAt }), - }); - await this.#json<{ ok: true }>(res); - } - - async deactivateProductCommerce( - productId: string, - idempotencyKey: string, - contentUpdatedAt: string, - ): Promise { - const res = await this.#fetch(`${this.#url(productId)}/deactivate`, { - method: "POST", - headers: this.#baseHeaders({ - "content-type": "application/json", - "Idempotency-Key": idempotencyKey, - }), - body: JSON.stringify({ contentUpdatedAt }), - }); - await this.#json<{ ok: true }>(res); - } - - // ── Phase 2: catalog batch read (plan §6) ───────────────────────────── - // (A later Phase-3 task adds its cart methods below this block — keep - // the delimiters so the diff surfaces stay additive.) - - /** `POST /catalog/commerce/batch` — one request per page of ids (the - * request-scoped loader guarantees the "one" part; the service's id cap - * is the size guard). No idempotency key: a pure read. */ - async getCommerceBatch(productIds: string[]): Promise { - const res = await this.#fetch(`${this.#baseUrl}/catalog/commerce/batch`, { - method: "POST", - headers: this.#baseHeaders({ "content-type": "application/json" }), - body: JSON.stringify({ productIds }), - }); - const body = await this.#json<{ items: ProductCommerceBatchItem[] }>(res); - return body.items; - } - - // ── end Phase 2 catalog batch read ──────────────────────────────────── - - // ── Variants: one method per WRITER (ADR-0016) ──────────────────────── - // 1:1 mirrors of the service's `/products/:id/variants*` routes. The - // variant key is a path SEGMENT and is `encodeURIComponent`-escaped: it is - // opaque CMS text, so a key carrying a slash or a space must address its own - // row rather than a route that does not exist. - - /** `GET /products/:id/variants` — the LIVE variants of one product, ordered - * by key. The route answers two projections and this call takes the PUBLIC - * one: it deliberately sends no `X-Internal-Token`, so orphans are filtered - * out server-side, for the same reason `getPublicOrder` withholds that header - * — a storefront page must never be handed the operator's view. A pure read: - * no idempotency key, and an unknown product — or one whose every size is - * orphaned — is `[]`, never an error. */ - async listProductVariants(productId: string): Promise { - const res = await this.#fetch(this.#variantsUrl(productId), { - method: "GET", - headers: this.#baseHeaders(), - }); - const body = await this.#json<{ variants: ProductVariantSummaryWire[] }>(res); - return body.variants; - } - - /** `PUT /products/:id/variants/:variantKey` — the CMS-sync declare. Sends - * ONLY the name cache and the watermark; the body is `.strict()` at the - * service, so a stray commercial field is a 400 rather than a silent drop. */ - async upsertProductVariant( - productId: string, - variantKey: string, - input: UpsertProductVariantInput, - idempotencyKey: string, - ): Promise { - const res = await this.#fetch(this.#variantUrl(productId, variantKey), { - method: "PUT", - headers: this.#baseHeaders({ - "content-type": "application/json", - "Idempotency-Key": idempotencyKey, - }), - body: JSON.stringify(input), - }); - return this.#json(res); - } - - /** `PATCH /products/:id/variants/:variantKey` — the guarded admin edit. - * Every documented refusal is normalized to a typed VALUE; only a body with - * no recognizable envelope at all still throws `CommerceClientError`. */ - async updateProductVariantFields( - productId: string, - variantKey: string, - input: UpdateProductVariantFieldsInput, - expectedUpdatedAt: string, - idempotencyKey: string, - ): Promise { - const res = await this.#fetch(this.#variantUrl(productId, variantKey), { - method: "PATCH", - headers: this.#baseHeaders({ - "content-type": "application/json", - "Idempotency-Key": idempotencyKey, - }), - body: JSON.stringify({ ...input, expectedUpdatedAt }), - }); - let body: unknown; - try { - body = await res.json(); - } catch { - body = undefined; - } - if (res.ok) return { ok: true, variant: body as ProductVariantWire }; - // The integrator commerce routes carry their machine code on `error`, - // where the cart/checkout envelopes carry it on `reason`. Normalize to - // `reason` HERE so every typed failure in this client reads the same way, - // and a caller never has to know which family of routes answered it. - const refusal = asVariantRefusal(body); - if (refusal !== null) return refusal; - throw new CommerceClientError(res.status, body); - } - - /** `POST /products/:id/variants/:variantKey/deactivate` — the orphan - * transition. Deactivation, never deletion; an unknown key is a no-op. */ - async deactivateProductVariant( - productId: string, - variantKey: string, - idempotencyKey: string, - contentUpdatedAt: string, - ): Promise { - const res = await this.#fetch(`${this.#variantUrl(productId, variantKey)}/deactivate`, { - method: "POST", - headers: this.#baseHeaders({ - "content-type": "application/json", - "Idempotency-Key": idempotencyKey, - }), - body: JSON.stringify({ contentUpdatedAt }), - }); - await this.#json<{ ok: true }>(res); - } - // ── end variants ────────────────────────────────────────────────────── - - // ── Phase 3 group E: cart (plan §6 step 6) ──────────────────────────── - // Straight 1:1 mirrors of `@otta-sh/service`'s `routes/carts.ts`. Typed - // cart failures (`OUT_OF_STOCK`/`CART_NOT_FOUND`/…) ride a MIX of 200/ - // 404/409 at the wire (adapter-architecture rule #2 — no status-code- - // as-logic); `#cartResult` normalizes all of them to the same - // `{ ok: false; reason }` shape regardless of status, so callers never - // branch on an HTTP code. Only a genuinely unexpected response (no - // `ok`/`reason` envelope — a malformed body, a 500, a 400 validation - // reject) still throws `CommerceClientError`. - - /** `POST /carts` — no typed-failure envelope; a non-2xx here is a client - * bug (bad currency), not a business outcome, so it throws. */ - async createCart(currency?: string): Promise<{ cartId: string }> { - const res = await this.#fetch(`${this.#baseUrl}/carts`, { - method: "POST", - headers: this.#baseHeaders({ "content-type": "application/json" }), - body: JSON.stringify(currency === undefined ? {} : { currency }), - }); - return this.#json<{ cartId: string }>(res); - } - - /** `GET /carts/:cartId` — runs lazy-expiry server-side first; 404 ⇒ typed - * `CART_NOT_FOUND`, never a thrown error for that expected case. - * - * Also NORMALIZES `cart.orderId` to `null` (issue #132). See the comment on - * the coercion below for why the guard lives here and nowhere else. */ - async getCart(cartId: string): Promise> { - const res = await this.#fetch(`${this.#baseUrl}/carts/${encodeURIComponent(cartId)}`, { - method: "GET", - headers: this.#baseHeaders(), - }); - const result = await this.#cartResult<{ cart: CartWire }>(res); - // Nothing on this path validates the cart body at runtime: `#cartResult` - // blind-casts once `isCartEnvelope` has confirmed only "an object with an - // `ok` key". A field the service stops emitting therefore arrives as - // `undefined`, fully type-checked. - // - // `state` fails SAFELY that way (`isCartTerminal(undefined)` is false). - // `orderId` fails UNSAFELY: `undefined !== null` is true, so a consumer - // renders `/orders/undefined` — a dead link offered as a primary action. - // `""` is just as bad (`/orders/`), hence the length check as well as the - // type check. - // - // It belongs HERE, in `getCart`: this is field-specific, and it sits at - // the wire boundary where version skew actually lands (a new bundle - // talking to an older deployed service). `HttpCommerceClient` is the sole - // `CommerceClient` implementation, so this one coercion also covers - // `cart-routes.ts`'s read route, `checkout-routes.ts`, and any consumer of - // the published `CommerceClient.getCart`. - // - // NOT in `#cartResult`: that is generic over `T` and shared with - // `addCartLine`/`adjustCartLine`/`removeCartLine`; special-casing a field - // name inside a generic envelope normalizer is the wrong layer. And NOT - // double-guarded downstream: `sites/staging` bundles `@otta-sh/plugin` - // (`noExternal`), so site+plugin ship as ONE deployable and the only skew - // boundary is (site+plugin) ⇄ service — a second guard would be redundant - // by construction and would drift. - // - // The coercion is TOTAL, and that includes `cart` itself: the thesis above - // is "this wire is unvalidated", and `isCartEnvelope` never checked for a - // `cart` key either. A success envelope arriving without one — or with a - // null or non-object one — is passed through EXACTLY as it was before this - // PR rather than becoming a new `TypeError` thrown from inside the client. - // Failing loud there would be defensible, but it would be an undocumented - // behaviour change for a direct `CommerceClient.getCart` consumer, and the - // guard costs one condition. - const cart: unknown = result.ok ? result.cart : undefined; - if (typeof cart === "object" && cart !== null) { - const wire = cart as CartWire; - const raw: unknown = wire.orderId; - wire.orderId = typeof raw === "string" && raw.length > 0 ? raw : null; - } - return result; - } - - /** `POST /carts/:cartId/lines` — `Idempotency-Key` header (CLAUDE.md: every - * command carries one); `OUT_OF_STOCK` is a typed 200 body. */ - async addCartLine( - cartId: string, - sku: string, - productId: string | null, - qty: number, - idempotencyKey: string, - ): Promise> { - const res = await this.#fetch(`${this.#baseUrl}/carts/${encodeURIComponent(cartId)}/lines`, { - method: "POST", - headers: this.#baseHeaders({ - "content-type": "application/json", - "Idempotency-Key": idempotencyKey, - }), - // `productId` is the join key to `product_commerce` (issue #80): the - // service resolves price/fulfillment kind from it, and a null productId - // is why a storefront cart used to 409 PRODUCT_NOT_PRICED at checkout. - // OMIT the key when null so the wire stays byte-identical to the - // pre-#80 shape for a bare (legacy) add (the service body treats an - // absent productId as null — `addLineBody`). - body: JSON.stringify(productId === null ? { sku, qty } : { sku, qty, productId }), - }); - return this.#cartResult<{ line: CartLineWire }>(res); - } - - /** `PATCH /carts/:cartId/lines/:lineId` — delta-free on the wire: the - * caller sends the target qty, the service applies the delta. */ - async adjustCartLine( - cartId: string, - lineId: string, - qty: number, - idempotencyKey: string, - ): Promise> { - const res = await this.#fetch( - `${this.#baseUrl}/carts/${encodeURIComponent(cartId)}/lines/${encodeURIComponent(lineId)}`, - { - method: "PATCH", - headers: this.#baseHeaders({ - "content-type": "application/json", - "Idempotency-Key": idempotencyKey, - }), - body: JSON.stringify({ qty }), - }, - ); - return this.#cartResult<{ line: CartLineWire }>(res); - } - - /** `DELETE /carts/:cartId/lines/:lineId`. */ - async removeCartLine( - cartId: string, - lineId: string, - idempotencyKey: string, - ): Promise>> { - const res = await this.#fetch( - `${this.#baseUrl}/carts/${encodeURIComponent(cartId)}/lines/${encodeURIComponent(lineId)}`, - { method: "DELETE", headers: this.#baseHeaders({ "Idempotency-Key": idempotencyKey }) }, - ); - return this.#cartResult>(res); - } - - /** Normalizes a cart response to `{ok:true,...}`/`{ok:false,reason}` - * regardless of HTTP status — the typed-failure envelope IS the - * contract, not the status code (adapter-architecture rule #2). Falls - * back to throwing `CommerceClientError` only when the body carries no - * recognizable envelope at all. */ - async #cartResult>(res: Response): Promise> { - let body: unknown; - try { - body = await res.json(); - } catch { - body = undefined; - } - if (isCartEnvelope(body)) return body as CartResult; - throw new CommerceClientError(res.status, body); - } - // ── end Phase 3 group E: cart ───────────────────────────────────────── - - // -- Phase 4: checkout + entitlement seam --------------------------------- - // (A clearly-delimited additive block — Phase 2 adds `getCommerceBatch` to - // this same file in parallel.) These mirror the service's Phase-4 endpoints - // 1:1. Delivery authorization is a READ, but NOT anonymous (issue #33 / - // ADR-0011): the orderId scope is an unguessable bearer capability (no auth - // header), and the session scope threads the customer's Bearer so the service - // can derive the email server-side. - - /** - * Delivery authorization (§6/§7, ADR-0011), matching the service's - * presence-based scope precedence. Two scopes only: - * - `orderId` — the download link's unguessable order id; an open bearer - * capability, no auth header. - * - session — a logged-in customer checks their OWN entitlements; the Bearer - * session token is threaded and the service derives the email server-side. - * The plugin NEVER sends `buyerRef`: the raw-email scope is operator-only - * (`X-Internal-Token`), a secret the sandbox does not and must not hold — so a - * storefront path can never re-acquire the email existence oracle. - * A 401 (invalid/expired session) normalizes to a typed `UNAUTHENTICATED` - * (never a thrown error) — the download route turns it into a login redirect. - */ - async checkEntitlement( - scope: { orderId?: string }, - sku: string, - opts: { sessionToken?: string } = {}, - ): Promise> { - const params = new URLSearchParams({ sku }); - if (scope.orderId !== undefined) params.set("orderId", scope.orderId); - const headers = - opts.sessionToken !== undefined ? this.#authHeaders(opts.sessionToken) : this.#baseHeaders(); - const res = await this.#fetch(`${this.#baseUrl}/entitlements/check?${params.toString()}`, { - method: "GET", - headers, - }); - if (res.status === 401) return { ok: false, reason: "UNAUTHENTICATED" }; - const body = await this.#json<{ ok: boolean; active?: boolean }>(res); - return { ok: true, active: body.active === true }; - } - - // ------------------------------------------------------------------------- - - // ── Phase 4: checkout (storefront-checkout plan §1.2) ──────────────────── - // 1:1 mirrors of `POST /checkout/quote`, `POST /checkout/orders` and - // `GET /orders/:orderId`. Typed failures ride a MIX of 400/404/409/502 at - // the wire; `#envelopeResult` normalizes every one of them to the same - // `{ ok: false, reason }` value (adapter rule #2 — no status-code-as-logic), - // so a 502 `PAYMENT_INTENT_FAILED` is a business outcome the checkout page - // can explain, never a thrown transport error. Only a body with no - // recognizable envelope at all (a zod parse reject, a 500) still throws. - - /** `POST /checkout/quote` — a read, but a POST, so the write gate blocks it - * without `X-Service-Token`. Never redeems a coupon: safe to repeat. */ - async quoteCheckout(input: QuoteRequestWire): Promise { - const res = await this.#fetch(`${this.#baseUrl}/checkout/quote`, { - method: "POST", - headers: this.#baseHeaders({ "content-type": "application/json" }), - body: JSON.stringify(input), - }); - return this.#envelopeResult<{ breakdown: QuoteBreakdownWire }, QuoteFailureReason>(res); - } - - /** `POST /checkout/orders` — mints the order, holds stock for the TTL and - * creates the payment intent. `idempotencyKey` is the CALLER's and is - * forwarded VERBATIM: a same-key replay returns the original order (and, - * via Stripe's own native idempotency, the same PaymentIntent), which is - * exactly what makes a reload of the pay step safe. */ - async createOrder(input: CheckoutRequestWire, idempotencyKey: string): Promise { - const res = await this.#fetch(`${this.#baseUrl}/checkout/orders`, { - method: "POST", - headers: this.#baseHeaders({ - "content-type": "application/json", - "Idempotency-Key": idempotencyKey, - }), - body: JSON.stringify(input), - }); - return this.#envelopeResult< - { order: PublicOrderWire; intent: PaymentIntentWire }, - CheckoutFailureReason - >(res); - } - - /** `GET /orders/:orderId` — the unauthenticated capability read (ADR-0010 - * §2). Deliberately sends NO `X-Internal-Token`: with one the service - * answers the full admin projection (`buyerRef`, ship-to, reconciliation), - * and this reply renders on a page any holder of the URL can open. The - * storefront must only ever see `serializePublicOrder`'s whitelist. */ - async getPublicOrder(orderId: string): Promise { - const res = await this.#fetch(`${this.#baseUrl}/orders/${encodeURIComponent(orderId)}`, { - method: "GET", - headers: this.#baseHeaders(), - }); - return this.#envelopeResult<{ order: PublicOrderWire }, "ORDER_NOT_FOUND">(res); - } - - /** The cart-envelope normalization, generalized over its failure token — - * `#cartResult`'s shape, reused so checkout cannot drift from carts. */ - async #envelopeResult, R extends string>( - res: Response, - ): Promise<({ ok: true } & T) | { ok: false; reason: R }> { - let body: unknown; - try { - body = await res.json(); - } catch { - body = undefined; - } - if (isCartEnvelope(body)) return body as ({ ok: true } & T) | { ok: false; reason: R }; - throw new CommerceClientError(res.status, body); - } - // ── end Phase 4 checkout ───────────────────────────────────────────────── - - // ── Phase 5: storefront customer account (plan §7) ───────────────────── - // 1:1 mirrors of the service's /auth + /me routes. The bearer session token - // is threaded from the plugin's first-party cookie layer; a 401 is - // normalized to a typed `UNAUTHENTICATED` the account route turns into a - // redirect (never a thrown error for that expected case). - - /** `POST /auth/login/request` — always a generic success (no enumeration - * oracle, §9 Risk 4). */ - async requestLoginLink(email: string): Promise<{ ok: true }> { - await this.#fetch(`${this.#baseUrl}/auth/login/request`, { - method: "POST", - headers: this.#baseHeaders({ "content-type": "application/json" }), - body: JSON.stringify({ email }), - }); - return { ok: true }; - } - - /** `POST /auth/login/verify` — 200 ⇒ session token; 401 ⇒ typed reason. */ - async verifyLogin(challengeId: string, token: string): Promise { - const res = await this.#fetch(`${this.#baseUrl}/auth/login/verify`, { - method: "POST", - headers: this.#baseHeaders({ "content-type": "application/json" }), - body: JSON.stringify({ challengeId, token }), - }); - const body = (await res.json().catch(() => undefined)) as - | { sessionToken?: string; expiresAt?: string; reason?: string } - | undefined; - if (res.ok && body?.sessionToken !== undefined && body.expiresAt !== undefined) { - return { ok: true, sessionToken: body.sessionToken, expiresAt: body.expiresAt }; - } - const reason = body?.reason; - if (reason === "EXPIRED" || reason === "INVALID" || reason === "CONSUMED") { - return { ok: false, reason }; - } - return { ok: false, reason: "INVALID" }; - } - - /** `POST /auth/logout` — best-effort revoke; idempotent server-side. */ - async logout(sessionToken: string): Promise { - await this.#fetch(`${this.#baseUrl}/auth/logout`, { - method: "POST", - headers: this.#authHeaders(sessionToken), - }); - } - - async listMyOrders(sessionToken: string): Promise> { - const res = await this.#fetch(`${this.#baseUrl}/me/orders`, { - method: "GET", - headers: this.#authHeaders(sessionToken), - }); - if (res.status === 401) return { ok: false, reason: "UNAUTHENTICATED" }; - const body = await this.#json<{ orders: OrderSummaryWire[] }>(res); - return { ok: true, orders: body.orders }; - } - - async getMyOrder( - sessionToken: string, - orderId: string, - ): Promise< - { ok: true; order: OrderSummaryWire } | { ok: false; reason: "UNAUTHENTICATED" | "NOT_FOUND" } - > { - const res = await this.#fetch(`${this.#baseUrl}/me/orders/${encodeURIComponent(orderId)}`, { - method: "GET", - headers: this.#authHeaders(sessionToken), - }); - if (res.status === 401) return { ok: false, reason: "UNAUTHENTICATED" }; - if (res.status === 404) return { ok: false, reason: "NOT_FOUND" }; - const body = await this.#json<{ order: OrderSummaryWire }>(res); - return { ok: true, order: body.order }; - } - - async listMyAddresses(sessionToken: string): Promise> { - const res = await this.#fetch(`${this.#baseUrl}/me/addresses`, { - method: "GET", - headers: this.#authHeaders(sessionToken), - }); - if (res.status === 401) return { ok: false, reason: "UNAUTHENTICATED" }; - const body = await this.#json<{ addresses: AddressWire[] }>(res); - return { ok: true, addresses: body.addresses }; - } - - /** Session-auth headers. `authorization: Bearer ` is the CUSTOMER - * session token (owned by the service's session auth); the write-gate - * `X-Service-Token` is merged in alongside it (ADR-0007) — the two headers - * are orthogonal, so `logout` and the `/me/*` reads carry BOTH when a service - * token is configured, exactly what the gate + session auth each require. */ - #authHeaders(sessionToken: string): Record { - return this.#baseHeaders({ authorization: `Bearer ${sessionToken}` }); - } - // ── end Phase 5 customer account ─────────────────────────────────────── - - #url(productId: string): string { - return `${this.#baseUrl}/products/${encodeURIComponent(productId)}/commerce`; - } - - #variantsUrl(productId: string): string { - return `${this.#baseUrl}/products/${encodeURIComponent(productId)}/variants`; - } - - #variantUrl(productId: string, variantKey: string): string { - return `${this.#variantsUrl(productId)}/${encodeURIComponent(variantKey)}`; - } - - async #json(res: Response): Promise { - let body: unknown; - try { - body = await res.json(); - } catch { - body = undefined; - } - if (!res.ok) { - throw new CommerceClientError(res.status, body); - } - return body as T; - } -} - -/** - * Map one variant-edit refusal body onto its typed value, or `null` when the - * body carries no refusal this client knows — which is what makes an unknown - * shape throw instead of silently becoming a plausible-looking failure. - * - * The operands travel WITH the token on purpose: every one of these refusals is - * something an operator has to act on (which sku is taken, which two skus a - * rename spans, how many holds are still live, which watermark to reload), and - * the service composes no sentence — the console does, from these fields, in one - * place. - * - * A missing or wrong-typed operand becomes `null`, and NEVER a stand-in value. - * The token is still the decision, so the refusal is not discarded over a field - * the console can render as "unavailable" — but a default here is a lie the - * console cannot detect: `liveHolds: 0` beside SKU_HELD_STOCK denies the very - * holds that caused the refusal, and `currentUpdatedAt: ""` hands back a - * watermark that is guaranteed to be stale again on the retry. See - * `VariantUpdateResult`. - */ -function asVariantRefusal(body: unknown): VariantUpdateResult | null { - if (typeof body !== "object" || body === null) return null; - const row = body as Record; - if (row.ok !== false) return null; - const text = (key: string): string | null => (typeof row[key] === "string" ? row[key] : null); - switch (row.error) { - case "VARIANT_NOT_FOUND": - return { ok: false, reason: "VARIANT_NOT_FOUND" }; - case "STALE_EDIT": - return { ok: false, reason: "STALE_EDIT", currentUpdatedAt: text("currentUpdatedAt") }; - case "CURRENCY_MISMATCH": - return { ok: false, reason: "CURRENCY_MISMATCH", currency: text("currency") }; - case "INVALID_FIELD": - return { ok: false, reason: "INVALID_FIELD", field: text("field") }; - case "SKU_TAKEN": - return { ok: false, reason: "SKU_TAKEN", sku: text("sku") }; - case "SKU_STOCK_CONFLICT": - return { - ok: false, - reason: "SKU_STOCK_CONFLICT", - fromSku: text("fromSku"), - toSku: text("toSku"), - }; - case "SKU_HELD_STOCK": - return { - ok: false, - reason: "SKU_HELD_STOCK", - sku: text("sku"), - liveHolds: typeof row.liveHolds === "number" ? row.liveHolds : null, - }; - default: - return null; - } -} - -/** True for both `{ok:true,...}` and `{ok:false,reason:}` — the two - * shapes `@otta-sh/service`'s cart routes' `failure()`/success bodies take. */ -function isCartEnvelope(body: unknown): body is { ok: boolean; reason?: unknown } { - if (typeof body !== "object" || body === null || !("ok" in body)) return false; - const ok = (body as { ok: unknown }).ok; - if (ok === true) return true; - if (ok === false) return typeof (body as { reason?: unknown }).reason === "string"; - return false; -} diff --git a/packages/plugin/src/storefront/account-routes.ts b/packages/plugin/src/storefront/account-routes.ts index 3fd36dfe..bc329c6e 100644 --- a/packages/plugin/src/storefront/account-routes.ts +++ b/packages/plugin/src/storefront/account-routes.ts @@ -1,8 +1,9 @@ /** * Storefront customer account — PLUGIN-OWNED PUBLIC ROUTES (Phase 5 §9, shape - * per ADR-0003 and the cart-routes precedent). Thin, HTTP-only: each route - * validates input → `HttpCommerceClient` call over `ctx.http` → serialize the - * (already-typed) result. The plugin holds NO customer/session state. + * per ADR-0003 and the cart-routes precedent). Thin: each route validates + * input → calls the in-process `CommerceClient` from `makeCommerceClient` → + * serializes the (already-typed) result. The plugin holds NO customer/session + * state. * * ── Platform-verified deviation from plan §4's session-cookie wording ────── * Plan §4 has the plugin route set/read the session cookie directly. That is diff --git a/packages/plugin/src/storefront/cart-routes.ts b/packages/plugin/src/storefront/cart-routes.ts index 5656d411..24bd1200 100644 --- a/packages/plugin/src/storefront/cart-routes.ts +++ b/packages/plugin/src/storefront/cart-routes.ts @@ -1,10 +1,11 @@ /** * Cart — PLUGIN-OWNED PUBLIC ROUTES (Phase 3 group E, plan §7 step E1, shape * per ADR-0003). The plugin holds no cart/stock state (plan §4 "Where cart - * state lives"): every route here is a straight proxy over `ctx.http` to - * `@otta-sh/service`'s `/carts` REST surface — validate input → `HttpCommerceClient` - * call → serialize the (already-typed) result. No cart truth is duplicated - * or cached in the plugin. + * state lives"): every route here validates input → calls the in-process + * `CommerceClient` from `makeCommerceClient` → serializes the (already-typed) + * result — the shape the plugin's own `/carts` handling used to reach over + * `ctx.http` before the commerce service was folded in. No cart truth is + * duplicated or cached in the plugin. * * ── Platform-verified deviation from plan §4's literal wording ──────────── * Plan §4 says "The plugin storefront route sets [cartId] as a cookie: @@ -305,7 +306,7 @@ export function createCartLineAddRouteHandler(): RouteHandler { return (routeCtx, ctx): Promise> => renderGuard(STOREFRONT_CART_LINE_UPDATE_ROUTE, async () => { diff --git a/packages/plugin/src/storefront/checkout-route-input.ts b/packages/plugin/src/storefront/checkout-route-input.ts index 8271b781..2ff7dedb 100644 --- a/packages/plugin/src/storefront/checkout-route-input.ts +++ b/packages/plugin/src/storefront/checkout-route-input.ts @@ -3,11 +3,12 @@ * hand-rolled, no schema library in the plugin, because the routes are * reachable by anything that can POST to `/_emdash/api/plugins/otta/...`). * - * Everything here runs BEFORE any `ctx.http` egress: a garbage body must never - * become an upstream round trip, and certainly never an order. Bounds mirror - * `@otta-sh/service`'s own `checkoutBody` / `shippingAddressBody` - * (`packages/service/src/schemas.ts`) so a request this layer accepts is one - * the service will not reject on shape — the service re-validates regardless. + * Everything here runs BEFORE any commerce-client call: a garbage body must + * never become an in-process round trip, and certainly never an order. Bounds + * mirror the `checkoutBody` / `shippingAddressBody` schemas the standalone + * `@otta-sh/service` used to enforce before it was folded into the plugin, so + * a request this layer accepts is one the commerce client will not reject on + * shape — it re-validates regardless. * * `buyerRef` is checked for LENGTH only, never for format: the service * documents it as an "email/session claim token", and the *site* owns the diff --git a/packages/plugin/src/sync/parse-product-title.ts b/packages/plugin/src/sync/parse-product-title.ts index 17623df9..63f73fbf 100644 --- a/packages/plugin/src/sync/parse-product-title.ts +++ b/packages/plugin/src/sync/parse-product-title.ts @@ -1,6 +1,7 @@ -/** Mirrors `@otta-sh/service`'s `upsertProductCommerceBody.title` bound - * (`z.string().min(1).max(500)`) — the plugin declares no dependency on the - * service package, so the bound is restated here, not imported. */ +/** Mirrors the `upsertProductCommerceBody.title` bound + * (`z.string().min(1).max(500)`) that the standalone `@otta-sh/service` + * enforced before it was folded into the plugin; restated as a constant here + * since no schema package survives to import it from. */ const TITLE_MAX_LENGTH = 500; /** The outcome of validating a product title: a value fit to send, or a diff --git a/packages/plugin/src/sync/variants.ts b/packages/plugin/src/sync/variants.ts index e80454e3..a37c1368 100644 --- a/packages/plugin/src/sync/variants.ts +++ b/packages/plugin/src/sync/variants.ts @@ -71,10 +71,11 @@ export const VARIANT_KEY_SUBFIELD = "key"; export const VARIANT_NAME_SUBFIELD = "name"; /** - * Mirrors `@otta-sh/service`'s `upsertProductVariantBody.title` bound - * (`z.string().min(1).max(500)`), restated rather than imported for the reason - * `parse-product-title.ts` restates its own: the plugin declares no dependency - * on the service package. + * Mirrors the `upsertProductVariantBody.title` bound + * (`z.string().min(1).max(500)`) that the standalone `@otta-sh/service` + * enforced before it was folded into the plugin, restated rather than + * imported for the reason `parse-product-title.ts` restates its own: no + * schema package survives to import it from. */ const NAME_MAX_LENGTH = 500; diff --git a/packages/plugin/src/webhooks/stripe-settle-route.ts b/packages/plugin/src/webhooks/stripe-settle-route.ts index e862dbe1..4f5db94a 100644 --- a/packages/plugin/src/webhooks/stripe-settle-route.ts +++ b/packages/plugin/src/webhooks/stripe-settle-route.ts @@ -113,8 +113,9 @@ function isNonEmptyString(value: unknown): value is string { } /** - * The `SettleResult` → status/reason table, mirrored EXACTLY from - * `packages/service/src/routes/webhooks.ts`: + * The `SettleResult` → status/reason table, mirrored EXACTLY from the + * standalone `@otta-sh/service`'s webhook route before it was folded into + * the plugin: * * - settled (or an idempotent no-op) ⇒ 200, so Stripe stops retrying; * - INVALID_SIGNATURE / MALFORMED / UNKNOWN_EVENT ⇒ 400; diff --git a/packages/plugin/test/commerce-client-contract.http.test.ts b/packages/plugin/test/commerce-client-contract.http.test.ts deleted file mode 100644 index 31cc03db..00000000 --- a/packages/plugin/test/commerce-client-contract.http.test.ts +++ /dev/null @@ -1,276 +0,0 @@ -/** - * The HTTP tier of `commerceClientContract` (work order 02, INC-A7 / D7 tier - * T5(a)). - * - * This file binds the transport-agnostic contract to `HttpCommerceClient` and - * the four admin HTTP clients over a LIVE `@otta-sh/service` (Postgres-backed) - * — the same harness the eight source client test files used, and the same - * `PG_CONNECTION_STRING` gating, so a run without a database skips exactly what - * it skipped before. - * - * It REPLACES the transport-agnostic cases of - * `http-commerce-client.test.ts`, `http-commerce-client-cart.test.ts` and - * `admin-rules-client.test.ts`. Each of those files keeps only its HTTP-wire - * cases (request shape, headers, base-URL joining, status→error mapping); see - * `test/contracts/README.md` for the classification rule. - * - * ⚠ THIS FILE IS DELETED AT INC-D3b together with the HTTP transport. The - * contract it invokes is what survives — INC-B10a/b/c add a second tier that - * runs the very same cases with no HTTP anywhere. - */ -import { afterAll, describe, expect, test } from "vitest"; -import { AdminOrdersClient } from "../src/admin/admin-orders-client.js"; -import { AdminProductsClient } from "../src/admin/admin-products-client.js"; -import { AdminRulesClient } from "../src/admin/admin-rules-client.js"; -import { ReportingSettingsClient } from "../src/admin/reporting-client.js"; -import type { CommerceClient } from "../src/product-commerce/commerce-client.js"; -import { HttpCommerceClient } from "../src/product-commerce/http-commerce-client.js"; -import { - adminOrdersProductsClientContract, - adminRulesReportingClientContract, - type AdminClientSurfaces, - type CommerceClientTier, - storefrontCommerceClientContract, -} from "./contracts/commerce-client-contract.js"; -import { sharedTierSeeders } from "./helpers/commerce-tier-arrange.js"; -import { startLiveService, type LiveService } from "./helpers/start-live-service.js"; - -const PG = process.env.PG_CONNECTION_STRING; - -/** The admin gate + write gate secrets the admin tier's service enforces — - * exactly the pair `admin-rules-client.test.ts` booted its service with. */ -const ADMIN_TOKEN = "admin-secret"; -const ADMIN_SERVICE_TOKEN = "svc-secret"; - -interface HttpTierOptions { - name: string; - /** Boot the service with the admin + write gates closed, and thread both - * tokens into every client. Omitted ⇒ a gate-open service and tokenless - * clients, which is how the storefront client tests always ran. */ - gated?: boolean; -} - -/** - * The HTTP tier. `arrange` programs backend state the way the source files - * already did — through the client's own writes against the live service — so - * nothing here is invented: `arrange.product` is their `seedProduct` / - * `parentProduct` helpers, and `arrange.cart` is their `createCart` setup call. - */ -function httpTier(options: HttpTierOptions): CommerceClientTier { - let service: LiveService | undefined; - let client: CommerceClient | undefined; - - function baseOptions(): { fetch: typeof globalThis.fetch; baseUrl: string } { - if (service === undefined) throw new Error("tier not set up"); - return { fetch: globalThis.fetch, baseUrl: service.baseUrl }; - } - - function serviceOrThrow(): LiveService { - if (service === undefined) throw new Error("tier not set up"); - return service; - } - - /** - * A real login, end to end over the wire and through a CAPTURING MAIL SENDER. - * - * The magic link's token is in the mail and nowhere else — the request reply is - * deliberately generic, so that an attacker cannot learn from it whether an - * account exists — which means the only honest way to hold the token a shopper - * would have received is to capture the message. The service is started with a - * sender that records instead of delivering, and this reads the last recorded - * login message. Both calls go through the CLIENT, so the case is exercising the - * transport rather than a shortcut around it. - */ - async function login(email: string): Promise<{ bearer: string; customerId?: string }> { - const live = serviceOrThrow(); - const c = await clientOrThrow(); - await c.requestLoginLink(email); - // BY RECIPIENT AS WELL AS TEMPLATE. The capture is cumulative for the whole - // slice, so "the last login message" is whichever case logged in most recently — - // which would hand this call another shopper's challenge and mint a session for - // the wrong customer. A case asserting cross-customer isolation would then be - // comparing one customer against themselves, and would pass while proving nothing. - const captured = live.emailSender.sends.filter( - (sent) => sent.template === "customer-login-link" && String(sent.to) === email, - ); - const last = captured[captured.length - 1]; - if (last === undefined) { - throw new Error(`arrange.session: no login message was dispatched to ${email}`); - } - const challengeId = last.data["challengeId"]; - const token = last.data["token"]; - if (typeof challengeId !== "string" || typeof token !== "string") { - throw new Error("arrange.session: the captured login message carries no challenge"); - } - const verified = await c.verifyLogin(challengeId, token); - if (!verified.ok) throw new Error(`arrange.session: login failed (${verified.reason})`); - const customerId = await live.stores.sessionStore.validate(verified.sessionToken); - return { - bearer: verified.sessionToken, - ...(customerId === null ? {} : { customerId }), - }; - } - - async function clientOrThrow(): Promise { - if (client === undefined) throw new Error("tier not set up"); - return client; - } - - return { - name: options.name, - async setup() { - if (service !== undefined) return; // one service per tier, however many slices ask - service = await startLiveService( - options.gated === true - ? { internalToken: ADMIN_TOKEN, serviceToken: ADMIN_SERVICE_TOKEN } - : {}, - ); - client = new HttpCommerceClient({ - ...baseOptions(), - ...(options.gated === true ? { serviceToken: ADMIN_SERVICE_TOKEN } : {}), - }); - }, - async teardown() { - if (service === undefined) return; - await service.stop(); - service = undefined; - client = undefined; - }, - async reset() { - // A DOCUMENTED NO-OP for this tier. The live service owns one isolated - // Postgres schema for the whole slice and the lifted cases address - // disjoint product ids, skus, cart ids and idempotency keys — which is - // how they always ran. Dropping and re-migrating a schema per case - // would be a behavioural change (and minutes of runtime) for no gained - // assertion. A tier whose backend is cheap to rebuild does the real - // thing here instead. - }, - makeClient: clientOrThrow, - async makeAdminClients(): Promise { - const shared = { - ...baseOptions(), - ...(options.gated === true - ? { adminToken: ADMIN_TOKEN, serviceToken: ADMIN_SERVICE_TOKEN } - : {}), - }; - return { - orders: new AdminOrdersClient(shared), - products: new AdminProductsClient(shared), - rules: new AdminRulesClient(shared), - reporting: new ReportingSettingsClient(shared), - }; - }, - // NO `clock` HOOK, and the reason is structural rather than an omission: this - // tier stands ONE service, with one clock, for the whole slice, and its - // `reset()` is a documented no-op — so winding that clock forward would expire - // every other case's holds with no way to put them back. The one case whose - // subject is an elapsed deadline therefore skips here and runs on a tier whose - // backend is cheap to rebuild, saying so in its own name. - // - // `payments` IS declared: this tier composes a gateway that mints an intent - // with no network call, so a checkout genuinely succeeds on it. - payments: { method: "stripe" }, - arrange: { - ...sharedTierSeeders({ - get orderStore() { - return serviceOrThrow().stores.orderStore; - }, - get addressStore() { - return serviceOrThrow().stores.addressStore; - }, - get sessionStore() { - return serviceOrThrow().stores.sessionStore; - }, - get shippingRules() { - return serviceOrThrow().stores.shippingRules; - }, - get couponStore() { - return serviceOrThrow().stores.couponStore; - }, - get taxRules() { - return serviceOrThrow().stores.taxRules; - }, - }), - session: login, - async product(spec) { - const c = await clientOrThrow(); - await c.upsertProductCommerce( - spec.productId, - { - sku: spec.sku, - ...(spec.price !== undefined ? { price: spec.price } : {}), - ...(spec.title !== undefined ? { title: spec.title } : {}), - ...(spec.onHand !== undefined ? { initialOnHand: spec.onHand } : {}), - }, - spec.idempotencyKey, - ); - return spec.productId; - }, - async cart(currency) { - const c = await clientOrThrow(); - const { cartId } = await c.createCart(currency); - return cartId; - }, - }, - }; -} - -const storefront = httpTier({ name: "http, live @otta-sh/service, Postgres" }); -const admin = httpTier({ name: "http, live @otta-sh/service, Postgres", gated: true }); - -describe.skipIf(PG === undefined)("commerceClientContract over HttpCommerceClient", () => { - afterAll(async () => { - await storefront.teardown(); - }); - storefrontCommerceClientContract(storefront); -}); - -describe.skipIf(PG === undefined)("commerceClientContract over the admin HTTP clients", () => { - afterAll(async () => { - await admin.teardown(); - }); - adminRulesReportingClientContract(admin); - // Bound to the GATED tier like its sibling slice: the admin surface needs both - // the admin gate and the write gate closed, or its cases would be written - // against a service that never enforces them. - adminOrdersProductsClientContract(admin); - - /** - * THE HALF OF THE ORDER-SEARCH DIVERGENCE THIS TIER OWNS (ADR-0019 §6). - * - * The shared slice asserts the FLOOR — an id prefix, a folded buyer-ref prefix, - * an exact folded line sku — and asserts no negative, because this dialect - * answers MORE than the floor and the in-process one does not. Postgres plans - * the buyer-ref half as an unanchored `like '%q%'`, a SANCTIONED superset - * (ratified 2026-09-13), so a fragment from the middle of an address finds the - * order HERE and misses THERE. Neither is a defect and neither can be shared: - * the two tiers give opposite answers to the identical call, so each pins its - * own half in its own file — the miss lives in - * `commerce-client-contract.in-process.test.ts`. - * - * It runs on the SAME tier instance the slice above uses (its `setup()` is - * idempotent and its service is torn down by the `afterAll` overhead), so it - * costs no second service. - */ - describe("http admin orders: search ALSO matches a buyer-ref substring (a sanctioned superset)", () => { - test("a fragment from the middle of the buyer ref finds the order", async () => { - await admin.setup(); - if (admin.makeAdminClients === undefined) { - throw new Error("the admin tier composes no admin clients"); - } - const surfaces = await admin.makeAdminClients(); - const orders = surfaces.orders; - if (orders === undefined) throw new Error("the admin orders surface is not composed"); - await admin.arrange.order({ orderId: "div-o-1", buyerRef: "marguerite@example.test" }); - - // The floor, met here too. - expect((await orders.listOrders({ search: "MARGUER" })).orders.map((o) => o.id)).toEqual([ - "div-o-1", - ]); - // And the superset this dialect gives for free. - const midString = await orders.listOrders({ search: "guerite@" }); - expect(midString.orders.map((o) => o.id)).toEqual(["div-o-1"]); - expect(midString.total).toBe(1); - }); - }); -}); diff --git a/packages/plugin/test/contracts/commerce-client-contract.ts b/packages/plugin/test/contracts/commerce-client-contract.ts index 3b1b4545..b1ce98d3 100644 --- a/packages/plugin/test/contracts/commerce-client-contract.ts +++ b/packages/plugin/test/contracts/commerce-client-contract.ts @@ -2,22 +2,22 @@ * `commerceClientContract` — the transport-agnostic client contract (work order * 02, INC-A7 / D6 / D7 tier T5). * - * WHAT THIS IS. The behavioural spec of the commerce client surface, lifted out - * of the HTTP client's own test files so it can be run against a SECOND - * transport. Every case here is shaped as *arrange backend state* → *call a - * client method* → *assert the returned value or the typed rejection*. Nothing - * in this file knows how the call travels: no URLs, no headers, no status codes, - * no request recording. Those assertions are real and they are kept — they live - * in the HTTP tier's own file, which is the transport's file and dies with the - * transport. + * WHAT THIS IS. The behavioural spec of the commerce client surface. Every case + * here is shaped as *arrange backend state* → *call a client method* → *assert + * the returned value or the typed rejection*. Nothing in this file knows how the + * call travels: no URLs, no headers, no status codes, no request recording. * - * WHY IT EXISTS. The HTTP client's test body *is* the spec (D6, "Why keep a flag - * at all"). INC-B10a/b/c build an in-process client and must prove it - * behaviourally identical before INC-D3b deletes the HTTP one. A spec that only - * one transport can execute cannot do that. So the spec moves here and the - * implementation-specific residue stays behind. + * WHY IT EXISTS, AND WHY IT OUTLIVED ITS OCCASION. It was lifted out of the HTTP + * client's own test files so the in-process client could be proved behaviourally + * identical BEFORE the HTTP one was deleted — a spec only one transport can + * execute cannot do that. INC-D3b has now deleted the HTTP tier, so the + * in-process tier is the only one left and + * `commerce-client-contract.in-process.test.ts` is the only file that runs this. + * The spec stays separate from that runner anyway: it is the port's behavioural + * contract, and keeping it free of any one implementation's construction detail + * is what would let a second implementation be held to it again. * - * THREE SLICES, one per consuming increment: + * THREE SLICES, one per increment that consumed it: * - `storefrontCommerceClientContract` → INC-B10a (`CommerceClient`) * - `adminOrdersProductsClientContract` → INC-B10b (orders + products) * - `adminRulesReportingClientContract` → INC-B10c (rules + reporting) @@ -36,26 +36,27 @@ */ import { beforeAll, beforeEach, describe, expect, test } from "vitest"; -import type { AdminOrdersClient } from "../../src/admin/admin-orders-client.js"; -import type { AdminProductsClient } from "../../src/admin/admin-products-client.js"; -import type { AdminRulesClient } from "../../src/admin/admin-rules-client.js"; -import type { ReportingSettingsClient } from "../../src/admin/reporting-client.js"; +import type { AdminOrdersSurface } from "../../src/admin/admin-orders-surface.js"; +import type { AdminProductsSurface } from "../../src/admin/admin-products-surface.js"; +import type { AdminRulesSurface } from "../../src/admin/admin-rules-surface.js"; +import type { ReportingSettingsSurface } from "../../src/admin/reporting-settings-surface.js"; import type { CommerceClient, CommerceMoney } from "../../src/product-commerce/commerce-client.js"; // ── The tier interface ──────────────────────────────────────────────────── // -// A "tier" is one transport plus the means to seed state behind it. The four -// admin surfaces are named by `Pick<…>` of the classes that implement them -// TODAY purely to borrow their method signatures — this file never constructs -// one, and at INC-D3b the `Pick` targets swap to the in-process classes while -// every case below stays put. - -/** The admin orders surface the contract exercises — the class's WHOLE public - * surface, named method by method, for the same reason products is: INC-B10b-ii - * folds all twelve in-process, and a surface that listed fewer would let one be +// A "tier" is one implementation plus the means to seed state behind it. The +// four admin surfaces are named by `Pick<…>` of the PORTS in `src/admin/*- +// surface.ts` purely to borrow their method signatures — this file never +// constructs one. Restating each method here rather than aliasing the port whole +// is what keeps the cases below and the port from drifting apart silently: a +// method added to a port is not exercised until it is named here too. + +/** The admin orders surface the contract exercises — the port's WHOLE surface, + * named method by method, for the same reason products is: all twelve are + * implemented in-process, and a surface that listed fewer would let one be * forgotten silently. */ export type OrdersClientSurface = Pick< - AdminOrdersClient, + AdminOrdersSurface, | "listOrders" | "getOrder" | "transitionOrder" @@ -69,19 +70,19 @@ export type OrdersClientSurface = Pick< | "listNotes" | "addNote" >; -/** The admin products surface the contract exercises — the class's WHOLE public - * surface, named method by method, because INC-B10b-i folds all six in-process +/** The admin products surface the contract exercises — the port's WHOLE + * surface, named method by method, because all six are implemented in-process * and a surface that listed fewer would let one be forgotten silently. */ export type ProductsClientSurface = Pick< - AdminProductsClient, + AdminProductsSurface, "updateProduct" | "restock" | "removeStock" | "listProducts" | "getProduct" | "getTaxClasses" >; /** The rules surface the contract exercises (shipping, tax, coupons) — the - * class's WHOLE public surface, all twenty-five methods named one by one, - * because INC-B10c-i folds all twenty-five in-process and a surface that listed - * fewer would let one be forgotten silently. */ + * port's WHOLE surface, all twenty-five methods named one by one, because all + * twenty-five are implemented in-process and a surface that listed fewer would + * let one be forgotten silently. */ export type RulesClientSurface = Pick< - AdminRulesClient, + AdminRulesSurface, | "listZones" | "createZone" | "updateZone" @@ -112,12 +113,13 @@ export type RulesClientSurface = Pick< * The reporting + settings surface, in full (work order 02, INC-B10c-ii). * * EVERY METHOD IS LISTED, for the same reason `RulesClientSurface` lists all - * twenty-five: adding a method to `ReportingSettingsClient` without deciding what - * the in-process tier does about it has to be a COMPILE error here, not a gap - * discovered when a console screen is bound to a tier that cannot serve it. + * twenty-five: adding a method to `ReportingSettingsSurface` without deciding + * what the implementation does about it has to be a COMPILE error here, not a + * gap discovered when a console screen is bound to a client that cannot serve + * it. */ export type ReportingClientSurface = Pick< - ReportingSettingsClient, + ReportingSettingsSurface, | "getRevenue" | "getOrdersByStatus" | "getTopProducts" @@ -1790,10 +1792,10 @@ export function storefrontCommerceClientContract(tier: CommerceClientTier): void /** * NO CASE HERE WAS LIFTED, and that is the finding rather than an oversight: no - * test file in `packages/plugin/test/` ever exercised `AdminOrdersClient` or - * `AdminProductsClient`. There was nothing to move, so the cases below are - * written against the tier interface from the start and run on both transports - * for free. + * test file in `packages/plugin/test/` ever exercised the old HTTP admin orders + * or products client. There was nothing to move, so the cases below were written + * against the tier interface from the start — which is why they cost nothing + * when the HTTP tier was deleted. * * PRODUCTS IS COVERED (INC-B10b-i) — all six methods: `listProducts`, * `getProduct`, `updateProduct`, `restock`, `removeStock`, `getTaxClasses`. diff --git a/packages/plugin/test/helpers/start-live-service.ts b/packages/plugin/test/helpers/start-live-service.ts deleted file mode 100644 index 6ad9b99b..00000000 --- a/packages/plugin/test/helpers/start-live-service.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { serve } from "@hono/node-server"; -import type { - AddressStore, - CouponStore, - OrderStore, - SessionStore, - ShippingRulesStore, - TaxRulesStore, -} from "@otta-sh/domain"; -import { FakeEmailSender, FixedClock } from "@otta-sh/domain/testing"; -import { StripePaymentGateway } from "@otta-sh/payments-stripe"; -import { createApp } from "@otta-sh/service/app"; -import { - KyselyAddressStore, - KyselyCartStore, - KyselyCouponStore, - KyselyCredentialVerifier, - KyselyCustomerStore, - KyselyEntitlementStore, - KyselyInventoryStore, - KyselyOrderNotesStore, - KyselyOrderStore, - KyselyPaymentEventStore, - KyselyProductCommerceStore, - KyselyReportingStore, - KyselySessionStore, - KyselySettingsStore, - KyselyShippingRulesStore, - KyselyTaxRulesStore, - uuidIdGen, -} from "@otta-sh/store-postgres"; -import { createIsolatedPgSchema } from "@otta-sh/store-postgres/testing"; - -/** The Stripe webhook signing secret the live test service verifies against. */ -export const LIVE_STRIPE_WEBHOOK_SECRET = "whsec_plugin_live_test"; - -/** - * The stores a caller needs in order to SEED state this service's own client - * surface cannot write — a guest order, a customer's address, a shipping rule, a - * coupon. Every one of them is a `@otta-sh/domain` PORT, and that is the point: - * the other transport's harness seeds the very same ports over its own adapters, - * so a shared case's arrangement is identical on both and a difference in outcome - * can only come from the transport under test. - */ -export interface LiveServiceStores { - orderStore: OrderStore; - addressStore: AddressStore; - sessionStore: SessionStore; - shippingRules: ShippingRulesStore; - couponStore: CouponStore; - /** The tax-class + tax-rate registry. Exposed so a tier can seed a tax class - * through the PORT — the admin products contract needs one to exist before it - * can assert that `getTaxClasses` returns the registry. */ - taxRules: TaxRulesStore; -} - -export interface LiveService { - baseUrl: string; - host: string; - /** The in-memory email sender — the account tests read the emitted magic-link - * token from here to complete a login over the wire. */ - emailSender: FakeEmailSender; - /** See {@link LiveServiceStores}. The same instances the app was built with, - * so a seeded row is a row this service reads. */ - stores: LiveServiceStores; - /** The X-Internal-Token the service accepts (undefined ⇒ guarded admin routes - * answer 503). Exposed so the admin-orders live-client test can drive the - * guarded `/admin/orders` reads. */ - internalToken: string | undefined; - /** The X-Service-Token the write gate enforces (undefined ⇒ gate OPEN). Exposed - * so the ADR-0007 contract test can drive the client with a matching token. */ - serviceToken: string | undefined; - stop(): Promise; -} - -export interface StartLiveServiceOptions { - /** Enable the guarded admin surface with this X-Internal-Token. Omitted ⇒ the - * internal endpoints stay DISABLED (503), preserving the prior behavior. */ - internalToken?: string; - /** Enable the write gate (ADR-0007) with this X-Service-Token. Omitted ⇒ the - * gate stays OPEN (every non-GET passes), preserving the prior behavior. */ - serviceToken?: string; -} - -/** - * Boots the REAL `@otta-sh/service` (`createApp`) on an ephemeral port, - * Postgres-backed in an isolated schema — mirrors - * `packages/service/test/helpers/start-test-server.ts` (Phase 0 §0.6), used - * here so `HttpCommerceClient` (plan §6 step 6) is proven against the real - * wire, not a hand-rolled stub. - */ -export async function startLiveService( - options: StartLiveServiceOptions = {}, -): Promise { - const connectionString = process.env.PG_CONNECTION_STRING; - if (connectionString === undefined) throw new Error("PG_CONNECTION_STRING is not set"); - const iso = await createIsolatedPgSchema(connectionString, { poolMax: 8 }); - const db = iso.db; - const clock = new FixedClock(new Date("2026-07-10T00:00:00.000Z")); - - const store = new KyselyInventoryStore({ db, idGen: uuidIdGen, clock }); - const productCommerce = new KyselyProductCommerceStore({ db, clock }); - // Phase 3 grew AppDeps with the cart surface; the plugin exercises only the - // product routes here, but the real app wires everything. - const cartStore = new KyselyCartStore({ db, idGen: uuidIdGen, clock }); - const orderStore = new KyselyOrderStore({ db, idGen: uuidIdGen, clock }); - const orderNotesStore = new KyselyOrderNotesStore({ db, idGen: uuidIdGen, clock }); - const entitlementStore = new KyselyEntitlementStore({ db, idGen: uuidIdGen, clock }); - const paymentEventStore = new KyselyPaymentEventStore({ db, idGen: uuidIdGen }); - const customerStore = new KyselyCustomerStore({ db, idGen: uuidIdGen, clock }); - const addressStore = new KyselyAddressStore({ db, idGen: uuidIdGen, clock }); - const sessionStore = new KyselySessionStore({ db, idGen: uuidIdGen, clock }); - const credentialVerifier = new KyselyCredentialVerifier({ - db, - customerStore, - idGen: uuidIdGen, - clock, - }); - const emailSender = new FakeEmailSender(); - const shippingRules = new KyselyShippingRulesStore({ db }); - const couponStore = new KyselyCouponStore({ db, idGen: uuidIdGen, clock }); - const taxRules = new KyselyTaxRulesStore({ db }); - const app = createApp({ - store, - productCommerce, - cartStore, - orderStore, - orderNotesStore, - entitlementStore, - paymentEventStore, - shippingRules, - taxRules, - couponStore, - reportingStore: new KyselyReportingStore({ db, dialect: "postgres" }), - settingsStore: new KyselySettingsStore({ db, clock }), - customerStore, - addressStore, - sessionStore, - credentialVerifier, - emailSender, - idGen: uuidIdGen, - gateways: { stripe: new StripePaymentGateway({ webhookSecret: LIVE_STRIPE_WEBHOOK_SECRET }) }, - clock, - ...(options.internalToken !== undefined ? { internalToken: options.internalToken } : {}), - ...(options.serviceToken !== undefined ? { serviceToken: options.serviceToken } : {}), - }); - - const server = await new Promise>((resolve) => { - const s = serve({ fetch: app.fetch, port: 0 }, () => resolve(s)); - }); - const address = server.address(); - const port = typeof address === "object" && address !== null ? address.port : 0; - - return { - baseUrl: `http://127.0.0.1:${port}`, - host: "127.0.0.1", - emailSender, - stores: { orderStore, addressStore, sessionStore, shippingRules, couponStore, taxRules }, - internalToken: options.internalToken, - serviceToken: options.serviceToken, - async stop() { - await new Promise((resolve, reject) => { - server.close((err: Error | undefined) => (err ? reject(err) : resolve())); - }); - await iso.teardown(); - }, - }; -} diff --git a/packages/plugin/test/helpers/stub-commerce-server.ts b/packages/plugin/test/helpers/stub-http-server.ts similarity index 71% rename from packages/plugin/test/helpers/stub-commerce-server.ts rename to packages/plugin/test/helpers/stub-http-server.ts index c71e2874..dac8a67a 100644 --- a/packages/plugin/test/helpers/stub-commerce-server.ts +++ b/packages/plugin/test/helpers/stub-http-server.ts @@ -9,7 +9,7 @@ export interface RecordedRequest { export type StubResponder = (req: RecordedRequest) => { status: number; body: unknown }; -export interface StubCommerceServer { +export interface StubHttpServer { baseUrl: string; /** Hostname only (no port) — `ctx.http`'s allowedHosts check matches on * `new URL(url).hostname`, which never includes the port. */ @@ -19,10 +19,20 @@ export interface StubCommerceServer { close(): Promise; } -/** A tiny hand-rolled HTTP stub standing in for `@otta-sh/service` (plan §6 - * step 1) — records every request it receives and replies per a - * test-configured responder. */ -export async function startStubCommerceServer(): Promise { +/** + * A tiny hand-rolled, GENERIC recording HTTP server for tests — it records every + * request it receives and replies per a test-configured responder, and it cares + * nothing about what the endpoint is supposed to be. + * + * WHAT IT IS FOR NOW: standing in for an ARBITRARY external host so a sandbox + * test can prove the `ctx.http` egress rules. It backs the email API in the + * `allowedHosts` harness test, and a Settings re-render in the Stripe settle + * route test that asserts no secret leaks outbound. It once stood in for the + * separate commerce service as well; that service is gone, and these uses are + * not, which is why the helper is named for what it does rather than for who it + * used to impersonate. + */ +export async function startStubHttpServer(): Promise { const requests: RecordedRequest[] = []; const responders = new Map(); diff --git a/packages/plugin/test/http-commerce-client-cart-order-id.test.ts b/packages/plugin/test/http-commerce-client-cart-order-id.test.ts deleted file mode 100644 index 3d6b02a8..00000000 --- a/packages/plugin/test/http-commerce-client-cart-order-id.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { HttpCommerceClient } from "../src/product-commerce/http-commerce-client.js"; - -// Issue #132 — the ONE place the `cart.orderId` guarantee is pinned. -// -// Nothing on this path validates the cart body at runtime: `#cartResult` -// blind-casts as soon as `isCartEnvelope` has seen an object with an `ok` key. -// A field the service stops emitting therefore arrives as `undefined`, fully -// type-checked, and `undefined !== null` is TRUE — so an un-normalized consumer -// renders `/orders/undefined`, a dead link offered as a primary action. `""` is -// the same failure with a different URL (`/orders/`). -// -// `getCart` coerces all three shapes to `null`. Stub fetch (no service, no PG) -// so the guarantee is provable without a live server. - -const BASE = "https://commerce.test"; - -/** A stub service whose `GET /carts/:id` returns exactly `body`. */ -function clientReturningBody(body: Record): HttpCommerceClient { - const fetch = async (): Promise => - new Response(JSON.stringify(body), { - status: 200, - headers: { "content-type": "application/json" }, - }); - return new HttpCommerceClient({ fetch, baseUrl: BASE }); -} - -/** A stub service whose `GET /carts/:id` returns exactly `cart`. */ -function clientReturning(cart: Record): HttpCommerceClient { - return clientReturningBody({ ok: true, cart }); -} - -const BODY = { cartId: "c1", state: "active", currency: "USD", lines: [] }; - -describe("HttpCommerceClient.getCart normalizes cart.orderId (#132)", () => { - test("an OMITTED orderId (an older deployed service) reads as null, never undefined", async () => { - const result = await clientReturning(BODY).getCart("c1"); - expect(result.ok).toBe(true); - if (!result.ok) return; - expect(result.cart.orderId).toBeNull(); - // Not merely falsy: the strict identity is the whole point, because - // `undefined !== null` would sail through a consumer's `!== null` fence. - expect(Object.is(result.cart.orderId, null)).toBe(true); - }); - - test("an EMPTY-STRING orderId reads as null (it would render `/orders/`)", async () => { - const result = await clientReturning({ ...BODY, orderId: "" }).getCart("c1"); - expect(result.ok).toBe(true); - if (!result.ok) return; - expect(result.cart.orderId).toBeNull(); - }); - - test("a NON-STRING orderId reads as null", async () => { - const result = await clientReturning({ ...BODY, orderId: 42 }).getCart("c1"); - expect(result.ok).toBe(true); - if (!result.ok) return; - expect(result.cart.orderId).toBeNull(); - }); - - // The coercion is TOTAL, `cart` included. `isCartEnvelope` never checked for - // a `cart` key, so a success envelope without a usable one must keep behaving - // exactly as it did before this PR — passed through — rather than becoming a - // new TypeError thrown from inside the client. - test.each([ - ["no cart key at all", { ok: true }], - ["a null cart", { ok: true, cart: null }], - ["a non-object cart", { ok: true, cart: "nope" }], - ])("a success envelope with %s is passed through, never a thrown TypeError", async (_l, body) => { - const result = await clientReturningBody(body as Record).getCart("c1"); - expect(result.ok).toBe(true); - }); - - test("a real order id is passed through untouched", async () => { - const result = await clientReturning({ - ...BODY, - state: "checked_out", - orderId: "order-abc", - }).getCart("c1"); - expect(result.ok).toBe(true); - if (!result.ok) return; - expect(result.cart.orderId).toBe("order-abc"); - expect(result.cart.state).toBe("checked_out"); - }); -}); diff --git a/packages/plugin/test/http-commerce-client-cart.test.ts b/packages/plugin/test/http-commerce-client-cart.test.ts deleted file mode 100644 index b4ba362e..00000000 --- a/packages/plugin/test/http-commerce-client-cart.test.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { afterAll, beforeAll, describe, expect, test } from "vitest"; -import { HttpCommerceClient } from "../src/product-commerce/http-commerce-client.js"; -import { startLiveService, type LiveService } from "./helpers/start-live-service.js"; - -const PG = process.env.PG_CONNECTION_STRING; - -/** - * HTTP-WIRE RESIDUE. The cart METHOD contract moved to - * `test/contracts/commerce-client-contract.ts` (INC-A7) and runs here through - * `commerce-client-contract.http.test.ts`. What stays is what is only - * expressible on the wire: - * - * - the HTTP STATUS `POST /checkout/quote` answers. The quote itself is a port - * method (`quoteCheckout`) and its computed totals and typed refusal reasons - * are asserted in the contract, against the client — an earlier revision of - * this file wrongly claimed the port had no quote method. What the client - * cannot show is the status code the route chose to carry the answer on, so - * each of these five cases is the wire half of a contract case and reaches - * past the client with a raw fetch for that one assertion. - * - a malformed request arriving as a structured `CommerceClientError` with - * its HTTP status. - * - * Deleted with the transport at INC-D3b. - */ -describe.skipIf(PG === undefined)( - "HttpCommerceClient cart methods [live @otta-sh/service, Postgres]", - () => { - let service: LiveService; - let client: HttpCommerceClient; - - beforeAll(async () => { - service = await startLiveService(); - client = new HttpCommerceClient({ fetch: globalThis.fetch, baseUrl: service.baseUrl }); - }); - afterAll(async () => { - await service.stop(); - }); - - /** Seed a `product_commerce` row keyed by its CMS content id (the productId - * join key), optionally priced. Returns the productId so a cart add can - * thread it, exactly as the storefront now does (issue #80). */ - async function seedProduct(opts: { - sku: string; - onHand: number; - price?: { amount: number; currency: string }; - }): Promise { - const productId = `prod-for-${opts.sku}`; - await client.upsertProductCommerce( - productId, - { - sku: opts.sku, - ...(opts.price !== undefined ? { price: opts.price } : {}), - initialOnHand: opts.onHand, - }, - `seed-${opts.sku}`, - ); - return productId; - } - - /** Raw `POST /checkout/quote`, for its STATUS only — the port's - * `quoteCheckout` hands back `{ ok, breakdown }` / `{ ok:false, reason }` - * and never the status code, so the status is reachable only here. This is - * also the exact call the issue-#80 repro made against a live cart. */ - async function quote(cartId: string): Promise<{ status: number }> { - const res = await globalThis.fetch(`${service.baseUrl}/checkout/quote`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ cartId }), - }); - return { status: res.status }; - } - - test("a storefront cart for a priced+active product QUOTES on the wire with status 200 (issue #80 repro)", async () => { - const productId = await seedProduct({ - sku: "SKU-QUOTE-OK", - onHand: 10, - price: { amount: 1500, currency: "USD" }, - }); - const { cartId } = await client.createCart("USD"); - const added = await client.addCartLine(cartId, "SKU-QUOTE-OK", productId, 2, "quote-ok-1"); - if (!added.ok) throw new Error("unreachable"); - - // The computed totals are the contract's ("a priced cart QUOTES computed - // totals…"); the status the route carries them on is only visible here. - const q = await quote(cartId); - expect(q.status).toBe(200); - }); - - // The wire half of the contract's "no false positive: an UNPRICED product - // … is refused PRODUCT_NOT_PRICED at the ADD" case, which asserts the - // CART_EMPTY reason through `quoteCheckout`. The status is what is left. - test("an UNPRICED product refused at the ADD leaves nothing for the quote: the wire status is 409", async () => { - const productId = await seedProduct({ sku: "SKU-UNPRICED-Q", onHand: 5 }); // no price - const { cartId } = await client.createCart("USD"); - await client.addCartLine(cartId, "SKU-UNPRICED-Q", productId, 1, "unpriced-q-1"); - - const q = await quote(cartId); - expect(q.status).toBe(409); - }); - - // The wire half of the contract's "a legacy add with NO productId (absent) - // is preserved as null and still quotes PRODUCT_NOT_PRICED" case. - test("a legacy add with NO productId quotes PRODUCT_NOT_PRICED: the wire status is 409", async () => { - await seedProduct({ - sku: "SKU-LEGACY-Q", - onHand: 5, - price: { amount: 1500, currency: "USD" }, - }); - const { cartId } = await client.createCart("USD"); - const added = await client.addCartLine(cartId, "SKU-LEGACY-Q", null, 1, "legacy-q-1"); - if (!added.ok) throw new Error("unreachable"); - - const q = await quote(cartId); - expect(q.status).toBe(409); - }); - - // The wire half of the contract's SKU_MISMATCH security case: the refused - // add leaves an empty cart, so the attack never reaches a priced checkout. - test("SECURITY (issue #80 review): a mismatched sku/productId pair never reaches checkout: the wire status is 409", async () => { - const cheapId = await seedProduct({ - sku: "SKU-CHEAP-Q", - onHand: 10, - price: { amount: 100, currency: "USD" }, - }); - await seedProduct({ - sku: "SKU-PRICEY-Q", - onHand: 10, - price: { amount: 100000, currency: "USD" }, - }); - const { cartId } = await client.createCart("USD"); - await client.addCartLine(cartId, "SKU-PRICEY-Q", cheapId, 1, "mismatch-q-1"); - - const q = await quote(cartId); - expect(q.status).toBe(409); - }); - - // The wire half of the contract's CURRENCY_MISMATCH quote case. - test("currency mismatch: a product priced in EUR in a USD cart quotes on the wire with status 409", async () => { - const productId = await seedProduct({ - sku: "SKU-EUR", - onHand: 5, - price: { amount: 1500, currency: "EUR" }, - }); - const { cartId } = await client.createCart("USD"); - const added = await client.addCartLine(cartId, "SKU-EUR", productId, 1, "eur-1"); - if (!added.ok) throw new Error("unreachable"); - - const q = await quote(cartId); - expect(q.status).toBe(409); - }); - - test("a genuinely malformed request (a bad qty) still surfaces as a structured CommerceClientError", async () => { - const { cartId } = await client.createCart(); - // qty: 0 fails the service's positive-int schema (400, no ok/reason - // envelope) — the client's #cartResult falls back to throwing here, - // exactly the "no recognizable envelope" branch. - await expect( - client.addCartLine(cartId, "SKU-X", null, 0, "bad-qty-key"), - ).rejects.toMatchObject({ - name: "CommerceClientError", - status: 400, - }); - }); - }, -); diff --git a/packages/plugin/test/http-commerce-client-checkout.test.ts b/packages/plugin/test/http-commerce-client-checkout.test.ts deleted file mode 100644 index 28f43140..00000000 --- a/packages/plugin/test/http-commerce-client-checkout.test.ts +++ /dev/null @@ -1,265 +0,0 @@ -/** - * A1 (storefront-checkout plan §3) — the three checkout methods on - * `HttpCommerceClient`, as a straight 1:1 mirror of `@otta-sh/service`'s - * `POST /checkout/quote`, `POST /checkout/orders` and `GET /orders/:orderId`. - * - * The load-bearing properties, none of which are visible from the happy path: - * - the two POSTs are non-GET, so they MUST carry `X-Service-Token` when one - * is configured or every checkout 401s once the write gate is closed; - * - `Idempotency-Key` is forwarded VERBATIM and never invented (a fresh key - * per attempt would mint a second order the `CART_CHECKED_OUT` fence then - * rejects with no way forward); - * - every typed failure — including the 502 `PAYMENT_INTENT_FAILED` — comes - * back as a `{ ok: false, reason }` value, never a thrown error, so callers - * branch on the token and never on an HTTP status (adapter rule #2); - * - `getPublicOrder` never sends `X-Internal-Token`: that header would unlock - * the full admin projection (`serializeOrder`, incl. `buyerRef` and the - * ship-to snapshot) on a page a guest reads. - */ -import { describe, expect, test } from "vitest"; -import { CommerceClientError } from "../src/product-commerce/commerce-client.js"; -import { HttpCommerceClient } from "../src/product-commerce/http-commerce-client.js"; - -const BASE = "https://commerce.test"; -const TOKEN = "SVC-TOKEN-abc123"; - -interface Recorded { - url: string; - init: RequestInit | undefined; -} - -function stub( - responses: { status: number; body: unknown }[], - serviceToken?: string, -): { client: HttpCommerceClient; requests: Recorded[] } { - const requests: Recorded[] = []; - let i = 0; - const fetch = async (url: string, init?: RequestInit): Promise => { - requests.push({ url, init }); - const next = responses[Math.min(i++, responses.length - 1)] ?? { status: 200, body: {} }; - return new Response(JSON.stringify(next.body), { - status: next.status, - headers: { "content-type": "application/json" }, - }); - }; - const client = new HttpCommerceClient({ - fetch, - baseUrl: BASE, - ...(serviceToken !== undefined ? { serviceToken } : {}), - }); - return { client, requests }; -} - -function header(init: RequestInit | undefined, name: string): string | undefined { - const headers = (init?.headers ?? {}) as Record; - for (const [k, v] of Object.entries(headers)) { - if (k.toLowerCase() === name.toLowerCase()) return v; - } - return undefined; -} - -function body(init: RequestInit | undefined): Record { - return JSON.parse(String(init?.body ?? "{}")) as Record; -} - -const BREAKDOWN = { - currency: "USD", - subtotalCents: 3998, - discountCents: 0, - shippingCents: 0, - taxCents: 0, - totalCents: 3998, - appliedCouponCode: null, -}; - -const ORDER = { - id: "order-1", - state: "pending", - currency: "USD", - paymentMethod: "stripe", - holdExpiresAt: "2099-01-01T00:00:00.000Z", - createdAt: "2026-07-27T00:00:00.000Z", - totals: { ...BREAKDOWN, shippingZoneId: null }, - lines: [], - fulfillment: null, - cancellation: null, -}; - -const INTENT = { - gateway: "stripe", - intentId: "pi_123", - clientAction: { kind: "stripe_client_secret", clientSecret: "pi_123_secret_xyz" }, -}; - -describe("HttpCommerceClient.quoteCheckout", () => { - test("POSTs /checkout/quote with the cart id and returns the breakdown", async () => { - const { client, requests } = stub([{ status: 200, body: { ok: true, breakdown: BREAKDOWN } }]); - const result = await client.quoteCheckout({ cartId: "cart-1" }); - - expect(requests).toHaveLength(1); - expect(requests[0]!.url).toBe(`${BASE}/checkout/quote`); - expect(requests[0]!.init?.method).toBe("POST"); - expect(body(requests[0]!.init)).toEqual({ cartId: "cart-1" }); - expect(result).toEqual({ ok: true, breakdown: BREAKDOWN }); - }); - - test("forwards X-Service-Token when configured (the quote is a non-GET the write gate blocks)", async () => { - const { client, requests } = stub( - [{ status: 200, body: { ok: true, breakdown: BREAKDOWN } }], - TOKEN, - ); - await client.quoteCheckout({ cartId: "cart-1" }); - expect(header(requests[0]!.init, "X-Service-Token")).toBe(TOKEN); - }); - - test("attaches NO X-Service-Token when none is configured (byte-identical to the pre-gate wire)", async () => { - const { client, requests } = stub([{ status: 200, body: { ok: true, breakdown: BREAKDOWN } }]); - await client.quoteCheckout({ cartId: "cart-1" }); - expect(header(requests[0]!.init, "X-Service-Token")).toBeUndefined(); - }); - - test("omits optional selection fields entirely rather than sending undefined/null", async () => { - const { client, requests } = stub([{ status: 200, body: { ok: true, breakdown: BREAKDOWN } }]); - await client.quoteCheckout({ cartId: "cart-1", couponCode: "SAVE10" }); - expect(body(requests[0]!.init)).toEqual({ cartId: "cart-1", couponCode: "SAVE10" }); - }); - - test.each([ - [404, "CART_NOT_FOUND"], - [409, "CART_EMPTY"], - [409, "PRODUCT_NOT_PRICED"], - [409, "CURRENCY_MISMATCH"], - [404, "COUPON_NOT_FOUND"], - ])( - "a %i quote failure surfaces as the typed reason %s, never a throw", - async (status, reason) => { - const { client } = stub([{ status, body: { ok: false, reason } }]); - await expect(client.quoteCheckout({ cartId: "cart-1" })).resolves.toEqual({ - ok: false, - reason, - }); - }, - ); -}); - -describe("HttpCommerceClient.createOrder", () => { - test("POSTs /checkout/orders with the checkout body and returns order + intent", async () => { - const { client, requests } = stub([ - { status: 201, body: { ok: true, order: ORDER, intent: INTENT } }, - ]); - const result = await client.createOrder( - { cartId: "cart-1", paymentMethod: "stripe", buyerRef: "Buyer@Example.com" }, - "checkout:cart-1", - ); - - expect(requests).toHaveLength(1); - expect(requests[0]!.url).toBe(`${BASE}/checkout/orders`); - expect(requests[0]!.init?.method).toBe("POST"); - expect(body(requests[0]!.init)).toEqual({ - cartId: "cart-1", - paymentMethod: "stripe", - buyerRef: "Buyer@Example.com", - }); - expect(result).toEqual({ ok: true, order: ORDER, intent: INTENT }); - }); - - test("forwards the caller's Idempotency-Key VERBATIM — never invents or rewrites one", async () => { - const { client, requests } = stub([ - { status: 201, body: { ok: true, order: ORDER, intent: INTENT } }, - ]); - await client.createOrder( - { cartId: "cart-1", paymentMethod: "stripe", buyerRef: "a@b.co" }, - "checkout:cart-1", - ); - expect(header(requests[0]!.init, "Idempotency-Key")).toBe("checkout:cart-1"); - }); - - test("forwards X-Service-Token when configured", async () => { - const { client, requests } = stub( - [{ status: 201, body: { ok: true, order: ORDER, intent: INTENT } }], - TOKEN, - ); - await client.createOrder( - { cartId: "cart-1", paymentMethod: "stripe", buyerRef: "a@b.co" }, - "k", - ); - expect(header(requests[0]!.init, "X-Service-Token")).toBe(TOKEN); - }); - - test("forwards the optional shipping address verbatim when present", async () => { - const { client, requests } = stub([ - { status: 201, body: { ok: true, order: ORDER, intent: INTENT } }, - ]); - const shippingAddress = { - name: "A Buyer", - line1: "1 Test St", - city: "Testville", - postalCode: "12345", - country: "Testland", - }; - await client.createOrder( - { cartId: "cart-1", paymentMethod: "stripe", buyerRef: "a@b.co", shippingAddress }, - "k", - ); - expect(body(requests[0]!.init)["shippingAddress"]).toEqual(shippingAddress); - }); - - test.each([ - [400, "INVALID_SHIPPING_ADDRESS"], - [404, "CART_NOT_FOUND"], - [404, "COUPON_NOT_FOUND"], - [409, "CART_EMPTY"], - [409, "CART_CHECKED_OUT"], - [409, "RESERVATION_LOST"], - [409, "PRODUCT_NOT_PRICED"], - [409, "CURRENCY_MISMATCH"], - [502, "PAYMENT_INTENT_FAILED"], - ])( - "a %i checkout failure surfaces as the typed reason %s, never a throw", - async (status, reason) => { - const { client } = stub([{ status, body: { ok: false, reason } }]); - await expect( - client.createOrder({ cartId: "c", paymentMethod: "stripe", buyerRef: "a@b.co" }, "k"), - ).resolves.toEqual({ ok: false, reason }); - }, - ); - - test("a body with NO typed envelope (a zod parse reject, a 500) still throws CommerceClientError", async () => { - const { client } = stub([{ status: 400, body: { error: "invalid request body", issues: [] } }]); - await expect( - client.createOrder({ cartId: "c", paymentMethod: "stripe", buyerRef: "a@b.co" }, "k"), - ).rejects.toBeInstanceOf(CommerceClientError); - }); -}); - -describe("HttpCommerceClient.getPublicOrder", () => { - test("GETs /orders/:orderId and returns the public projection", async () => { - const { client, requests } = stub([{ status: 200, body: { ok: true, order: ORDER } }]); - const result = await client.getPublicOrder("order-1"); - - expect(requests).toHaveLength(1); - expect(requests[0]!.url).toBe(`${BASE}/orders/order-1`); - expect(requests[0]!.init?.method).toBe("GET"); - expect(result).toEqual({ ok: true, order: ORDER }); - }); - - test("NEVER sends X-Internal-Token — that header would unlock the full admin projection", async () => { - const { client, requests } = stub([{ status: 200, body: { ok: true, order: ORDER } }], TOKEN); - await client.getPublicOrder("order-1"); - expect(header(requests[0]!.init, "X-Internal-Token")).toBeUndefined(); - }); - - test("percent-encodes the order id into the path", async () => { - const { client, requests } = stub([{ status: 200, body: { ok: true, order: ORDER } }]); - await client.getPublicOrder("a/b"); - expect(requests[0]!.url).toBe(`${BASE}/orders/a%2Fb`); - }); - - test("a 404 surfaces as the typed ORDER_NOT_FOUND, never a throw", async () => { - const { client } = stub([{ status: 404, body: { ok: false, reason: "ORDER_NOT_FOUND" } }]); - await expect(client.getPublicOrder("nope")).resolves.toEqual({ - ok: false, - reason: "ORDER_NOT_FOUND", - }); - }); -}); diff --git a/packages/plugin/test/http-commerce-client-entitlement.test.ts b/packages/plugin/test/http-commerce-client-entitlement.test.ts deleted file mode 100644 index 51e91fc0..00000000 --- a/packages/plugin/test/http-commerce-client-entitlement.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { HttpCommerceClient } from "../src/product-commerce/http-commerce-client.js"; - -// Issue #33 / ADR-0011 — stub-fetch proof of the client's entitlement-check wire -// shape: the orderId scope carries NO auth header and NEVER a buyerRef param, the -// session scope threads `authorization: Bearer`, and a 401 is normalized to a -// typed UNAUTHENTICATED result (never a thrown CommerceClientError). Pure wire -// checks, so no live service / Postgres. - -const BASE = "https://commerce.test"; - -interface Recorded { - url: string; - init: RequestInit | undefined; -} - -function stubClient( - status: number, - active: boolean, -): { - client: HttpCommerceClient; - requests: Recorded[]; -} { - const requests: Recorded[] = []; - const fetch = async (url: string, init?: RequestInit): Promise => { - requests.push({ url, init }); - const body = status === 401 ? { ok: false, error: "unauthorized" } : { ok: true, active }; - return new Response(JSON.stringify(body), { - status, - headers: { "content-type": "application/json" }, - }); - }; - return { client: new HttpCommerceClient({ fetch, baseUrl: BASE }), requests }; -} - -function header(init: RequestInit | undefined, name: string): string | undefined { - const headers = (init?.headers ?? {}) as Record; - for (const [k, v] of Object.entries(headers)) { - if (k.toLowerCase() === name.toLowerCase()) return v; - } - return undefined; -} - -describe("HttpCommerceClient.checkEntitlement (ADR-0011)", () => { - test("orderId scope: no auth header, no buyerRef param, returns {ok,active}", async () => { - const { client, requests } = stubClient(200, true); - const result = await client.checkEntitlement({ orderId: "o1" }, "DIG-1"); - expect(result).toEqual({ ok: true, active: true }); - - const req = requests[0]!; - const url = new URL(req.url); - expect(url.searchParams.get("orderId")).toBe("o1"); - expect(url.searchParams.get("sku")).toBe("DIG-1"); - expect(url.searchParams.has("buyerRef")).toBe(false); - expect(header(req.init, "authorization")).toBeUndefined(); - }); - - test("session scope: sends Authorization: Bearer and never a buyerRef param", async () => { - const { client, requests } = stubClient(200, true); - await client.checkEntitlement({}, "DIG-1", { sessionToken: "sess-abc" }); - - const req = requests[0]!; - const url = new URL(req.url); - expect(header(req.init, "authorization")).toBe("Bearer sess-abc"); - expect(url.searchParams.has("buyerRef")).toBe(false); - expect(url.searchParams.has("orderId")).toBe(false); - }); - - test("a 401 surfaces as a typed UNAUTHENTICATED result, not a thrown error", async () => { - const { client } = stubClient(401, false); - const result = await client.checkEntitlement({}, "DIG-1", { sessionToken: "expired" }); - expect(result).toEqual({ ok: false, reason: "UNAUTHENTICATED" }); - }); -}); diff --git a/packages/plugin/test/http-commerce-client-service-token.live.test.ts b/packages/plugin/test/http-commerce-client-service-token.live.test.ts deleted file mode 100644 index 427ddae8..00000000 --- a/packages/plugin/test/http-commerce-client-service-token.live.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { afterAll, beforeAll, describe, expect, test } from "vitest"; -import { CommerceClientError } from "../src/product-commerce/commerce-client.js"; -import { HttpCommerceClient } from "../src/product-commerce/http-commerce-client.js"; -import { startLiveService, type LiveService } from "./helpers/start-live-service.js"; - -const PG = process.env.PG_CONNECTION_STRING; - -/** - * B9 (ADR-0007) — the write-gate wire contract against a LIVE Postgres-backed - * `@otta-sh/service` booted WITH `SERVICE_API_TOKEN` set. A client carrying the - * matching `serviceToken` clears the gate on a write; a client WITHOUT it is - * 401'd at the gate — proving the header the client sends (`X-Service-Token`) - * is exactly the header the service enforces (no wire drift from the port). - */ -describe.skipIf(PG === undefined)( - "HttpCommerceClient X-Service-Token [live service, Postgres]", - () => { - const TOKEN = "live-svc-token-7Kq"; - let service: LiveService; - let authed: HttpCommerceClient; - let unauthed: HttpCommerceClient; - - beforeAll(async () => { - service = await startLiveService({ serviceToken: TOKEN }); - authed = new HttpCommerceClient({ - fetch: globalThis.fetch, - baseUrl: service.baseUrl, - serviceToken: TOKEN, - }); - unauthed = new HttpCommerceClient({ fetch: globalThis.fetch, baseUrl: service.baseUrl }); - }); - afterAll(async () => { - await service.stop(); - }); - - test("a matching serviceToken clears the write gate (upsert PUT succeeds)", async () => { - const row = await authed.upsertProductCommerce( - "prod-svc-1", - { sku: "SKU-SVC-1", price: { amount: 1200, currency: "USD" } }, - "svc-k1", - ); - expect(row).toMatchObject({ productId: "prod-svc-1", sku: "SKU-SVC-1" }); - }); - - test("no serviceToken is 401'd at the gate on the same write", async () => { - let caught: unknown; - try { - await unauthed.upsertProductCommerce("prod-svc-2", { sku: "SKU-SVC-2" }, "svc-k2"); - } catch (err) { - caught = err; - } - expect(caught).toBeInstanceOf(CommerceClientError); - expect((caught as CommerceClientError).status).toBe(401); - }); - - test("a GET read stays open through the gate (getProductCommerce needs no token)", async () => { - // GET is gate-exempt: even the unauthed client reads the row the authed - // write created above. - const found = await unauthed.getProductCommerce("prod-svc-1"); - expect(found).toMatchObject({ productId: "prod-svc-1", sku: "SKU-SVC-1" }); - }); - }, -); diff --git a/packages/plugin/test/http-commerce-client-service-token.test.ts b/packages/plugin/test/http-commerce-client-service-token.test.ts deleted file mode 100644 index 5bac5e42..00000000 --- a/packages/plugin/test/http-commerce-client-service-token.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { HttpCommerceClient } from "../src/product-commerce/http-commerce-client.js"; - -// B6 (ADR-0007) — stub-fetch proof that the write-gate token is threaded as -// `X-Service-Token` on EVERY request when configured (incl. GET reads and the -// POST *read* `getCommerceBatch`), that `logout` carries BOTH the session -// Bearer AND the service token, and that WITHOUT a token no request grows one -// (byte-identical to the pre-gate wire). - -const TOKEN = "SVC-TOKEN-abc123"; -const BASE = "https://commerce.test"; - -interface Recorded { - url: string; - init: RequestInit | undefined; -} - -/** A permissive stub that satisfies every client method's response parsing. */ -function stubClient(serviceToken: string | undefined): { - client: HttpCommerceClient; - requests: Recorded[]; -} { - const requests: Recorded[] = []; - const fetch = async (url: string, init?: RequestInit): Promise => { - requests.push({ url, init }); - return new Response( - JSON.stringify({ - ok: true, - cartId: "c1", - cart: { id: "c1", lines: [] }, - line: { id: "l1" }, - items: [], - active: true, - sessionToken: "sess", - expiresAt: "2026-07-12T00:00:00.000Z", - orders: [], - addresses: [], - order: { id: "o1" }, - }), - { status: 200, headers: { "content-type": "application/json" } }, - ); - }; - const client = new HttpCommerceClient({ - fetch, - baseUrl: BASE, - ...(serviceToken !== undefined ? { serviceToken } : {}), - }); - return { client, requests }; -} - -/** Case-insensitive header lookup over a plain-object headers init. */ -function header(init: RequestInit | undefined, name: string): string | undefined { - const headers = (init?.headers ?? {}) as Record; - for (const [k, v] of Object.entries(headers)) { - if (k.toLowerCase() === name.toLowerCase()) return v; - } - return undefined; -} - -/** Drive one call per HTTP surface the client exposes (read + write + logout). */ -async function driveAll(client: HttpCommerceClient): Promise { - await client.upsertProductCommerce("p1", { sku: "S" }, "k1"); // PUT (write) - await client.getProductCommerce("p1"); // GET (read) - await client.softDeleteProductCommerce("p1", "k2"); // DELETE - await client.activateProductCommerce("p1", "k3", "2026-07-12T00:00:00.000Z"); // POST - await client.deactivateProductCommerce("p1", "k4", "2026-07-12T00:00:00.000Z"); // POST - await client.getCommerceBatch(["p1"]); // POST read (gated!) - await client.createCart("USD"); // POST - await client.getCart("c1"); // GET - await client.addCartLine("c1", "S", "p1", 1, "k5"); // POST - await client.adjustCartLine("c1", "l1", 2, "k6"); // PATCH - await client.removeCartLine("c1", "l1", "k7"); // DELETE - await client.checkEntitlement({ orderId: "o1" }, "S"); // GET - await client.requestLoginLink("a@b.io"); // POST (login pre-auth) - await client.verifyLogin("ch1", "t1"); // POST (login pre-auth) - await client.listMyOrders("sess"); // GET (session) - await client.getMyOrder("sess", "o1"); // GET (session) - await client.listMyAddresses("sess"); // GET (session) - await client.logout("sess"); // POST (session) — dual-header -} - -describe("HttpCommerceClient X-Service-Token threading (ADR-0007)", () => { - test("with a token: EVERY request carries X-Service-Token, and logout carries BOTH it and the session Bearer", async () => { - const { client, requests } = stubClient(TOKEN); - await driveAll(client); - - expect(requests.length).toBe(18); - for (const r of requests) { - expect(header(r.init, "X-Service-Token")).toBe(TOKEN); - } - - // logout is the LAST call: it is the dual-header session path — the write - // gate's X-Service-Token AND the customer session Bearer, side by side. - const logout = requests.at(-1)!; - expect(logout.url).toBe(`${BASE}/auth/logout`); - expect(header(logout.init, "authorization")).toBe("Bearer sess"); - expect(header(logout.init, "X-Service-Token")).toBe(TOKEN); - }); - - test("without a token: NO request carries X-Service-Token (byte-identical pre-gate wire)", async () => { - const { client, requests } = stubClient(undefined); - await driveAll(client); - - expect(requests.length).toBe(18); - for (const r of requests) { - expect(header(r.init, "X-Service-Token")).toBeUndefined(); - } - // logout still carries its session Bearer — only the gate header is absent. - const logout = requests.at(-1)!; - expect(header(logout.init, "authorization")).toBe("Bearer sess"); - }); -}); diff --git a/packages/plugin/test/http-commerce-client.test.ts b/packages/plugin/test/http-commerce-client.test.ts deleted file mode 100644 index 89610d5a..00000000 --- a/packages/plugin/test/http-commerce-client.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { afterAll, beforeAll, describe, expect, test } from "vitest"; -import { HttpCommerceClient } from "../src/product-commerce/http-commerce-client.js"; -import { startLiveService, type LiveService } from "./helpers/start-live-service.js"; - -const PG = process.env.PG_CONNECTION_STRING; - -/** - * HTTP-WIRE RESIDUE. Every transport-agnostic case this file used to carry now - * lives in `test/contracts/commerce-client-contract.ts` (INC-A7) and runs here - * through `commerce-client-contract.http.test.ts`. What stays is what can only - * be stated in wire terms: a non-2xx response surfacing as a structured, - * catchable `CommerceClientError` carrying the HTTP status. Deleted with the - * transport at INC-D3b. - */ -describe.skipIf(PG === undefined)("HttpCommerceClient [live @otta-sh/service, Postgres]", () => { - let service: LiveService; - let client: HttpCommerceClient; - - beforeAll(async () => { - service = await startLiveService(); - client = new HttpCommerceClient({ fetch: globalThis.fetch, baseUrl: service.baseUrl }); - }); - afterAll(async () => { - await service.stop(); - }); - - test("getCommerceBatch over the service's id cap surfaces the 400 as a structured CommerceClientError", async () => { - const ids = Array.from({ length: 101 }, (_, i) => `prod-cap-${i}`); - await expect(client.getCommerceBatch(ids)).rejects.toMatchObject({ - name: "CommerceClientError", - status: 400, - }); - }); - - test("a MISSING_PRODUCT_ID rejection (empty product id) surfaces as a structured CommerceClientError, not a silent create", async () => { - // An empty productId collapses the URL to `/products//commerce`, which - // Hono's router itself declines to match (404) before ever reaching the - // MISSING_PRODUCT_ID domain guard — the service-level 400 case is - // covered directly in packages/service/test/product-commerce-http.test.ts. - // What THIS test proves is the transport contract: any non-2xx response - // surfaces as a structured, catchable CommerceClientError, never a - // silent success. - await expect(client.upsertProductCommerce("", { sku: "SKU-X" }, "k5")).rejects.toMatchObject({ - name: "CommerceClientError", - }); - }); -}); diff --git a/packages/plugin/test/in-process-egress.sandbox.test.ts b/packages/plugin/test/in-process-egress.sandbox.test.ts index ecf0bd53..59d93c2c 100644 --- a/packages/plugin/test/in-process-egress.sandbox.test.ts +++ b/packages/plugin/test/in-process-egress.sandbox.test.ts @@ -45,10 +45,7 @@ import { afterAll, beforeAll, describe, expect, test } from "vitest"; import { SWEEP_TASK_NAME } from "../src/cron/index.js"; import type { CommerceSweepSummary, SweepLegOutcome } from "../src/cron/index.js"; import { X402_SETTLE_ROUTE } from "../src/payments/x402-settle-route.js"; -import { - startStubCommerceServer, - type StubCommerceServer, -} from "./helpers/stub-commerce-server.js"; +import { startStubHttpServer, type StubHttpServer } from "./helpers/stub-http-server.js"; import { loadPluginInSandbox, type SandboxHandle } from "./sandbox/harness.js"; import { storageBridge } from "./sandbox/storage-bridge.js"; @@ -66,7 +63,7 @@ const EMAIL_FROM = "orders@egress.example"; const EMAIL_PATH = "/email/send"; const FACILITATOR_PATH = "/x402/verify"; -let stub: StubCommerceServer; +let stub: StubHttpServer; let granted: SandboxHandle; let refused: SandboxHandle; let storage: StorageAccess; @@ -208,7 +205,7 @@ function postsTo(pathname: string): number { beforeAll(async () => { ({ storage } = await storageBridge()); - stub = await startStubCommerceServer(); + stub = await startStubHttpServer(); stub.respondWith("POST", (req) => { if (req.url === FACILITATOR_PATH) { const asked = req.body as { orderId?: string; transaction?: string }; diff --git a/packages/plugin/test/orders-refund-key.test.ts b/packages/plugin/test/orders-refund-key.test.ts index b3bcbf3c..c9f444be 100644 --- a/packages/plugin/test/orders-refund-key.test.ts +++ b/packages/plugin/test/orders-refund-key.test.ts @@ -32,7 +32,7 @@ import { type OrdersActionPayload, type OrdersActionResult, } from "../src/admin/orders-actions.js"; -import type { AdminOrdersSurface, RefundsSummaryWire } from "../src/admin/admin-orders-client.js"; +import type { AdminOrdersSurface, RefundsSummaryWire } from "../src/admin/admin-orders-surface.js"; const ORDER_ID = "order-refund-key-1"; diff --git a/packages/plugin/test/payment-secrets.test.ts b/packages/plugin/test/payment-secrets.test.ts index e9e4e699..29b04503 100644 --- a/packages/plugin/test/payment-secrets.test.ts +++ b/packages/plugin/test/payment-secrets.test.ts @@ -3,16 +3,12 @@ * in WRITE-ONLY plugin kv, plus the Settings provisioning surface for them. * * WHERE THE NAMES COME FROM. Every key below is the in-process equivalent of an - * environment variable `@otta-sh/service` reads TODAY — nothing is invented: + * environment variable the standalone `@otta-sh/service` package (now deleted, + * folded into the plugin) used to read — nothing is invented: * - `settings:stripeSecretKey` ← `STRIPE_SECRET_KEY` - * (`packages/service/src/stripe-wiring.ts:7`) * - `settings:stripeWebhookSecret` ← `STRIPE_WEBHOOK_SECRET` - * (`packages/service/src/stripe-wiring.ts:6`) * - `settings:emailApiKey` ← `EMAIL_API_KEY` - * (`packages/service/src/index.ts:79`, - * `packages/service/src/worker.ts:72`) * - `settings:x402FacilitatorApiKey` ← `X402_FACILITATOR_SECRET` - * (`packages/service/src/x402-wiring.ts:6`) * RENAMED off `…FacilitatorSecret` at * INC-C5: the value is now SENT, not * used to verify (review round 2, A5). diff --git a/packages/plugin/test/reports-refunded-fallback.test.ts b/packages/plugin/test/reports-refunded-fallback.test.ts index 17e0a52e..6f1b0fa0 100644 --- a/packages/plugin/test/reports-refunded-fallback.test.ts +++ b/packages/plugin/test/reports-refunded-fallback.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import type { RevenueBucketWire } from "../src/admin/reporting-client.js"; +import type { RevenueBucketWire } from "../src/admin/reporting-settings-surface.js"; import { buildReportsBlocks } from "../src/admin/reports-page.js"; import { findBlocks, type LooseBlock } from "./helpers/blocks.js"; diff --git a/packages/plugin/test/resolve-stock-context.test.ts b/packages/plugin/test/resolve-stock-context.test.ts index 0d21f9bd..0f40009a 100644 --- a/packages/plugin/test/resolve-stock-context.test.ts +++ b/packages/plugin/test/resolve-stock-context.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import type { ProductSummaryWire } from "../src/admin/admin-products-client.js"; +import type { ProductSummaryWire } from "../src/admin/admin-products-surface.js"; import { resolveStockContext } from "../src/admin/products-read.js"; /** diff --git a/packages/plugin/test/sandbox-harness.test.ts b/packages/plugin/test/sandbox-harness.test.ts index 31fbd50e..6ac2b4d6 100644 --- a/packages/plugin/test/sandbox-harness.test.ts +++ b/packages/plugin/test/sandbox-harness.test.ts @@ -19,13 +19,10 @@ import { SWEEP_TASK_NAME } from "../src/cron/index.js"; import type { CommerceSweepSummary, SweepLegOutcome } from "../src/cron/index.js"; import { loadPluginInSandbox, type SandboxHandle } from "./sandbox/harness.js"; import { storageBridge } from "./sandbox/storage-bridge.js"; -import { - startStubCommerceServer, - type StubCommerceServer, -} from "./helpers/stub-commerce-server.js"; +import { startStubHttpServer, type StubHttpServer } from "./helpers/stub-http-server.js"; let sandbox: SandboxHandle | undefined; -let stub: StubCommerceServer | undefined; +let stub: StubHttpServer | undefined; afterEach(async () => { await sandbox?.close(); @@ -125,7 +122,7 @@ describe("workerd-on-Node sandbox harness (plan §6 step 1)", () => { // adapters lives in `in-process-egress.sandbox.test.ts`, not here. const { storage } = await storageBridge(); - stub = await startStubCommerceServer(); + stub = await startStubHttpServer(); stub.respondWith("POST", () => ({ status: 202, body: { queued: true } })); sandbox = await loadPluginInSandbox({ diff --git a/packages/plugin/test/stripe-settle-route.sandbox.test.ts b/packages/plugin/test/stripe-settle-route.sandbox.test.ts index 6b81fe0d..458742a5 100644 --- a/packages/plugin/test/stripe-settle-route.sandbox.test.ts +++ b/packages/plugin/test/stripe-settle-route.sandbox.test.ts @@ -27,10 +27,7 @@ */ import { signStripeWebhook } from "@otta-sh/payments-stripe"; import { afterEach, describe, expect, test } from "vitest"; -import { - startStubCommerceServer, - type StubCommerceServer, -} from "./helpers/stub-commerce-server.js"; +import { startStubHttpServer, type StubHttpServer } from "./helpers/stub-http-server.js"; import { loadPluginInSandbox, productionAllowedHosts, @@ -41,7 +38,7 @@ const WEBHOOK_SECRET = "whsec_sandbox_NEVER_LEAK"; const EDGE_TOKEN = "otta_edge_sandbox_NEVER_LEAK"; let sandbox: SandboxHandle | undefined; -let stub: StubCommerceServer | undefined; +let stub: StubHttpServer | undefined; afterEach(async () => { await sandbox?.close(); @@ -84,7 +81,7 @@ describe("webhooks/stripe/settle under workerd", () => { test("the edge token header crosses into the isolate and gates the route end to end", async () => { // The stub backs the Settings re-render only; the settle route itself makes // no request, and `stub.requests` below says so. - stub = await startStubCommerceServer(); + stub = await startStubHttpServer(); stub.respondWith("GET", () => ({ status: 200, body: { ok: true, settings: { holdTtlMinutes: 15, lowStockThreshold: 5 } }, @@ -171,7 +168,7 @@ describe("webhooks/stripe/settle under workerd", () => { }, 180_000); test("with NO edge token provisioned the route passes through, and the HMAC still governs", async () => { - stub = await startStubCommerceServer(); + stub = await startStubHttpServer(); stub.respondWith("GET", () => ({ status: 200, body: { ok: true, settings: { holdTtlMinutes: 15, lowStockThreshold: 5 } }, diff --git a/packages/store-emdash/src/emdash-order-store.ts b/packages/store-emdash/src/emdash-order-store.ts index cfa02e96..8d130ad2 100644 --- a/packages/store-emdash/src/emdash-order-store.ts +++ b/packages/store-emdash/src/emdash-order-store.ts @@ -318,9 +318,9 @@ const MAX_LIST_PAGES = 1000; * adapter that simply forwarded it would truncate a larger page silently. It does not: * the scan pages internally until it has `limit + 1` rows. In practice that loop is a * correctness guarantee rather than a hot path, because **the 100-row cap on what a - * caller may ask for lives at the ROUTE** (`@otta-sh/service`'s admin-orders query - * schema), not here — so a page bigger than one host page is a programmatic caller, not - * the console. + * caller may ask for lives at the ROUTE** (`in-process-admin-orders-client.ts`'s + * `clampLimit`), not here — so a page bigger than one host page is a programmatic + * caller, not the console. */ const LIST_PAGE_SIZE = 100; diff --git a/packages/store-emdash/src/id-gen.ts b/packages/store-emdash/src/id-gen.ts index cd1a61c7..7eef8168 100644 --- a/packages/store-emdash/src/id-gen.ts +++ b/packages/store-emdash/src/id-gen.ts @@ -6,11 +6,10 @@ import type { IdGen } from "@otta-sh/domain"; * sandbox, where a `node:` import is a runtime failure the type system would not * have caught. * - * This duplicates `@otta-sh/store-postgres`'s `uuidIdGen` on purpose. Importing - * it instead is forbidden by `store-emdash-is-sandbox-clean`, and rightly: that - * package's entry pulls a Kysely/pg graph into a module that ships inside the - * isolate. The duplication is also temporary in one direction — the - * store-postgres copy goes when that package does, and this one is what remains. + * This duplicated `@otta-sh/store-postgres`'s `uuidIdGen` on purpose. Importing + * it instead was forbidden by `store-emdash-is-sandbox-clean`, and rightly: that + * package's entry pulled a Kysely/pg graph into a module that ships inside the + * isolate. `@otta-sh/store-postgres` is gone now; this is what remains. */ export const uuidIdGen: IdGen = { newId(): string { diff --git a/packages/store-emdash/test/cart-fence.dialects.test.ts b/packages/store-emdash/test/cart-fence.dialects.test.ts index 1c399321..413c7749 100644 --- a/packages/store-emdash/test/cart-fence.dialects.test.ts +++ b/packages/store-emdash/test/cart-fence.dialects.test.ts @@ -1,6 +1,6 @@ /** - * The cart-mutation fences against the document adapter — the store-postgres - * suite of the same name, re-pointed at `EmdashCartStore`. + * The cart-mutation fences against the document adapter. `@otta-sh/store-postgres` + * is gone; this is the dialect coverage now, re-pointed at `EmdashCartStore`. * * The cases are unchanged: a cart-initiated adjust/remove on a hold that is no * longer the cart's is `LINE_CHECKED_OUT` with no stock moved, and any mutation on diff --git a/packages/store-emdash/test/describe-each-dialect.ts b/packages/store-emdash/test/describe-each-dialect.ts index fd0db06f..0b965b77 100644 --- a/packages/store-emdash/test/describe-each-dialect.ts +++ b/packages/store-emdash/test/describe-each-dialect.ts @@ -237,8 +237,8 @@ function makeContext(dialect: "sqlite" | "postgres"): DialectContext { /** * Run one suite body against every available dialect. Postgres is reported as a * visibly skipped suite — naming the missing env var — rather than silently - * absent, matching `@otta-sh/store-postgres`'s convention that a pg tier which - * did not run says so. + * absent, the same convention the now-deleted `@otta-sh/store-postgres` used for + * a pg tier that did not run. */ export function describeEachDialect(name: string, fn: (ctx: DialectContext) => void): void { describe(`${name} [sqlite]`, () => { diff --git a/packages/store-emdash/test/hold-expiry.dialects.test.ts b/packages/store-emdash/test/hold-expiry.dialects.test.ts index af474392..debca3db 100644 --- a/packages/store-emdash/test/hold-expiry.dialects.test.ts +++ b/packages/store-emdash/test/hold-expiry.dialects.test.ts @@ -1,6 +1,6 @@ /** - * Hold expiry against the document adapter — the store-postgres suite of the same - * name, re-pointed at `EmdashCartStore`. + * Hold expiry against the document adapter. `@otta-sh/store-postgres` is gone; + * this is the dialect coverage now, re-pointed at `EmdashCartStore`. * * The four cases are the specification of ADR-0019 §7.7: an expired hold is * released and its stock returns, a lazy read racing the sweep returns stock diff --git a/packages/store-emdash/test/no-oversell-cart.pg.test.ts b/packages/store-emdash/test/no-oversell-cart.pg.test.ts index 99cb5999..dd65e278 100644 --- a/packages/store-emdash/test/no-oversell-cart.pg.test.ts +++ b/packages/store-emdash/test/no-oversell-cart.pg.test.ts @@ -1,6 +1,6 @@ /** - * THE cart-layer acceptance gate, on the document adapter — the store-postgres - * suite of the same name, re-pointed at `EmdashCartStore`. Postgres only: + * THE cart-layer acceptance gate, on the document adapter. `@otta-sh/store-postgres` + * is gone; this is the pg-tier coverage now, re-pointed at `EmdashCartStore`. Postgres only: * better-sqlite3 serializes writes in one process, so it verifies the shape and * never the contention. * diff --git a/packages/store-emdash/test/no-oversell-checkout-multiline.pg.test.ts b/packages/store-emdash/test/no-oversell-checkout-multiline.pg.test.ts index 01ad5a17..c877271f 100644 --- a/packages/store-emdash/test/no-oversell-checkout-multiline.pg.test.ts +++ b/packages/store-emdash/test/no-oversell-checkout-multiline.pg.test.ts @@ -1,7 +1,8 @@ /** - * THE batched-checkout acceptance gate, on the document adapter — the - * store-postgres suite of the same name, re-pointed at `EmdashOrderStore` over - * `EmdashCartStore` and `EmdashInventoryStore`. Postgres only: better-sqlite3 + * THE batched-checkout acceptance gate, on the document adapter. + * `@otta-sh/store-postgres` is gone; this is the pg-tier coverage now, + * re-pointed at `EmdashOrderStore` over `EmdashCartStore` and + * `EmdashInventoryStore`. Postgres only: better-sqlite3 * serializes writes in one process, so it verifies the shape and never the * contention. * diff --git a/packages/store-emdash/test/no-oversell-checkout.pg.test.ts b/packages/store-emdash/test/no-oversell-checkout.pg.test.ts index ffcc2d45..82101214 100644 --- a/packages/store-emdash/test/no-oversell-checkout.pg.test.ts +++ b/packages/store-emdash/test/no-oversell-checkout.pg.test.ts @@ -1,6 +1,7 @@ /** - * THE checkout acceptance gate, on the document adapter — the store-postgres suite - * of the same name, re-pointed at `EmdashOrderStore` over `EmdashCartStore` and + * THE checkout acceptance gate, on the document adapter. `@otta-sh/store-postgres` + * is gone; this is the pg-tier coverage now, re-pointed at `EmdashOrderStore` over + * `EmdashCartStore` and * `EmdashInventoryStore`. Postgres only: better-sqlite3 serializes writes in one * process, so it verifies the shape and never the contention. * diff --git a/packages/store-emdash/test/order-flow.dialects.test.ts b/packages/store-emdash/test/order-flow.dialects.test.ts index 0437d43d..9242fcf3 100644 --- a/packages/store-emdash/test/order-flow.dialects.test.ts +++ b/packages/store-emdash/test/order-flow.dialects.test.ts @@ -1,6 +1,7 @@ /** - * THE end-to-end order flow on the document adapter — the store-postgres suite of - * the same name, re-pointed at `EmdashOrderStore` over `EmdashCartStore` and + * THE end-to-end order flow on the document adapter. `@otta-sh/store-postgres` is + * gone; this is the dialect coverage now, re-pointed at `EmdashOrderStore` over + * `EmdashCartStore` and * `EmdashInventoryStore`. Every case is the original's, with its assertions * translated from SQL rows to the documents that replaced them (`payments` → * `orders/{id}.payments`, `payment_events` → the payment-event fake's recorded diff --git a/packages/store-emdash/test/refund-race.pg.test.ts b/packages/store-emdash/test/refund-race.pg.test.ts index 4df1441a..7f90402f 100644 --- a/packages/store-emdash/test/refund-race.pg.test.ts +++ b/packages/store-emdash/test/refund-race.pg.test.ts @@ -1,8 +1,8 @@ /** - * Money movement under concurrency, on the document adapter — the store-postgres - * suite of the same name, re-pointed at `EmdashOrderStore`. Postgres only: - * better-sqlite3 serializes writes in one process, so it verifies the shape and - * never the contention. + * Money movement under concurrency, on the document adapter. `@otta-sh/store-postgres` + * is gone; this is the pg-tier coverage now, re-pointed at `EmdashOrderStore`. + * Postgres only: better-sqlite3 serializes writes in one process, so it verifies + * the shape and never the contention. * * The invariant is the ceiling: `Σ active refunds ≤ min(Σ captured, frozen total)` * under EVERY interleaving of N racing refunds. The SQL held it with a row lock on diff --git a/packages/store-emdash/test/reserve-cart-line-crash.dialects.test.ts b/packages/store-emdash/test/reserve-cart-line-crash.dialects.test.ts index c37ad0fe..90f11258 100644 --- a/packages/store-emdash/test/reserve-cart-line-crash.dialects.test.ts +++ b/packages/store-emdash/test/reserve-cart-line-crash.dialects.test.ts @@ -1,6 +1,7 @@ /** - * The reserve ↔ cart-line crash window against the document adapter — the - * store-postgres suite of the same name, re-pointed at `EmdashCartStore`. + * The reserve ↔ cart-line crash window against the document adapter. + * `@otta-sh/store-postgres` is gone; this is the dialect coverage now, + * re-pointed at `EmdashCartStore`. * * The one substantive improvement over the SQL version: it no longer HAND-SEEDS * the crashed state. `seedCrashedHold` there inserted a `cart_mutations` row, a diff --git a/packages/store-emdash/test/resolve-reconciliation-race.pg.test.ts b/packages/store-emdash/test/resolve-reconciliation-race.pg.test.ts index 65e48646..4eea2834 100644 --- a/packages/store-emdash/test/resolve-reconciliation-race.pg.test.ts +++ b/packages/store-emdash/test/resolve-reconciliation-race.pg.test.ts @@ -1,8 +1,8 @@ /** - * `resolveReconciliation` under concurrency, on the document adapter — the - * store-postgres suite of the same name, re-pointed at `EmdashOrderStore`. Postgres - * only: better-sqlite3 serializes writes in one process, so it verifies the shape - * and never the contention. + * `resolveReconciliation` under concurrency, on the document adapter. + * `@otta-sh/store-postgres` is gone; this is the pg-tier coverage now, re-pointed + * at `EmdashOrderStore`. Postgres only: better-sqlite3 serializes writes in one + * process, so it verifies the shape and never the contention. * * The resolve is a compare-and-CLEAR, and the guard is EQUALITY against the flag the * operator reviewed (never a bare "is flagged"). The SQL got its once-only from diff --git a/packages/store-emdash/test/storage-access.dialects.test.ts b/packages/store-emdash/test/storage-access.dialects.test.ts index 7b4eb460..283d6bbd 100644 --- a/packages/store-emdash/test/storage-access.dialects.test.ts +++ b/packages/store-emdash/test/storage-access.dialects.test.ts @@ -54,10 +54,10 @@ it("accepts the host's own ctx.storage as a StorageAccess", () => { }); describe("the in-process id and clock adapters", () => { - // `uuidIdGen` duplicates `@otta-sh/store-postgres`'s function deliberately: + // `uuidIdGen` duplicated `@otta-sh/store-postgres`'s function deliberately: // `store-emdash-is-sandbox-clean` forbids importing that package, because it // would drag a Kysely/pg graph into a module that is bundled into workerd. The - // store-postgres copy is deleted with that package; this one survives it. + // store-postgres package is gone; this is the surviving copy. it("draws distinct v4 UUIDs", () => { const drawn = new Set(); for (let i = 0; i < 1000; i++) drawn.add(uuidIdGen.newId()); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 96848149..7cff2115 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,9 +9,6 @@ catalogs: '@changesets/cli': specifier: ^2.31.0 version: 2.31.0 - '@hono/node-server': - specifier: ^2.0.8 - version: 2.0.8 '@playwright/test': specifier: ^1.62.1 version: 1.62.1 @@ -33,9 +30,6 @@ catalogs: fast-check: specifier: ^3.23.2 version: 3.23.2 - hono: - specifier: ^4.12.29 - version: 4.12.29 kysely: specifier: ^0.29.3 version: 0.29.3 @@ -60,9 +54,6 @@ catalogs: wrangler: specifier: ^4.99.0 version: 4.110.0 - zod: - specifier: ^4.4.3 - version: 4.4.3 overrides: emdash: file:./vendor/emdash-0.37.1-otta.1.tgz @@ -219,15 +210,6 @@ importers: specifier: workspace:* version: link:../store-emdash devDependencies: - '@hono/node-server': - specifier: 'catalog:' - version: 2.0.8(hono@4.12.29) - '@otta-sh/service': - specifier: workspace:* - version: link:../service - '@otta-sh/store-postgres': - specifier: workspace:* - version: link:../store-postgres '@types/node': specifier: 'catalog:' version: 22.20.1 @@ -244,43 +226,6 @@ importers: specifier: ^1.20260710.1 version: 1.20260710.1 - packages/service: - dependencies: - '@hono/node-server': - specifier: 'catalog:' - version: 2.0.8(hono@4.12.29) - '@otta-sh/domain': - specifier: workspace:* - version: link:../domain - '@otta-sh/payments-stripe': - specifier: workspace:* - version: link:../payments-stripe - '@otta-sh/payments-x402': - specifier: workspace:* - version: link:../payments-x402 - '@otta-sh/store-postgres': - specifier: workspace:* - version: link:../store-postgres - hono: - specifier: 'catalog:' - version: 4.12.29 - zod: - specifier: 'catalog:' - version: 4.4.3 - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 22.20.1 - tsdown: - specifier: 'catalog:' - version: 0.22.4(typescript@5.9.3) - typescript: - specifier: 'catalog:' - version: 5.9.3 - vitest: - specifier: 'catalog:' - version: 4.1.10(@types/node@22.20.1)(happy-dom@20.11.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(yaml@2.9.0)) - packages/store-emdash: dependencies: '@otta-sh/domain': @@ -321,40 +266,6 @@ importers: specifier: 'catalog:' version: 4.1.10(@types/node@22.20.1)(happy-dom@20.11.1)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(yaml@2.9.0)) - packages/store-postgres: - dependencies: - '@otta-sh/domain': - specifier: workspace:* - version: link:../domain - better-sqlite3: - specifier: 'catalog:' - version: 12.11.1 - kysely: - specifier: 'catalog:' - version: 0.29.3 - pg: - specifier: 'catalog:' - version: 8.22.0 - devDependencies: - '@types/better-sqlite3': - specifier: 'catalog:' - version: 7.6.13 - '@types/node': - specifier: 'catalog:' - version: 22.20.1 - '@types/pg': - specifier: 'catalog:' - version: 8.20.0 - tsdown: - specifier: 'catalog:' - version: 0.22.4(typescript@5.9.3) - typescript: - specifier: 'catalog:' - version: 5.9.3 - vitest: - specifier: 'catalog:' - version: 4.1.10(@types/node@22.20.1)(happy-dom@20.11.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(yaml@2.9.0)) - sites/staging: dependencies: '@astrojs/cloudflare': @@ -1296,12 +1207,6 @@ packages: peerDependencies: hono: ^4 - '@hono/node-server@2.0.8': - resolution: {integrity: sha512-GuCWzLxwg218fy1JaHculFsdcuY12hxit83V+algozTPnwhNjLrRL/Alg9OYjLZLoUZ1rw/S4CdTMsnkSKCmFA==} - engines: {node: '>=20'} - peerDependencies: - hono: ^4 - '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} @@ -6595,10 +6500,6 @@ snapshots: dependencies: hono: 4.12.29 - '@hono/node-server@2.0.8(hono@4.12.29)': - dependencies: - hono: 4.12.29 - '@img/colour@1.1.0': {} '@img/sharp-darwin-arm64@0.34.5': diff --git a/sites/staging/e2e/harness.spec.ts b/sites/staging/e2e/harness.spec.ts index 073c77e4..627fe2eb 100644 --- a/sites/staging/e2e/harness.spec.ts +++ b/sites/staging/e2e/harness.spec.ts @@ -16,7 +16,6 @@ import { DEV_BYPASS_PATH, E2E_BASE_URL, E2E_PG_CONNECTION_STRING, - E2E_SERVICE_URL, E2E_VIEWPORT, MIGRATED_SCREENS, NEVER_MIGRATED_PATHS, @@ -97,11 +96,13 @@ test.describe("harness configuration", () => { test("every resolved e2e endpoint is loopback (§0.3 — no remote host, ever)", () => { // The port guard above only covers Postgres — and Postgres is the variable // a deploying shell really does export, which is the whole reason for this - // check. The two `OTTA_E2E_*` URLs are e2e-only names, guarded anyway so - // that no resolved endpoint in this harness is merely trusted. + // check. `OTTA_E2E_BASE_URL` is an e2e-only name, guarded anyway so that no + // resolved endpoint in this harness is merely trusted. (There were two such + // names until INC-D3b: `OTTA_E2E_SERVICE_URL` pointed at the standalone + // commerce service, which no longer exists, so the variable was removed + // rather than left as a knob that configures nothing.) for (const [label, url] of [ ["OTTA_E2E_BASE_URL", E2E_BASE_URL], - ["OTTA_E2E_SERVICE_URL", E2E_SERVICE_URL], ["PG_CONNECTION_STRING", E2E_PG_CONNECTION_STRING], ] as const) { expect(() => assertLoopbackUrl(url, label), `${label} is not loopback`).not.toThrow(); diff --git a/sites/staging/e2e/harness.ts b/sites/staging/e2e/harness.ts index 4378f836..4c064092 100644 --- a/sites/staging/e2e/harness.ts +++ b/sites/staging/e2e/harness.ts @@ -32,9 +32,11 @@ * module-load env guards below. Those guards then read `COMMERCE_SERVICE_URL`, * which was at the time the staging site's ordinary BUILD-time variable, so * merely having it set to a real URL made the whole unit suite throw on an e2e - * loopback check it was never subject to. (INC-D3a retired that variable, and - * the guard now reads `OTTA_E2E_SERVICE_URL` — but the split below is what made - * the collision impossible rather than merely unlikely, so it stands.) + * loopback check it was never subject to. (INC-D3a retired that variable, its + * successor `OTTA_E2E_SERVICE_URL` went with the service package in INC-D3b, + * and no commerce endpoint is guarded here any more — but the split below is + * what made the collision impossible rather than merely unlikely, so it + * stands.) * * The registry now lives in `./registry.js` — no imports, no environment, no * code at load — and this file re-exports it. Anything else the unit tier ever @@ -59,10 +61,10 @@ export const E2E_VIEWPORT = { width: 1440, height: 2200 } as const; * and this harness reads it. Nothing about "it is only a test run" stops an * inherited export from aiming the stack boot, or a dev-bypass POST, at * production. So the values are guarded rather than trusted, at module load, - * where the failure is loud and precedes any request. The two `OTTA_E2E_*` URLs - * get the same treatment even though nothing but an e2e run sets them — the - * guard is one line and a harness that trusts *some* of its endpoints is the - * one that eventually trusts the wrong one. + * where the failure is loud and precedes any request. `OTTA_E2E_BASE_URL` gets + * the same treatment even though nothing but an e2e run sets it — the guard is + * one line and a harness that trusts *some* of its endpoints is the one that + * eventually trusts the wrong one. */ const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]); @@ -103,20 +105,17 @@ export const E2E_BASE_URL = assertLoopbackUrl( "OTTA_E2E_BASE_URL", ); -/** - * The standalone `@otta-sh/service` process the §0.2 stack boots. - * - * The SITE does not use it: since INC-D3a the storefront runs commerce - * in-process and reads no commerce address at build or at run time. This value - * only says which port Playwright boots the service on and where it polls - * `/health`. It was read from `COMMERCE_SERVICE_URL` until that variable was - * retired with the rest of the mode plumbing; the `OTTA_E2E_` name says plainly - * that it is an e2e knob and not something a deployment shell already exports. +/* + * There is NO second endpoint here any more. The §0.2 stack used to boot a + * standalone commerce service alongside the site, and this module exported an + * `E2E_SERVICE_URL` (read from `OTTA_E2E_SERVICE_URL`, default port 3500) + * naming the port Playwright booted it on. INC-D3a stopped the site from + * reading a commerce address at all; INC-D3b deleted the service package + * outright. The stack is one process now — the site, running commerce + * in-process against its own store — so the knob is REMOVED rather than left + * dangling: an environment variable that configures nothing is a trap for the + * next reader, and the loopback guard below has one less endpoint to police. */ -export const E2E_SERVICE_URL = assertLoopbackUrl( - process.env["OTTA_E2E_SERVICE_URL"] ?? "http://127.0.0.1:3500", - "OTTA_E2E_SERVICE_URL", -); /** * The LOCAL TEST database — container `urumi-pg-test`, port **55432**. @@ -131,7 +130,7 @@ export const E2E_PG_CONNECTION_STRING = assertLoopbackUrl( ); /** Opt in to having Playwright boot the §0.2 stack itself (off by default: a - * bare `pnpm test:e2e` must not try to start a database-backed service). */ + * bare `pnpm test:e2e` must not try to start a dev server). */ export const E2E_STARTS_STACK = process.env["OTTA_E2E_START_STACK"] === "1"; /** Turn "no site running" from a skip into a failure. */ diff --git a/sites/staging/e2e/registry.ts b/sites/staging/e2e/registry.ts index a3b508c3..e8867972 100644 --- a/sites/staging/e2e/registry.ts +++ b/sites/staging/e2e/registry.ts @@ -11,8 +11,9 @@ * That was wrong in two ways at once, both of which this split fixes: * * 1. **`harness.ts` runs side effects at import.** It resolves and - * loopback-guards `OTTA_E2E_BASE_URL`, `OTTA_E2E_SERVICE_URL` and - * `PG_CONNECTION_STRING` at module load — deliberately, because an + * loopback-guards `OTTA_E2E_BASE_URL` and `PG_CONNECTION_STRING` at module + * load (and, until INC-D3b deleted the service package, a commerce service + * URL beside them) — deliberately, because an * inherited export must not aim an e2e run at production. The service URL * was then read from `COMMERCE_SERVICE_URL`, which was ALSO the staging * site's ordinary build-time variable, so diff --git a/sites/staging/src/lib/cart-view.ts b/sites/staging/src/lib/cart-view.ts index 4e979531..4ca988e6 100644 --- a/sites/staging/src/lib/cart-view.ts +++ b/sites/staging/src/lib/cart-view.ts @@ -77,19 +77,20 @@ export function isCartPricingDegraded(pricing: CartPricingWire | null | undefine * radius of failing closed points the other way. `state !== "active"` here * would brick a LIVE cart read-only — no quantity field, no remove button, no * way to check out — for a shopper whose cart is perfectly fine. And it would - * do it on a value that NOTHING validates at runtime anywhere on the wire path: - * `CartWire.state` is typed `string`, and `HttpCommerceClient`'s `#cartResult` - * blind-casts the response body after checking only that it carries an - * `ok`/`reason` envelope. Whatever the service ever emits arrives here - * unchecked. - * - * `CartWire.orderId` rides that same unchecked path, and it is the reason the - * plugin normalizes ONE field and not this one: `state` fails safely under a - * blind cast (`isCartTerminal(undefined)` is `false`, so the page draws the + * do it on a value that NOTHING narrows at runtime anywhere on the wire path: + * `CartWire.state` is typed `string`, deliberately wider than the domain's + * `CartState` union that `InProcessCommerceClient`'s `serializeCart` copies it + * from — so this site, one real HTTP hop downstream of the plugin, still sees + * a bare `string` with nothing to narrow it back. + * + * `CartWire.orderId` rides that same wide-open path, and it is the reason the + * plugin normalizes ONE field and not this one: `state` fails safely staying + * a bare string (`isCartTerminal(undefined)` is `false`, so the page draws the * live cart it draws for every unknown state), whereas `orderId` fails * UNSAFELY — `undefined !== null` is true, so `cart/index.astro` would offer - * `/orders/undefined` as the panel's only action. Hence the coercion in - * `HttpCommerceClient.getCart`, at the wire boundary, and none downstream. + * `/orders/undefined` as the panel's only action. Hence `CartWire.orderId` is + * declared REQUIRED rather than optional (see its own doc comment on + * `commerce-client.ts`), and none downstream. * * So this answers for the ONE state that is genuinely terminal (`checked_out` * is one-way — `CartState` in `packages/domain/src/ports/cart-store.ts`, and @@ -114,10 +115,12 @@ export function isCartTerminal(state: string | undefined): boolean { * The companion to `isCartTerminal`'s deliberate tolerance: the page renders an * unrecognised state as a live cart, and logs that it did. Without this, a * third state would arrive as a permanent, silent mis-render. The other half of - * that worry — a `serializeCart` that quietly stopped emitting the field — is - * now pinned at the producer instead (#136): `carts.http.contract.test.ts` - * asserts the wire carries `state`, so a silent drop fails CI rather than - * reaching this log. + * that worry — a `serializeCart` that quietly stopped emitting the field — + * was meant to be pinned at the producer instead (#136), but the test that did + * that pinning lived in the now-deleted `@otta-sh/service` + * (`carts.http.contract.test.ts`). No equivalent presence guard for `state` is + * confirmed to exist against `InProcessCommerceClient`'s `serializeCart` today, + * so this log is this field's only backstop again. */ export function isKnownCartState(state: string | undefined): boolean { return state === "active" || state === "checked_out"; diff --git a/sites/staging/src/lib/email.ts b/sites/staging/src/lib/email.ts index 1d574714..174854bf 100644 --- a/sites/staging/src/lib/email.ts +++ b/sites/staging/src/lib/email.ts @@ -1,8 +1,9 @@ /** * The `buyerRef` guard — a check NOTHING upstream performs. * - * `POST /checkout/orders` types `buyerRef` as `z.string().min(1).max(320)` with - * no regex (`packages/service/src/schemas.ts`), and the domain treats it as an + * `POST /checkout/orders` types `buyerRef` as a length-only bound, max 320, + * with no regex (`checkout-route-input.ts`'s `nonEmptyString` / + * `BUYER_REF_MAX`), and the domain treats it as an * opaque string. That permissiveness is deliberate — the field is documented as * an "email/session claim token", not strictly an email — so `"asdf"`, `" "` * and `"jo@"` all produce a perfectly valid order. The consequences are not diff --git a/sites/staging/test/cart-page.test.ts b/sites/staging/test/cart-page.test.ts index a9d22828..fb0ed86c 100644 --- a/sites/staging/test/cart-page.test.ts +++ b/sites/staging/test/cart-page.test.ts @@ -547,9 +547,10 @@ describe("a checked-out cart is rendered as terminal, and never as a paid one", // whether to permit a mutation and must fail closed. This is a renderer // deciding which screen to draw, and failing closed here would brick a // LIVE cart read-only — no qty field, no remove, no way to check out — on - // a value nothing validates at runtime (`CartWire.state` is `string`, and - // `HttpCommerceClient`'s `#cartResult` blind-casts after an envelope-only - // check). An unknown state renders as the live cart it probably is. + // a value nothing narrows at runtime (`CartWire.state` is `string`, + // deliberately wider than the domain `CartState` union + // `InProcessCommerceClient`'s `serializeCart` copies it from). An unknown + // state renders as the live cart it probably is. expect(isCartTerminal("frozen")).toBe(false); }); @@ -563,14 +564,14 @@ describe("a checked-out cart is rendered as terminal, and never as a paid one", test("the wire type really does carry the state this page now reads", () => { // HONEST SCOPE: this pins the `CartWire` TypeScript DECLARATION, not what - // the service emits. `serializeCart` dropping the field would compile - // perfectly and arrive here as `undefined` — which the narrow fence above - // then renders as a live cart. That is still the failure mode this test - // does not cover, but it is no longer uncovered anywhere: #136 is closed, - // and `packages/service/test/carts.http.contract.test.ts` now asserts - // `toHaveProperty("state")` where the field is PRODUCED. The - // `console.warn` pinned below is the runtime backstop, no longer the only - // thing that would say so out loud. + // the plugin emits. `serializeCart` (`in-process-commerce-client.ts`) + // dropping the field would compile perfectly and arrive here as + // `undefined` — which the narrow fence above then renders as a live cart. + // #136 is closed by the `orderId` presence guard in + // `packages/plugin/test/cart-routes.sandbox.test.ts` (see the test below); + // no equivalent presence guard for `state` specifically is confirmed to + // exist there today. The `console.warn` pinned below is the runtime + // backstop either way. const cart: CartWire = { cartId: "cart_1", state: "checked_out", @@ -670,24 +671,25 @@ describe("a checked-out cart is rendered as terminal, and never as a paid one", // guarantees: // // - a MALFORMED value (`undefined`, `""`, a non-string over a skewed - // wire) is the plugin's: `HttpCommerceClient.getCart` coerces it to - // `null` before any consumer sees it, pinned in the plugin's own - // tests, at the wire boundary where the skew lands. - // - the field DISAPPEARING is not, and the coercion cannot catch it — + // wire) is the plugin's: `InProcessCommerceClient`'s `serializeCart` + // (`in-process-commerce-client.ts`) copies the domain `Cart.orderId`, + // itself `OrderId | null` and never absent, so the declaration is + // honest at runtime and not merely by assertion. + // - the field DISAPPEARING is not, and that typing cannot catch it — // it tolerates absence BY DESIGN (missing ⇒ `null`). A `serializeCart` // that silently stopped emitting `orderId` would drop every // checked-out cart to case B with this whole suite green: #110 again, - // in muted form. What covers it is the same thing that covers `state` - // three tests above — `packages/service/test/carts.http.contract.test.ts` - // asserts `toHaveProperty("orderId")` where the field is PRODUCED, so + // in muted form. What covers it is + // `packages/plugin/test/cart-routes.sandbox.test.ts`'s + // `toHaveProperty("orderId")` assertion where the field is PRODUCED, so // a silent drop fails CI instead of reaching a shopper. // // So the DECLARATION half of this test is enforced by the TYPE gates, not // by vitest: dropping `orderId` from `CartWire` leaves this file green and // reddens both of them — root `pnpm typecheck` (`tsc -b` over `packages/*` // only; the root tsconfig is `files: []` plus package references and does - // not reach `sites/*`) because `http-commerce-client.ts` reads and assigns - // the field, and `sites/staging`'s own `astro check` — CI reaches it via + // not reach `sites/*`) because `in-process-commerce-client.ts` reads and + // assigns the field, and `sites/staging`'s own `astro check` — CI reaches it via // `pnpm -r --if-present typecheck` — because of this fixture. Verified by // doing exactly that. The executed assertions below are vitest's share. const named: CartWire = { From a0a47b19f931d42813aff391f3bd52cf02aca196 Mon Sep 17 00:00:00 2001 From: Vedanshu Date: Sun, 20 Sep 2026 12:32:43 +0000 Subject: [PATCH 3/5] [Docs] Re-point the 59 changesets that named the two deleted packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit INC-D3b. `changeset version` hard-fails on a changeset naming a package that is no longer in the workspace, so this is part of the deletion rather than housekeeping after it. Of 148 unreleased changesets, 59 named `@otta-sh/service` or `@otta-sh/store-postgres` in their frontmatter. 51 also named a surviving package and were re-pointed — the dead frontmatter line dropped, every surviving line and the note kept. 8 were entirely about a deleted package and are removed outright. 140 changesets remain. Five of the re-pointed notes needed a prose edit too, because their summaries made a claim that spanned the dead and surviving packages and stopped being true once the line went: a count of "all four published packages", a dangling "store-postgres stays patch" clause, and three paragraphs explaining bump reasoning for a package that will never be bumped again. Left deliberately: 25 notes still describe, in the past tense, what shipped inside those packages at the time. That is accurate history and `changeset version` only reads frontmatter, so nothing breaks — but the released CHANGELOG will carry bullets attributed to packages that no longer exist, which is worth a pass in the D4/D5 docs increments. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CQbJYWWm8tf8owshm7XRp8 --- .changeset/admin-orders-console.md | 2 - .changeset/admin-products-console-list.md | 2 - .../admin-products-onhand-projection.md | 5 +- .changeset/admin-wire-completeness.md | 4 +- .changeset/after-publish-activate.md | 2 - .changeset/batch-order-items-insert.md | 9 --- .changeset/batch-product-snapshot.md | 2 - .changeset/batch-reservation-adopt-commit.md | 1 - .changeset/cart-order-id.md | 2 - .changeset/cart-thread-productid.md | 1 - .changeset/checkout-address-capture.md | 2 - .changeset/coupon-admin-list.md | 2 - .changeset/cursor-filter-fail-closed.md | 79 ------------------- .changeset/entitlement-lookup-indices.md | 30 ------- .changeset/entitlements-check-auth.md | 2 - .changeset/fix-public-order-redaction.md | 44 ----------- .changeset/fix-reservation-not-found-404.md | 2 - .changeset/fix-rules-admin-read-gate.md | 43 ---------- .../in-process-email-and-x402-settlement.md | 1 - .changeset/low-stock-list-predicate.md | 1 - .changeset/low-stock-server-side-predicate.md | 3 +- .changeset/merchant-restock.md | 2 - .changeset/order-cancel-with-reason.md | 2 - .changeset/order-customer-context.md | 2 - .changeset/order-fulfillment-tracking.md | 2 - .changeset/order-lookup-indices.md | 12 --- .changeset/order-notes-walking-skeleton.md | 2 - .changeset/order-refunds.md | 2 - .changeset/order-timeline-audit.md | 2 - .changeset/orders-search-by-snapshot-sku.md | 9 +-- .../orders-search-prefix-and-substring.md | 6 -- .changeset/oss-publish-metadata.md | 2 - .changeset/phase-0-atomic-inventory.md | 2 - .changeset/phase-1-plugin.md | 1 - .changeset/phase-1-product-model-and-sync.md | 2 - .changeset/phase-2-catalog-display.md | 2 - .changeset/phase-3-cart-and-inventory.md | 2 - .changeset/phase-4-checkout-and-gateways.md | 2 - .changeset/phase-5-orders-customers-emails.md | 2 - .changeset/phase-6-shipping-tax-coupons.md | 2 - .changeset/phase-7-reports-and-settings.md | 2 - .changeset/product-data-model-adds.md | 2 - .changeset/product-edit-page.md | 2 - .changeset/product-lifecycle-surfacing.md | 2 - .changeset/product-variants-model.md | 1 - .changeset/qty-upper-bound.md | 37 --------- .changeset/rebrand-otta.md | 2 - .changeset/resolve-reconciliation.md | 2 - .changeset/retire-service-deployment.md | 1 - .changeset/rules-update-delete.md | 2 - .changeset/seed-inventory-on-first-sku.md | 1 - .changeset/service-token-gate.md | 1 - .changeset/service-worker-deploy.md | 77 ------------------ .changeset/sku-rename-carries-stock.md | 1 - .changeset/sku-rename-refusal-is-legible.md | 1 - .changeset/stripe-live-payment-intent.md | 1 - .changeset/tax-class-verbs-closeout.md | 2 - .changeset/title-single-writer.md | 2 - .../variants-rest-and-cart-sku-guard.md | 1 - playwright.config.ts | 47 +++++------ 60 files changed, 25 insertions(+), 459 deletions(-) delete mode 100644 .changeset/batch-order-items-insert.md delete mode 100644 .changeset/cursor-filter-fail-closed.md delete mode 100644 .changeset/entitlement-lookup-indices.md delete mode 100644 .changeset/fix-public-order-redaction.md delete mode 100644 .changeset/fix-rules-admin-read-gate.md delete mode 100644 .changeset/order-lookup-indices.md delete mode 100644 .changeset/qty-upper-bound.md delete mode 100644 .changeset/service-worker-deploy.md diff --git a/.changeset/admin-orders-console.md b/.changeset/admin-orders-console.md index 83ac4652..2dcb44be 100644 --- a/.changeset/admin-orders-console.md +++ b/.changeset/admin-orders-console.md @@ -1,7 +1,5 @@ --- "@otta-sh/domain": minor -"@otta-sh/store-postgres": minor -"@otta-sh/service": minor "@otta-sh/plugin": minor --- diff --git a/.changeset/admin-products-console-list.md b/.changeset/admin-products-console-list.md index 1e252a8b..3c10f013 100644 --- a/.changeset/admin-products-console-list.md +++ b/.changeset/admin-products-console-list.md @@ -1,7 +1,5 @@ --- "@otta-sh/domain": minor -"@otta-sh/store-postgres": minor -"@otta-sh/service": minor "@otta-sh/plugin": minor --- diff --git a/.changeset/admin-products-onhand-projection.md b/.changeset/admin-products-onhand-projection.md index 2763991e..4973c4ae 100644 --- a/.changeset/admin-products-onhand-projection.md +++ b/.changeset/admin-products-onhand-projection.md @@ -1,8 +1,6 @@ --- "@otta-sh/domain": minor -"@otta-sh/service": minor "@otta-sh/plugin": minor -"@otta-sh/store-postgres": patch --- Carry stock on the admin Products list projection, and the product title on the @@ -13,8 +11,7 @@ learn whether anything was in stock; the low-stock report listed bare SKUs. `ProductSummary` (and the `GET /admin/products` wire) gains `onHand: number | null`, and `LowStockRow` (and `GET /reports/low-stock`) gains `title: string | null`. Both are REQUIRED fields on exported interfaces, hence `minor` for the -packages that export them; `store-postgres` changes adapter behaviour only and -stays `patch` — the same split as `title-single-writer`. +packages that export them. **`null` is not `0`.** `onHand: null` means there is no `inventory` record for the sku — "unknown" — while `0` means a known sku that is out of stock. Nothing diff --git a/.changeset/admin-wire-completeness.md b/.changeset/admin-wire-completeness.md index f86cd39d..3899e8ff 100644 --- a/.changeset/admin-wire-completeness.md +++ b/.changeset/admin-wire-completeness.md @@ -1,7 +1,5 @@ --- "@otta-sh/domain": minor -"@otta-sh/store-postgres": minor -"@otta-sh/service": minor "@otta-sh/plugin": minor --- @@ -9,7 +7,7 @@ Close the three recorded gaps where the admin wire knew something the console could not say (INC-23). One theme — the wire stops lying by omission — across a refunded amount that existed nowhere, a stock count the detail collapsed, and a set size the lists never sent. Three required port members, one required wire -field and one widened wire type, hence `minor` on all four published packages. +field and one widened wire type, hence `minor` on both published packages. - `@otta-sh/domain`: `PeriodBucket` gains a required `refundedCents: Cents` beside `revenueCents` — money returned on the orders in that bucket, per diff --git a/.changeset/after-publish-activate.md b/.changeset/after-publish-activate.md index 4f7c6f72..ecd81194 100644 --- a/.changeset/after-publish-activate.md +++ b/.changeset/after-publish-activate.md @@ -1,7 +1,5 @@ --- "@otta-sh/domain": minor -"@otta-sh/store-postgres": minor -"@otta-sh/service": minor "@otta-sh/plugin": minor --- diff --git a/.changeset/batch-order-items-insert.md b/.changeset/batch-order-items-insert.md deleted file mode 100644 index f9c44c14..00000000 --- a/.changeset/batch-order-items-insert.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@otta-sh/store-postgres": patch ---- - -`KyselyOrderStore.createFromCart` now writes `order_items` in one multi-row INSERT -instead of a per-line loop of single-row inserts. An N-line checkout emits one -`order_items` statement rather than N — inside the same transaction, with an -`id` still minted per line and every column mapping unchanged. Internal adapter -perf only: no port, wire-format, or return-shape change. diff --git a/.changeset/batch-product-snapshot.md b/.changeset/batch-product-snapshot.md index 14676ed8..ab71343d 100644 --- a/.changeset/batch-product-snapshot.md +++ b/.changeset/batch-product-snapshot.md @@ -1,7 +1,5 @@ --- "@otta-sh/domain": minor -"@otta-sh/store-postgres": minor -"@otta-sh/service": patch --- Removes the per-cart-line N+1 product-snapshot read in both checkout paths by adding a single bulk store method and rewiring both callers to fetch once. Snapshot semantics are unchanged: an order line still snapshots price + title at purchase time, and every per-line null / price / currency / kind check is byte-for-byte identical. diff --git a/.changeset/batch-reservation-adopt-commit.md b/.changeset/batch-reservation-adopt-commit.md index 74ee33cd..9ce7e65c 100644 --- a/.changeset/batch-reservation-adopt-commit.md +++ b/.changeset/batch-reservation-adopt-commit.md @@ -1,6 +1,5 @@ --- "@otta-sh/domain": minor -"@otta-sh/store-postgres": minor --- Batch the per-line checkout ADOPT and settle COMMIT into single guarded UPDATE diff --git a/.changeset/cart-order-id.md b/.changeset/cart-order-id.md index 56545973..7e5fc40d 100644 --- a/.changeset/cart-order-id.md +++ b/.changeset/cart-order-id.md @@ -1,7 +1,5 @@ --- "@otta-sh/domain": minor -"@otta-sh/store-postgres": minor -"@otta-sh/service": minor "@otta-sh/plugin": minor --- diff --git a/.changeset/cart-thread-productid.md b/.changeset/cart-thread-productid.md index 1d2e8494..c77ae9e3 100644 --- a/.changeset/cart-thread-productid.md +++ b/.changeset/cart-thread-productid.md @@ -1,6 +1,5 @@ --- "@otta-sh/plugin": patch -"@otta-sh/service": patch --- Thread `productId` through the storefront add-to-cart path so a storefront cart diff --git a/.changeset/checkout-address-capture.md b/.changeset/checkout-address-capture.md index 980d7037..53ff8615 100644 --- a/.changeset/checkout-address-capture.md +++ b/.changeset/checkout-address-capture.md @@ -1,7 +1,5 @@ --- "@otta-sh/domain": minor -"@otta-sh/store-postgres": minor -"@otta-sh/service": minor "@otta-sh/plugin": minor --- diff --git a/.changeset/coupon-admin-list.md b/.changeset/coupon-admin-list.md index d2b1802f..f4108d64 100644 --- a/.changeset/coupon-admin-list.md +++ b/.changeset/coupon-admin-list.md @@ -1,7 +1,5 @@ --- "@otta-sh/domain": minor -"@otta-sh/store-postgres": minor -"@otta-sh/service": minor "@otta-sh/plugin": minor --- diff --git a/.changeset/cursor-filter-fail-closed.md b/.changeset/cursor-filter-fail-closed.md deleted file mode 100644 index dff221bc..00000000 --- a/.changeset/cursor-filter-fail-closed.md +++ /dev/null @@ -1,79 +0,0 @@ ---- -"@otta-sh/service": minor ---- - -A cursor that disagrees with the request's own filter params is now a 400 on both admin -list routes (`GET /admin/orders`, `GET /admin/products`), instead of a 200 whose rows -answer a different question than the request asked. - -The opaque cursor carries the filter it was minted under, so paging preserves it. But a -request may also spell that filter out in the query string, and the two arms never met: -whenever a cursor was present the routes took the predicate SOLELY from the token and -never read the query's filter params at all. An unfiltered token sent beside -`?states=paid` answered 200 with the unfiltered set — four orders under a request naming -only the paid ones, with nothing in the response admitting the substitution. - -Two reasons to close it. The first is defense in depth on a token-guarded REST surface: -a route that accepts two descriptions of one page and silently discards one of them can -only be relied on by callers that already know which half wins, and "the rows quietly -disagreed with the request" is the class of divergence nobody can see in a log. The -second is concrete and near: the admin console is about to start deriving its list -filters from the URL and sending them alongside the cursor it already sends, which turns -a disagreeing pair from something no client emits into something a stale link, a back -button or a hand-edited parameter produces routinely. Better that the service answer -before that lands than after. - -- **Present params must agree; absent ones claim nothing.** A cursor-alone request is - untouched — byte-identical to before, which is what every current client sends. A - cursor beside AGREEING params is byte-identical to that same cursor alone: agreeing - params are redundant, not a second opinion. A cursor beside DISAGREEING params is - `400 {"error":"cursor filter mismatch"}`, the same envelope as the neighbouring - invalid-cursor and invalid-states-filter 400s, with its own value so the two causes - stay tellable apart. A request with no cursor at all is unchanged. -- **Two obligations this puts on a client that sends both.** (1) A paged request must - send the RESOLVED instants the cursor was minted with, not the period they came from: - re-resolving "last 30 days" at page-two time yields different `from`/`to` values, which - is a genuinely different predicate and will 400 — correctly, because the rows behind - that cursor are not the rows that window now describes. Carry the resolved bounds - alongside the cursor, or send the cursor alone. (2) `cursor filter mismatch` means - "drop the cursor and re-issue page one with these parameters", not "show the operator - an error": the request is answerable, just not from that token, and the recovery is - mechanical. It is deliberately one code across the filter and limit axes for that - reason — one condition, one remedy. -- **Compared as predicates, not as spelling**, so an agreeing request cannot 400 by - accident: key order is irrelevant, an absent axis and an `undefined` one are the same - thing, an OR-able array is a SET (`states=paid,cancelled` and `states=cancelled,paid,paid` - select the same rows and so agree), and a window bound is an INSTANT rather than a - string (`...T00:00:00Z` agrees with `...T00:00:00.000Z`). Case is deliberately not - folded — the store's case-insensitivity is the store's business, and a token - round-trips whatever the query said. -- **`deleted=false` and an omitted `deleted` are one predicate, and agree.** The - tombstone axis is `deleted_at IS NULL` for every value except `true`, so the two - spellings issue identical SQL; comparing them as distinct would 400 one predicate - written two ways. `active=false` is NOT that — the store emits a real `active = 0` - against an integer column — so it keeps disagreeing with an omitted `active`. The - asymmetry is the store's, and both halves are pinned. -- **A subset is not agreement.** A request naming only `states` while the token also - carries a date window is a disagreement, not a narrowing: the rows are tighter than - the request describes, which is the same invisible divergence in a quieter form. -- **Every axis participates**, including the products list's low-stock threshold — `0` - is a real threshold and is compared as one, never read as "absent". -- **The page size is compared too**, against the EFFECTIVE limit — what the page will - actually be. The existing re-clamp prefers the token's limit whenever it is a finite - number and clamps it into range, consulting the query's only when the token's is - missing or unusable. So a token carrying `999999` pages at 100 and a `?limit=50` - beside it is a real disagreement, while a token carrying nothing usable pages at - exactly the query's limit and agrees with it. -- **An unparseable `states` beside a cursor** is newly reachable and answers the - invalid-filter 400 the no-cursor arm has always given — one rule, both arms. - -The cursor's contents and the way it pages are unchanged; this only adds the comparison. -Both routes' four quadrants (cursor alone, cursor + agreeing, cursor + disagreeing, -params alone) are pinned by HTTP contract tests against a live server. - -**Follow-up, not done here:** the coupons list in `rules-admin.ts` has the same cursor -shape and the same unclosed gap — its cursor arm still ignores the query's `search` and -`limit` — and is noted as such at the site. `canonicalFilter` and the `has*FilterParams` -predicates are deliberately file-local to `admin.ts` for now; closing coupons should -LIFT them into a shared module and reuse them, never fork a second copy, which would be -free to drift on exactly the canonicalization details they exist to pin. diff --git a/.changeset/entitlement-lookup-indices.md b/.changeset/entitlement-lookup-indices.md deleted file mode 100644 index e9945bdb..00000000 --- a/.changeset/entitlement-lookup-indices.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -"@otta-sh/store-postgres": patch ---- - -Index the entitlement check. `KyselyEntitlementStore#check` — the delivery gate -that decides whether a customer may access what they bought — filters on -`state`, `sku`, and at least one of `order_id` or a case-folded `buyer_ref`, -against a table that carried only its primary key and the UNIQUE on -`grant_idempotency_key`. Every axis of that predicate was a sequential scan. - -Migration `0024` adds two composite b-trees, each led by one of the two scope -axes so the check is a point lookup on either path: `(lower(buyer_ref), sku, -state)` — functional, matching the fold the predicate already uses — and -`(order_id, sku, state)`. `sku` does not lead either: it is the one axis whose -matching set grows with a product's popularity rather than with a single order -or buyer. `state` is carried as an ordinary column rather than as a partial -`WHERE state = 'active'` predicate, because the store binds the state as a -parameter and Postgres can only prove a partial predicate from a parameter under -a custom plan — a partial index would silently fall back to a sequential scan -under a generic one. - -The write path pays two extra b-tree entries and one `lower()` evaluation per -row inserted. `grant` is the table's only writer and runs once per paid digital -line, so that cost lands on a path that already writes a row and never on the -check. - -Both indices apply on SQLite and Postgres with no dialect fork, and an EXPLAIN -test pins each plan against the SQL the store itself compiles, so a rewritten -fold fails loudly instead of quietly losing the index. Forward-only and additive -— no port, wire-format, or return-shape change. diff --git a/.changeset/entitlements-check-auth.md b/.changeset/entitlements-check-auth.md index 4f6c5a33..e1282bc4 100644 --- a/.changeset/entitlements-check-auth.md +++ b/.changeset/entitlements-check-auth.md @@ -1,7 +1,5 @@ --- "@otta-sh/domain": minor -"@otta-sh/store-postgres": minor -"@otta-sh/service": minor "@otta-sh/plugin": minor --- diff --git a/.changeset/fix-public-order-redaction.md b/.changeset/fix-public-order-redaction.md deleted file mode 100644 index cf1e1e89..00000000 --- a/.changeset/fix-public-order-redaction.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -"@otta-sh/service": minor ---- - -Redact PII on the unauthenticated `GET /orders/:orderId` read. - -`GET /orders/:orderId` is an unauthenticated, capability-URL-only read (guess -or leak the order UUID ⇒ a full read). It previously returned `serializeOrder` -verbatim; a new `serializePublicOrder` whitelist projection is now returned -unless the request carries a valid `X-Internal-Token`, in which case the full -`serializeOrder` view is returned (matching `GET /admin/orders/:id` and -`GET /me/orders/:id`). An absent, empty, or wrong token DEGRADES to the -redacted view — never 401/503 — so a guest's "track my order" link keeps -working whether or not the internal token is even configured. - -At `0.x`, changesets map a **minor** bump to a breaking change (there is no -major to take yet — semver's `0.x` carve-out). The `minor` here IS the -breaking bump, not a feature bump. - -**BREAKING:** the unauthenticated `GET /orders/:orderId` response no longer -contains `buyerRef`, `customerId`, `shippingAddress`, `reconciliationFlag`, -`reconciliationResolution`, and trims `fulfillment` (drops `recordedBy`/ -`recordedAt`) and `cancellation` (drops `detail`/`cancelledBy`). The full -projection now requires a session (`GET /me/orders/:id`) or a valid -`X-Internal-Token` (this same route). - -`serializePublicOrder` is a WHITELIST, not a delete-list: a future additive -`Order` field is private by default on this route, reversing the "additive — -existing consumers ignore it" habit that made `shippingAddress` silently -public under ADR-0009. - -A GUEST has no session, so `GET /me/orders/:id` is not a fallback for this -unauthenticated read — until a dedicated order-confirmation page exists, a -guest cannot see their own ship-to via this route. If that confirmation UX -ever needs a shipping hint, the widening path is a DERIVED -`shippingAddressSummary` (city + country + a masked postal code) — the raw -`shippingAddress` snapshot should never be reopened on this route. - -Unchanged deliberately: `POST /checkout/orders` still returns the full order -(a write-gated POST whose caller just supplied the address); -`GET /me/orders/:id` (session-scoped) and the `admin.ts` order-detail/console -routes (internal-token-gated) stay full; `entitlements.ts`'s `POST /grant` -also calls the full `serializeOrder`, but it already sits behind -`requireInternalToken`, so it is unaffected. diff --git a/.changeset/fix-reservation-not-found-404.md b/.changeset/fix-reservation-not-found-404.md index 48826dfc..ada67d57 100644 --- a/.changeset/fix-reservation-not-found-404.md +++ b/.changeset/fix-reservation-not-found-404.md @@ -1,7 +1,5 @@ --- "@otta-sh/domain": minor -"@otta-sh/store-postgres": patch -"@otta-sh/service": minor --- Typed 404 for `POST /inventory/commit` and `POST /inventory/release` against an diff --git a/.changeset/fix-rules-admin-read-gate.md b/.changeset/fix-rules-admin-read-gate.md deleted file mode 100644 index 864a0865..00000000 --- a/.changeset/fix-rules-admin-read-gate.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -"@otta-sh/service": minor ---- - -Require the internal token on the whole admin **read** surface, not just the writes -(ADR-0010). - -> At `0.x`, changesets map a **minor** bump to a breaking change (there is no major to take -> yet — semver's `0.x` carve-out). The `minor` here IS the breaking bump, not a feature bump. - -**BREAKING:** any caller that read `/admin/**`, `/reports/**` or `GET /settings` without -`X-Internal-Token` now receives **401** (token set) or **503** (token unset) where it -previously received 200. - -The shipping/tax/coupon admin GET reads (`GET /admin/shipping/zones`, -`/admin/shipping/zones/:zoneId/methods`, `/admin/shipping/methods/:methodId/rates`, -`/admin/tax/classes`, `/admin/tax/rates`, `/admin/coupons/:code`) and `GET /settings` -called their store methods with no auth. Only the write siblings carried -`requireInternalToken`, and the app-level `SERVICE_API_TOKEN` write gate exempts GET/HEAD -by design — so these reads were reachable with **no token at all**, regardless of whether -`SERVICE_API_TOKEN`/`INTERNAL_API_TOKEN` were set. - -The sharpest leak was `GET /admin/coupons/:code`: it returns the full coupon config via -`serializeCoupon` — `amountCents`, `rateBps`, `capCents`, `minSubtotalCents`, `maxUses`, -`maxUsesPerCustomer` and the live `usesCount` — so an unauthenticated caller could -enumerate coupon codes and read their entire discount configuration and remaining usage. -The storefront never needs this (coupon validation and quotes are computed server-side in -`POST /checkout/quote`), so it was never a deliberate public affordance. This also -contradicted `DEPLOYMENT.md` §4, which lists the `/admin/*` rules **CRUD** among endpoints -that answer 503 ("disabled — never silently open") when `INTERNAL_API_TOKEN` is unset. - -Fix: the authoritative guard is registered at the **parent app** in `createApp` — -`app.use` on `/admin/*`, `/reports/*`, `/settings` and `/settings/*`, before any route is -mounted — so those prefixes are default-DENY and a route added later without its own check -is still closed. A sub-app guard could not do this: Hono merges sub-app middleware at mount -time, so a blanket guard inside `rulesAdminRoutes` never covers `adminRoutes`, the sibling -sub-app mounted at `/admin` before it (probed and test-pinned). Sub-app and per-route guards -remain as defense-in-depth; the now-redundant inline calls in `rules-admin.ts` are gone — -one guard, no drift. - -**Operational note:** a deployment that never set `INTERNAL_API_TOKEN` now gets 503 on the -rules and settings reads. Provision the token before deploying — see `DEPLOYMENT.md` §4 and -ADR-0010's Consequences. No wire change for authorized callers. diff --git a/.changeset/in-process-email-and-x402-settlement.md b/.changeset/in-process-email-and-x402-settlement.md index 772102b3..f3bb4a1b 100644 --- a/.changeset/in-process-email-and-x402-settlement.md +++ b/.changeset/in-process-email-and-x402-settlement.md @@ -2,7 +2,6 @@ "@otta-sh/domain": minor "@otta-sh/payments-x402": minor "@otta-sh/plugin": minor -"@otta-sh/service": patch --- Dispatch order emails and settle x402 payments from inside the plugin, over diff --git a/.changeset/low-stock-list-predicate.md b/.changeset/low-stock-list-predicate.md index 402a64f6..fec5e271 100644 --- a/.changeset/low-stock-list-predicate.md +++ b/.changeset/low-stock-list-predicate.md @@ -1,6 +1,5 @@ --- "@otta-sh/domain": minor -"@otta-sh/store-postgres": patch --- Add a low-stock filter to the admin Products list port, so the "Low stock only" diff --git a/.changeset/low-stock-server-side-predicate.md b/.changeset/low-stock-server-side-predicate.md index 042bbc1a..84885a0d 100644 --- a/.changeset/low-stock-server-side-predicate.md +++ b/.changeset/low-stock-server-side-predicate.md @@ -3,7 +3,6 @@ "@otta-sh/admin-react": minor "@otta-sh/domain": minor "@otta-sh/plugin": patch -"@otta-sh/service": patch --- Wire the Pricing & inventory screen's "Low stock only" filter to the server-side @@ -12,7 +11,7 @@ before. The filter now applies to the whole catalogue rather than the rows on one fetched page, and pagination works correctly across a filtered scan. The two presentation packages take the larger bump: they LOSE exported surface, -while the plugin and the service only gain an optional field. +while the plugin only gains an optional field. - `@otta-sh/plugin`: `ProductsListFilter` gains an optional `lowStockThreshold` field, carried on the admin Products list request once the console has diff --git a/.changeset/merchant-restock.md b/.changeset/merchant-restock.md index 56a76eec..1aeb5261 100644 --- a/.changeset/merchant-restock.md +++ b/.changeset/merchant-restock.md @@ -1,7 +1,5 @@ --- "@otta-sh/domain": minor -"@otta-sh/store-postgres": minor -"@otta-sh/service": minor "@otta-sh/plugin": minor --- diff --git a/.changeset/order-cancel-with-reason.md b/.changeset/order-cancel-with-reason.md index 1e41a4d9..5d26b6b5 100644 --- a/.changeset/order-cancel-with-reason.md +++ b/.changeset/order-cancel-with-reason.md @@ -1,7 +1,5 @@ --- "@otta-sh/domain": minor -"@otta-sh/store-postgres": minor -"@otta-sh/service": minor "@otta-sh/plugin": minor --- diff --git a/.changeset/order-customer-context.md b/.changeset/order-customer-context.md index c86cf8fe..2f9fc327 100644 --- a/.changeset/order-customer-context.md +++ b/.changeset/order-customer-context.md @@ -1,7 +1,5 @@ --- "@otta-sh/domain": minor -"@otta-sh/store-postgres": minor -"@otta-sh/service": minor "@otta-sh/plugin": minor --- diff --git a/.changeset/order-fulfillment-tracking.md b/.changeset/order-fulfillment-tracking.md index 650badd0..732d5861 100644 --- a/.changeset/order-fulfillment-tracking.md +++ b/.changeset/order-fulfillment-tracking.md @@ -1,7 +1,5 @@ --- "@otta-sh/domain": minor -"@otta-sh/store-postgres": minor -"@otta-sh/service": minor "@otta-sh/plugin": minor --- diff --git a/.changeset/order-lookup-indices.md b/.changeset/order-lookup-indices.md deleted file mode 100644 index 503bead1..00000000 --- a/.changeset/order-lookup-indices.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@otta-sh/store-postgres": patch ---- - -Add two missing `orders` indices: a composite partial index on `(customer_id, -created_at, id) WHERE customer_id IS NOT NULL` (the storefront order-history -lookup, which fans out per order and sorts by `created_at, id`) and a -functional index on `lower(buyer_ref)` matching the case-folded predicate -every buyer-ref lookup already uses. Both order lookups by customer and by -buyer reference go from a full table scan to an index scan as order volume -grows, and the customer lookup's sort now comes off the index for free. -Internal adapter perf only: no port, wire-format, or return-shape change. diff --git a/.changeset/order-notes-walking-skeleton.md b/.changeset/order-notes-walking-skeleton.md index 005d8bb9..86b34d93 100644 --- a/.changeset/order-notes-walking-skeleton.md +++ b/.changeset/order-notes-walking-skeleton.md @@ -1,7 +1,5 @@ --- "@otta-sh/domain": minor -"@otta-sh/store-postgres": minor -"@otta-sh/service": minor "@otta-sh/plugin": minor --- diff --git a/.changeset/order-refunds.md b/.changeset/order-refunds.md index 96251e28..0a32170b 100644 --- a/.changeset/order-refunds.md +++ b/.changeset/order-refunds.md @@ -2,8 +2,6 @@ "@otta-sh/domain": minor "@otta-sh/payments-stripe": minor "@otta-sh/payments-x402": minor -"@otta-sh/store-postgres": minor -"@otta-sh/service": minor "@otta-sh/plugin": minor --- diff --git a/.changeset/order-timeline-audit.md b/.changeset/order-timeline-audit.md index c5ce95a6..f9a206b6 100644 --- a/.changeset/order-timeline-audit.md +++ b/.changeset/order-timeline-audit.md @@ -1,7 +1,5 @@ --- "@otta-sh/domain": minor -"@otta-sh/store-postgres": minor -"@otta-sh/service": minor "@otta-sh/plugin": minor --- diff --git a/.changeset/orders-search-by-snapshot-sku.md b/.changeset/orders-search-by-snapshot-sku.md index 10d9831f..90c6196a 100644 --- a/.changeset/orders-search-by-snapshot-sku.md +++ b/.changeset/orders-search-by-snapshot-sku.md @@ -1,9 +1,7 @@ --- "@otta-sh/domain": minor -"@otta-sh/store-postgres": minor "@otta-sh/admin-presentation": patch "@otta-sh/admin-react": patch -"@otta-sh/service": patch --- Orders search gains a third axis: the SKU frozen onto an order's lines at purchase time. @@ -20,10 +18,9 @@ the order, a partial SKU finds nothing. That is the same principle behind the pr `Search (SKU exact, or title contains)`, and both labels are now pinned side by side, plus a mounted check that the sentence actually reaches the control an operator types into. -`@otta-sh/service` is bumped because its `GET /admin/orders` answers differently for the same -query, though no service source changed — only its test coverage. `@otta-sh/admin-presentation` -and `@otta-sh/admin-react` are bumped for the label. `@otta-sh/plugin` is NOT bumped: it forwards -`search` verbatim, and the Orders list it renders is the React one. +`@otta-sh/admin-presentation` and `@otta-sh/admin-react` are bumped for the label. +`@otta-sh/plugin` is NOT bumped: it forwards `search` verbatim, and the Orders list it renders is +the React one. - **The purchase-time snapshot, not the live catalogue.** The sku compared is the one on the order's own lines — the insert-once snapshot the detail screen renders. Renaming a product's sku diff --git a/.changeset/orders-search-prefix-and-substring.md b/.changeset/orders-search-prefix-and-substring.md index 5798da3c..bd86ecea 100644 --- a/.changeset/orders-search-prefix-and-substring.md +++ b/.changeset/orders-search-prefix-and-substring.md @@ -1,7 +1,5 @@ --- "@otta-sh/domain": minor -"@otta-sh/store-postgres": minor -"@otta-sh/service": patch --- Orders search stops being exact-match only. `OrderListFilter.search` now matches an order-id @@ -10,10 +8,6 @@ exact lookup that worked before still RETURNS the same row — a whole id is its whole address its own substring — but it no longer runs the same PLAN: the old exact pair was served by an index and the new predicate scans (see below). Results preserved, cost changed. -`@otta-sh/service` is bumped because its `GET /admin/orders` answers differently for the same -query, though no service source changed — only its test coverage. `@otta-sh/plugin` is NOT -bumped: it forwards `search` verbatim and has no code, wire or copy change here. - - **A prefix, because a prefix is all the operator can see.** The console never renders a full uuid — it renders the shortest unique prefix (the git-style short id). Pasting the characters on screen back into the search box used to return nothing, which made the one identifier the diff --git a/.changeset/oss-publish-metadata.md b/.changeset/oss-publish-metadata.md index 45e10fde..547e67d0 100644 --- a/.changeset/oss-publish-metadata.md +++ b/.changeset/oss-publish-metadata.md @@ -3,8 +3,6 @@ "@otta-sh/payments-stripe": patch "@otta-sh/payments-x402": patch "@otta-sh/plugin": patch -"@otta-sh/service": patch -"@otta-sh/store-postgres": patch --- Add repository/homepage/bugs metadata to all publishable packages ahead of open-source diff --git a/.changeset/phase-0-atomic-inventory.md b/.changeset/phase-0-atomic-inventory.md index a706f1b2..1ce58d33 100644 --- a/.changeset/phase-0-atomic-inventory.md +++ b/.changeset/phase-0-atomic-inventory.md @@ -1,7 +1,5 @@ --- "@otta-sh/domain": minor -"@otta-sh/store-postgres": minor -"@otta-sh/service": minor --- Phase 0 — atomic inventory skeleton. diff --git a/.changeset/phase-1-plugin.md b/.changeset/phase-1-plugin.md index 492fd274..b7c8e7c8 100644 --- a/.changeset/phase-1-plugin.md +++ b/.changeset/phase-1-plugin.md @@ -1,6 +1,5 @@ --- "@otta-sh/plugin": minor -"@otta-sh/service": minor --- Phase 1 — `@otta-sh/plugin`, the first Otta EmDash plugin package: sandbox-clean diff --git a/.changeset/phase-1-product-model-and-sync.md b/.changeset/phase-1-product-model-and-sync.md index a8fe8168..52c3868f 100644 --- a/.changeset/phase-1-product-model-and-sync.md +++ b/.changeset/phase-1-product-model-and-sync.md @@ -1,7 +1,5 @@ --- "@otta-sh/domain": minor -"@otta-sh/store-postgres": minor -"@otta-sh/service": minor --- Phase 1 — product model + sync (domain/adapter/service slice). diff --git a/.changeset/phase-2-catalog-display.md b/.changeset/phase-2-catalog-display.md index ecfb0752..ba946078 100644 --- a/.changeset/phase-2-catalog-display.md +++ b/.changeset/phase-2-catalog-display.md @@ -1,7 +1,5 @@ --- "@otta-sh/domain": minor -"@otta-sh/store-postgres": minor -"@otta-sh/service": minor "@otta-sh/plugin": minor --- diff --git a/.changeset/phase-3-cart-and-inventory.md b/.changeset/phase-3-cart-and-inventory.md index 77e7bcb4..da5e72f9 100644 --- a/.changeset/phase-3-cart-and-inventory.md +++ b/.changeset/phase-3-cart-and-inventory.md @@ -1,7 +1,5 @@ --- "@otta-sh/domain": minor -"@otta-sh/store-postgres": minor -"@otta-sh/service": minor --- Phase 3 — cart + inventory (service-side; plugin/storefront deferred to Wave 3). diff --git a/.changeset/phase-4-checkout-and-gateways.md b/.changeset/phase-4-checkout-and-gateways.md index d5688ebd..0f325304 100644 --- a/.changeset/phase-4-checkout-and-gateways.md +++ b/.changeset/phase-4-checkout-and-gateways.md @@ -1,9 +1,7 @@ --- "@otta-sh/domain": minor -"@otta-sh/store-postgres": minor "@otta-sh/payments-stripe": minor "@otta-sh/payments-x402": minor -"@otta-sh/service": minor "@otta-sh/plugin": minor --- diff --git a/.changeset/phase-5-orders-customers-emails.md b/.changeset/phase-5-orders-customers-emails.md index 609ae3a7..6dbdb1aa 100644 --- a/.changeset/phase-5-orders-customers-emails.md +++ b/.changeset/phase-5-orders-customers-emails.md @@ -1,7 +1,5 @@ --- "@otta-sh/domain": minor -"@otta-sh/store-postgres": minor -"@otta-sh/service": minor "@otta-sh/plugin": minor --- diff --git a/.changeset/phase-6-shipping-tax-coupons.md b/.changeset/phase-6-shipping-tax-coupons.md index e7a01464..c30340b0 100644 --- a/.changeset/phase-6-shipping-tax-coupons.md +++ b/.changeset/phase-6-shipping-tax-coupons.md @@ -1,7 +1,5 @@ --- "@otta-sh/domain": minor -"@otta-sh/store-postgres": minor -"@otta-sh/service": minor --- Phase 6 — shipping / tax / coupons. Replaces the Phase-4 checkout-totals stub diff --git a/.changeset/phase-7-reports-and-settings.md b/.changeset/phase-7-reports-and-settings.md index b4349fd4..a5137b29 100644 --- a/.changeset/phase-7-reports-and-settings.md +++ b/.changeset/phase-7-reports-and-settings.md @@ -1,7 +1,5 @@ --- "@otta-sh/domain": minor -"@otta-sh/store-postgres": minor -"@otta-sh/service": minor "@otta-sh/plugin": minor --- diff --git a/.changeset/product-data-model-adds.md b/.changeset/product-data-model-adds.md index 78415995..c37ba031 100644 --- a/.changeset/product-data-model-adds.md +++ b/.changeset/product-data-model-adds.md @@ -1,7 +1,5 @@ --- "@otta-sh/domain": minor -"@otta-sh/store-postgres": minor -"@otta-sh/service": minor "@otta-sh/plugin": minor --- diff --git a/.changeset/product-edit-page.md b/.changeset/product-edit-page.md index d9135827..940d74b7 100644 --- a/.changeset/product-edit-page.md +++ b/.changeset/product-edit-page.md @@ -1,7 +1,5 @@ --- "@otta-sh/domain": minor -"@otta-sh/store-postgres": minor -"@otta-sh/service": minor "@otta-sh/plugin": minor --- diff --git a/.changeset/product-lifecycle-surfacing.md b/.changeset/product-lifecycle-surfacing.md index ecc4d9c9..88effed5 100644 --- a/.changeset/product-lifecycle-surfacing.md +++ b/.changeset/product-lifecycle-surfacing.md @@ -1,7 +1,5 @@ --- "@otta-sh/domain": minor -"@otta-sh/store-postgres": minor -"@otta-sh/service": minor "@otta-sh/plugin": minor --- diff --git a/.changeset/product-variants-model.md b/.changeset/product-variants-model.md index 80b34955..4999e62c 100644 --- a/.changeset/product-variants-model.md +++ b/.changeset/product-variants-model.md @@ -1,6 +1,5 @@ --- "@otta-sh/domain": minor -"@otta-sh/store-postgres": minor --- One commerce row per sellable unit: a product can now carry variants, keyed by the product plus a diff --git a/.changeset/qty-upper-bound.md b/.changeset/qty-upper-bound.md deleted file mode 100644 index 2e399049..00000000 --- a/.changeset/qty-upper-bound.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -"@otta-sh/service": minor ---- - -Wire-level upper bounds on the three unbounded `qty` sites (service-hardening plan §4): -`POST /carts/:cartId/lines`, `PATCH /carts/:cartId/lines/:lineId`, and -`POST /inventory/reserve`. Today `qty: 1e9` (or `Number.MAX_SAFE_INTEGER`) is a "valid" wire -request — only the store's arithmetic ever rejects it — so an absurd value reaches the store -before anything says no. A zod `.max()` makes "how much may one request ask for" an explicit, -documented, tested part of the contract instead of an accident of IEEE-754, and rejects it -early and cheaply (400 at the schema boundary, before any store call and before any row is -written). - -At `0.x`, changesets map a **minor** bump to a breaking change (there is no major to take yet — -semver's `0.x` carve-out). The `minor` here IS the breaking bump, not a feature bump. - -**BREAKING (wire-visible):** previously-accepted requests now fail — `qty > 10_000` -(`CART_LINE_MAX_QTY`, new exported constant) on `POST /carts/:cartId/lines` and -`PATCH /carts/:cartId/lines/:lineId`, and `qty > 1_000_000_000` (`RESERVE_MAX_QTY`, new -exported constant, aligned with the existing admin `stockMovementBody` cap) on -`POST /inventory/reserve`, now return **400** `{error: "invalid request body", issues: [...]}` -where they were previously accepted and processed. Both caps are two different numbers, -deliberately: cart lines are the shopper-facing, anonymous-internet-caller surface (10k is -already absurd for a storefront line); `/inventory/reserve` is the raw inventory primitive (a -machine caller), whose natural peer is the admin stock-movement cap. Both are wire-only -(zod, `schemas.ts`) — the domain already enforces the positive-integer bound -(`domain/src/inventory/use-cases.ts`) as defense-in-depth; no domain or port change. - -**Scope — read before assuming this closes the abuse surface:** this cap does **not** stop -junk-`failed`-reservation-row amplification or general write amplification on -`POST /inventory/reserve` / `POST /carts/:id/lines`. That is bound by **request count**, not -qty magnitude — a caller sending 10,000 requests at `qty: 9,999` (comfortably under either cap) -mints exactly as many junk rows as one request at `qty: 1e9` did before this change. The real -mitigation is rate limiting / abuse control on these two unauthenticated write endpoints, which -this repo does not have. Follow-up filed and tracked at -[UrumiAI/otta.sh#91](https://github.com/UrumiAI/otta.sh/issues/91) — do not read this PR as a -DoS fix. diff --git a/.changeset/rebrand-otta.md b/.changeset/rebrand-otta.md index 18ec6f58..cb758ab7 100644 --- a/.changeset/rebrand-otta.md +++ b/.changeset/rebrand-otta.md @@ -3,8 +3,6 @@ "@otta-sh/payments-stripe": patch "@otta-sh/payments-x402": patch "@otta-sh/plugin": patch -"@otta-sh/service": patch -"@otta-sh/store-postgres": patch --- Rebrand Urumi to Otta. The npm scope is now `@otta-sh/*` (was `@urumi/*`). diff --git a/.changeset/resolve-reconciliation.md b/.changeset/resolve-reconciliation.md index ca5bd34a..22b82e95 100644 --- a/.changeset/resolve-reconciliation.md +++ b/.changeset/resolve-reconciliation.md @@ -1,7 +1,5 @@ --- "@otta-sh/domain": minor -"@otta-sh/store-postgres": minor -"@otta-sh/service": minor "@otta-sh/plugin": minor --- diff --git a/.changeset/retire-service-deployment.md b/.changeset/retire-service-deployment.md index 5d74d69c..e306ae6c 100644 --- a/.changeset/retire-service-deployment.md +++ b/.changeset/retire-service-deployment.md @@ -1,6 +1,5 @@ --- "@otta-sh/plugin": minor -"@otta-sh/service": patch --- Retire the commerce-service deployment and the two-mode plumbing. diff --git a/.changeset/rules-update-delete.md b/.changeset/rules-update-delete.md index 77ef4ef0..f68ea507 100644 --- a/.changeset/rules-update-delete.md +++ b/.changeset/rules-update-delete.md @@ -1,7 +1,5 @@ --- "@otta-sh/domain": minor -"@otta-sh/store-postgres": minor -"@otta-sh/service": minor "@otta-sh/plugin": minor --- diff --git a/.changeset/seed-inventory-on-first-sku.md b/.changeset/seed-inventory-on-first-sku.md index 3676cfd5..756a5df8 100644 --- a/.changeset/seed-inventory-on-first-sku.md +++ b/.changeset/seed-inventory-on-first-sku.md @@ -1,6 +1,5 @@ --- "@otta-sh/domain": patch -"@otta-sh/service": patch "@otta-sh/plugin": patch --- diff --git a/.changeset/service-token-gate.md b/.changeset/service-token-gate.md index 04f969d4..6f3eb923 100644 --- a/.changeset/service-token-gate.md +++ b/.changeset/service-token-gate.md @@ -1,5 +1,4 @@ --- -"@otta-sh/service": minor "@otta-sh/plugin": minor --- diff --git a/.changeset/service-worker-deploy.md b/.changeset/service-worker-deploy.md deleted file mode 100644 index 18cb41bc..00000000 --- a/.changeset/service-worker-deploy.md +++ /dev/null @@ -1,77 +0,0 @@ ---- -"@otta-sh/service": minor -"@otta-sh/store-postgres": minor ---- - -Cloudflare Worker deploy entry for `@otta-sh/service`, plus the sqlite-free -`@otta-sh/store-postgres/pg` subpath it bundles from. Additive — the Node entry -and every existing consumer are behavior-identical. - -- **`@otta-sh/service/worker`** (`src/worker.ts`): `createWorker(overrides?)` - factory returning `{ fetch, scheduled }`, with `export default - createWorker()` for wrangler. Per-event pg Pool/Kysely/stores/app - (`{ max: 5, idleTimeoutMillis: 0 }`, destroyed via `ctx.waitUntil` in a - `finally` on every path — a cross-request pool is a bug on workerd); - closure-scoped memos for parsed config and lazy first-event migrations - (rejection clears the memo so the next event retries); pre-app failures - surface as the standard `{ok:false,error:"internal_error"}` 500. The - `scheduled` cron handler calls the `expireHolds` AND (Phase 4) `expireOrders` - domain use-cases directly (no HTTP self-call, no secret dependency), logging - and never throwing — on Workers this cron is order expiry's production - driver (it is clock-driven, unlike lazy-on-read hold expiry). Each sweep has - its own catch + log label, so a persistently failing hold sweep cannot - starve order expiry. Phase 4 gateways wire from env bindings exactly like - the Node bin (`wrangler secret put STRIPE_WEBHOOK_SECRET` etc.; x402 keeps - its fail-closed test-facilitator opt-in), memoized per isolate. - Rebased over Phases 5–7: the cron also drains the order-email outbox and - prunes login challenges (the Node bin's 30s interval pair — at 15 min an - order email can lag one tick; `POST /internal/dispatch-emails` is the - on-demand lever), and the Worker wires the Phase 5–7 stores + the email - sender (`EMAIL_API_URL`/`EMAIL_API_KEY`/`EMAIL_FROM`/`STOREFRONT_BASE_URL` - env, ConsoleEmailSender fallback) exactly like the Node bin. - Known follow-up (separate task, not in this change): the NODE bin's - self-interval still sweeps only holds — order-expiry parity for the Node - entry (an `expireOrders` interval or equivalent) is tracked separately; - until then Node deployments drive it via `POST /internal/expire-orders`. -- **`SERVICE_API_TOKEN` write gate**: new optional `AppDeps.serviceToken`; a - Hono middleware registered first in `createApp` — when set, GET/HEAD (and - `/health`) stay open and every other method on every path requires - `Authorization: Bearer ` (401 with `WWW-Authenticate: Bearer`); - unset preserves today's fully-open behavior. `tokenMatches` (constant-time - compare) moved to `src/auth.ts` — the single implementation, shared with the - `X-Internal-Token` guards (`routes/internal-auth.ts` and `routes/carts.ts`); - with both secrets set, `POST /internal/expire-holds`, `POST - /internal/expire-orders`, and `POST /entitlements/grant` need both headers. - **Exactly one exemption** (exact-path allowlist, default deny): - `POST /webhooks/stripe`, which Stripe calls directly and authenticates with - its own `Stripe-Signature` HMAC over the raw body — Stripe cannot carry our - Bearer token. Every other Phase 4 mutating route (checkout included) is - gated. Deploy ordering note: set `SERVICE_API_TOKEN` on the deployed Worker - only AFTER the CMS-side plugin threads the same token (issue #25), or - storefront cart writes will 401. - **Phase 5 interaction (flagged, unresolved here)**: the customer-session - routes (`POST /auth/logout`, `POST/PUT/DELETE /me/addresses`) authenticate - with the CUSTOMER session token in the SAME `Authorization: Bearer` header - the gate consumes — with `SERVICE_API_TOKEN` set they would 401 at the - gate. Issue #25's token threading must resolve the header collision (e.g. a - dedicated service-token header, or session-authenticated method+path - exemptions) before the secret is set in production. The internal-token - admin surface (`/admin/*` writes, `PUT /settings`, `/reports/*` reads) uses - `X-Internal-Token`, so it composes with the gate as dual headers (reads are - GET — ungated — anyway). -- **`src/config.ts`**: pure `parseHoldTtlMs`/`resolveServiceConfig` shared by - both entries; the Node bin (`index.ts`) now reads env through it (no - behavior change). -- **`wrangler.jsonc`** (a TEMPLATE — copy to the gitignored - `wrangler.local.jsonc` with your own Worker name and Hyperdrive config id, - then `wrangler deploy --config wrangler.local.jsonc`): `nodejs_compat`, - Hyperdrive binding `HYPERDRIVE` (no `PG_CONNECTION_STRING` secret on - Workers), cron `*/15 * * * *` (janitor only — hold expiry stays - lazy-on-read). -- **`@otta-sh/store-postgres/pg`**: sqlite-free subpath re-exporting the pg - dialect factories, all six Kysely stores (incl. the Phase 4 - order/entitlement/payment-event stores), `migrateToLatest`, `uuidIdGen` - (now in `src/id-gen.ts`), and the schema types — nothing that touches the - `better-sqlite3` native addon, so wrangler/esbuild can bundle it. The root - barrel API is unchanged (`dialects.ts` is now a re-export shim over - `dialects-pg.ts`/`dialects-sqlite.ts`). diff --git a/.changeset/sku-rename-carries-stock.md b/.changeset/sku-rename-carries-stock.md index 4ac4425f..5cbfe82f 100644 --- a/.changeset/sku-rename-carries-stock.md +++ b/.changeset/sku-rename-carries-stock.md @@ -1,6 +1,5 @@ --- "@otta-sh/domain": minor -"@otta-sh/store-postgres": patch --- Fix: renaming a product's SKU silently abandoned its stock. diff --git a/.changeset/sku-rename-refusal-is-legible.md b/.changeset/sku-rename-refusal-is-legible.md index d5faf943..3145577e 100644 --- a/.changeset/sku-rename-refusal-is-legible.md +++ b/.changeset/sku-rename-refusal-is-legible.md @@ -1,5 +1,4 @@ --- -"@otta-sh/service": minor "@otta-sh/plugin": minor "@otta-sh/admin-react": minor --- diff --git a/.changeset/stripe-live-payment-intent.md b/.changeset/stripe-live-payment-intent.md index 352f03dd..9fad08c8 100644 --- a/.changeset/stripe-live-payment-intent.md +++ b/.changeset/stripe-live-payment-intent.md @@ -1,7 +1,6 @@ --- "@otta-sh/domain": minor "@otta-sh/payments-stripe": minor -"@otta-sh/service": minor --- Live Stripe `paymentIntents.create` in `StripePaymentGateway.createIntent` when a diff --git a/.changeset/tax-class-verbs-closeout.md b/.changeset/tax-class-verbs-closeout.md index e0ae29c3..33090def 100644 --- a/.changeset/tax-class-verbs-closeout.md +++ b/.changeset/tax-class-verbs-closeout.md @@ -1,7 +1,5 @@ --- "@otta-sh/domain": minor -"@otta-sh/store-postgres": minor -"@otta-sh/service": minor "@otta-sh/plugin": minor --- diff --git a/.changeset/title-single-writer.md b/.changeset/title-single-writer.md index a7fba399..ef1d8bbf 100644 --- a/.changeset/title-single-writer.md +++ b/.changeset/title-single-writer.md @@ -1,8 +1,6 @@ --- "@otta-sh/domain": minor -"@otta-sh/service": minor "@otta-sh/plugin": minor -"@otta-sh/store-postgres": patch --- **Breaking:** a product's title is now edited only in the CMS. diff --git a/.changeset/variants-rest-and-cart-sku-guard.md b/.changeset/variants-rest-and-cart-sku-guard.md index 15942dc9..34729f41 100644 --- a/.changeset/variants-rest-and-cart-sku-guard.md +++ b/.changeset/variants-rest-and-cart-sku-guard.md @@ -1,5 +1,4 @@ --- -"@otta-sh/service": minor "@otta-sh/plugin": minor --- diff --git a/playwright.config.ts b/playwright.config.ts index 651a26fc..fb428003 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -19,31 +19,33 @@ * `OTTA_E2E_START_STACK=1` has Playwright boot DIRECTOR-SPEC §0.2's stack. */ import { defineConfig, type PlaywrightTestConfig } from "@playwright/test"; -import { - E2E_BASE_URL, - E2E_PG_CONNECTION_STRING, - E2E_SERVICE_URL, - E2E_STARTS_STACK, - E2E_VIEWPORT, -} from "./sites/staging/e2e/harness.js"; +import { E2E_BASE_URL, E2E_STARTS_STACK, E2E_VIEWPORT } from "./sites/staging/e2e/harness.js"; /** Playwright does not export `TestConfigWebServer`, so it is reached through - * the config type. Without the annotation the two entries below infer a UNION - * whose `env` members carry `?: undefined` optionals, which the index - * signature `{ [k: string]: string }` rejects — a real TS2769 that went - * unnoticed because nothing type-checked this file. */ + * the config type. The annotation dates from when `stack` held two entries and + * inferred a UNION whose `env` members carried `?: undefined` optionals, which + * the index signature `{ [k: string]: string }` rejects — a real TS2769 that + * went unnoticed because nothing type-checked this file. It is kept now that + * INC-D3b left one entry: it costs nothing and restores the same guard the + * moment a second process is ever added back. */ type WebServer = Extract< NonNullable, readonly unknown[] >[number]; /** - * DIRECTOR-SPEC §0.2, step 1 + step 2 — opt-in, because booting a - * database-backed service is not something a bare `pnpm test:e2e` should do. - * The database is the LOCAL test Postgres on **55432**. Port 5432 is an SSH - * tunnel to PRODUCTION (§0.3) and must appear nowhere in this repo's e2e - * surface; `harness.spec.ts` enforces that across every e2e file plus this one, - * and `assertLoopbackUrl` re-checks the resolved values at module load. + * DIRECTOR-SPEC §0.2 — opt-in, because booting a dev server is not something a + * bare `pnpm test:e2e` should do. + * + * ONE ENTRY, not two. Until INC-D3b this array booted a standalone commerce + * service (`packages/service/src/index.ts`) against the local test Postgres and + * waited on its `/health`, then the site beside it. INC-D3a folded commerce + * into the plugin and INC-D3b deleted the service package, so there is a single + * process to start and no commerce address, port or `INTERNAL_API_TOKEN` to + * hand it. The §0.3 port rule is unchanged and is still enforced where it + * always was — `assertLoopbackUrl` re-checks every resolved endpoint at harness + * module load, and `harness.spec.ts` greps this file and the harness for a bare + * 5432 (the SSH tunnel to PRODUCTION) on every run. * * `reuseExistingServer` is OFF under CI and on locally. Adopting whatever holds * the port is convenient at a desk and wrong in an automated run: a sibling @@ -52,17 +54,6 @@ type WebServer = Extract< * this worktree, so a local reuse cannot silently grade the wrong tree either. */ const stack: WebServer[] = [ - { - command: "pnpm dlx tsx@4 packages/service/src/index.ts", - url: `${E2E_SERVICE_URL}/health`, - reuseExistingServer: process.env["CI"] === undefined, - timeout: 120_000, - env: { - PORT: new URL(E2E_SERVICE_URL).port, - PG_CONNECTION_STRING: E2E_PG_CONNECTION_STRING, - INTERNAL_API_TOKEN: process.env["INTERNAL_API_TOKEN"] ?? "local-e2e-token", - }, - }, { // The site needs NO commerce address: INC-D3a folded the service into the // plugin, so this dev server runs commerce in-process against its own From fb2c72c4f14a5d30a274515a3e9b20ad42a652c7 Mon Sep 17 00:00:00 2001 From: Vedanshu Date: Sun, 20 Sep 2026 13:30:59 +0000 Subject: [PATCH 4/5] [Docs][Test] Close the review findings on the service deletion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the deletion increment found the changesets under-stated it and a handful of guards that died with `@otta-sh/service` had no surviving counterpart. This closes both. **Changesets.** A new changeset records the increment itself: the six public exports leaving `@otta-sh/plugin`'s entry point (a `minor`), and the removal of `@otta-sh/service` and `@otta-sh/store-postgres`. Neither deleted package can be named in frontmatter — changesets refuses a release plan for a package whose directory is gone — so the removal is recorded in the prose of the package that outlived them. The remaining changesets were swept: the earlier pass trimmed their frontmatter but left bodies narrating deleted packages, dead REST routes and deleted client classes, all of which would have published verbatim into surviving packages' CHANGELOGs. Two changesets described nothing that survived and are deleted. **The two concurrency races are re-created, not just mourned.** The once-only note append under a shared idempotency key, and the single audit event under racing state flips, were local to the deleted adapter's dialect suites, so `store-emdash` never inherited them. Both now run against `EmdashOrderNotesStore` and `EmdashOrderStore` as `runIf(ctx.canRace)` cases in the existing `describeEachDialect` blocks, on Postgres, where a race is real. **Restored negative guards.** Cart lines carry no price; a guest's read of a fulfilled or cancelled order drops the staff witness and the cancellation detail. Each is a typed whitelist at the producer, so these are belt-and-braces — but they are the assertions the deleted suite held. The admin route's `public: false` gate, which had lost its only coverage, is pinned at the manifest. **Corrected a false comment.** Two comments claimed no guard existed for `serializeCart` dropping `state`. It does: `serializeCart` is annotated `: CartWire`, whose `state` is required, so dropping it fails to compile. The comments now say so. Three `skipIf(tier.payments)` cases that skip on every tier now explain themselves and point at their domain-layer coverage. Smaller unasserted bounds in the in-process admin clients are tracked in #289. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CQbJYWWm8tf8owshm7XRp8 --- .changeset/accordion-titles-carry-values.md | 34 ++--- .changeset/admin-failed-load-clears.md | 2 +- .changeset/admin-orders-console.md | 20 +-- .changeset/admin-orders-layout.md | 17 ++- .changeset/admin-price-save-guards.md | 2 +- .changeset/admin-products-console-list.md | 22 +-- .../admin-products-onhand-projection.md | 53 +++---- .changeset/admin-products-stock-column.md | 4 +- .changeset/admin-wire-completeness.md | 50 ++----- .changeset/after-publish-activate.md | 4 +- .changeset/batch-product-snapshot.md | 6 +- .changeset/batch-reservation-adopt-commit.md | 11 +- .changeset/cart-order-id.md | 65 ++++----- .changeset/cart-thread-productid.md | 25 ++-- .changeset/checkout-address-capture.md | 22 +-- .changeset/coupon-admin-list.md | 27 ++-- .changeset/coupon-admin-ui.md | 18 +-- .changeset/coupons-detail-safety.md | 14 +- .changeset/cursor-in-the-url.md | 12 +- .../delete-service-and-store-postgres.md | 46 ++++++ .changeset/delete-unreached-review-pair.md | 2 +- .changeset/entitlements-check-auth.md | 20 +-- .changeset/fix-admin-route-dispatch.md | 4 +- .changeset/fix-price-activate-published.md | 18 +-- .changeset/fix-reservation-not-found-404.md | 46 +++--- .changeset/in-process-admin-orders.md | 56 ++++---- .changeset/in-process-admin-products.md | 42 +++--- .changeset/in-process-admin-rules.md | 58 ++++---- .../in-process-commerce-client-storefront.md | 10 +- .../in-process-email-and-x402-settlement.md | 21 +-- .changeset/in-process-reporting-settings.md | 23 ++- .changeset/list-counts-and-empty-states.md | 2 +- .changeset/list-refresh-window.md | 6 +- .changeset/low-stock-list-predicate.md | 20 ++- .changeset/low-stock-server-side-predicate.md | 28 ++-- .changeset/lowstock-page-scope-count.md | 4 +- .changeset/merchant-restock.md | 10 -- .changeset/one-commerce-client-factory.md | 24 ++-- .../one-home-per-field-remove-commerce-bag.md | 4 +- .changeset/order-cancel-with-reason.md | 27 ++-- .changeset/order-customer-context.md | 13 +- .changeset/order-fulfillment-tracking.md | 25 ++-- .changeset/order-notes-walking-skeleton.md | 18 +-- .changeset/order-refunds.md | 8 +- .changeset/order-timeline-audit.md | 19 +-- .../orders-search-prefix-and-substring.md | 5 +- .changeset/orders-write-path-extraction.md | 2 +- .changeset/payment-secrets-write-only-kv.md | 21 +-- .changeset/phase-0-atomic-inventory.md | 21 +-- .changeset/phase-1-plugin.md | 26 ++-- .changeset/phase-1-product-model-and-sync.md | 36 ++--- .changeset/phase-2-catalog-display.md | 22 +-- .changeset/phase-3-cart-and-inventory.md | 54 +++---- .changeset/phase-3-storefront-cart.md | 37 +++-- .changeset/phase-4-checkout-and-gateways.md | 57 +++----- .changeset/phase-5-orders-customers-emails.md | 35 ++--- .changeset/phase-6-shipping-tax-coupons.md | 40 ++---- .changeset/phase-7-reports-and-settings.md | 40 ++---- .../plugin-settings-admin-token-on-read.md | 17 --- .changeset/plugin-title-sync.md | 6 +- .changeset/prev-next-and-page-of.md | 10 +- .changeset/product-data-model-adds.md | 21 +-- .changeset/product-edit-page.md | 13 +- .changeset/product-lifecycle-surfacing.md | 25 ++-- .changeset/products-react-console.md | 2 +- .changeset/products-write-path-extraction.md | 17 +-- .changeset/promote-create-actions.md | 2 +- .changeset/reports-low-stock-titles.md | 16 +-- .changeset/reports-period-and-kpis.md | 10 +- .changeset/resolve-reconciliation.md | 23 ++- .changeset/retire-service-deployment.md | 5 +- .changeset/rules-update-delete.md | 19 ++- .changeset/seed-inventory-on-first-sku.md | 16 +-- .changeset/service-token-gate.md | 31 ---- .changeset/shipping-admin-drilldown.md | 17 ++- .changeset/sku-rename-carries-stock.md | 27 ++-- .changeset/sku-rename-refusal-is-legible.md | 12 +- .changeset/storefront-checkout.md | 34 +++-- .changeset/stripe-live-payment-intent.md | 17 +-- .changeset/tax-admin-drilldown.md | 12 +- .changeset/tax-class-verbs-closeout.md | 26 ++-- .changeset/tax-shipping-label-ordering.md | 22 +-- .changeset/title-single-writer.md | 12 +- .../variants-rest-and-cart-sku-guard.md | 85 ++++++----- .../src/testing/order-notes-store-contract.ts | 10 +- .../src/testing/order-timeline-contract.ts | 12 +- .../admin/in-process-admin-products-client.ts | 6 +- .../test/admin-route-dispatch.sandbox.test.ts | 21 +++ .../plugin/test/cart-routes.sandbox.test.ts | 10 ++ ...ommerce-client-contract.in-process.test.ts | 135 +++++++++--------- .../contracts/commerce-client-contract.ts | 120 +++++++++++++++- .../test/misc-contract.dialects.test.ts | 43 ++++++ .../order-timeline-contract.dialects.test.ts | 73 ++++++++++ sites/staging/src/lib/cart-view.ts | 12 +- sites/staging/test/cart-page.test.ts | 17 +-- 95 files changed, 1116 insertions(+), 1198 deletions(-) create mode 100644 .changeset/delete-service-and-store-postgres.md delete mode 100644 .changeset/plugin-settings-admin-token-on-read.md delete mode 100644 .changeset/service-token-gate.md diff --git a/.changeset/accordion-titles-carry-values.md b/.changeset/accordion-titles-carry-values.md index 0005b464..46157db2 100644 --- a/.changeset/accordion-titles-carry-values.md +++ b/.changeset/accordion-titles-carry-values.md @@ -4,7 +4,7 @@ Accordion labels state the values they hide, so the collapsed screen is readable (admin-UX INC-15). A Block Kit console cannot draw cards, and a group whose label is a -bare noun — `Identity`, `Service connection` — makes the operator open it just to find +bare noun — `Identity`, `Checkout & holds` — makes the operator open it just to find out whether it holds anything. The labels now answer that, which is the cheapest density win the surface allows. @@ -13,24 +13,19 @@ density win the surface allows. renders as its natural-key slug rather than `name (id)`: the pair would consume the whole 60-character label budget on its own, leaving no room for the weight the group also exists to show. -- **Settings.** `Checkout & holds — 15 min hold · low stock at 5` and `Service - connection — token set · service token not set`, and each group now renders closed. - The screen used to open `Store`, the one cosmetic field on it, pushing the two groups - that hold operational and connection state below an expanded form. This is the +- **Settings.** `Checkout & holds — 15 min hold · low stock at 5`, and each group now + renders closed. The screen used to open `Store`, the one cosmetic field on it, pushing + the group that holds operational state below an expanded form. This is the render-time kind of closing: no `block_id` changes to force a group shut, so no unsubmitted operator input is ever discarded. -- **A token's label states a FACT about the credential, never any part of it.** "Token - set" is derived from a boolean the render already had; neither token value is in - scope where the labels are built, and the whole-response no-echo pins cover the - labels along with everything else. Both tokens stay write-only and never render back. - **An absent value is named, not implied.** `Identity — no SKU`, `Classification & shipping — no tax class · no weight`, `Store — no display name`, and — when the - secondary `GET /settings` fails — `Checkout & holds — not loaded` rather than a label + settings read fails — `Checkout & holds — not loaded` rather than a label reading `0 min hold · low stock at 0`. - **A collapsed label reads as persisted state, so it only ever states persisted state.** On a REJECTED operational save the form keeps the attempted value for correction, and - the label keeps stating what the service actually holds — a group reading - `99999 min hold` after the service refused 99999 would be reporting a value nothing + the label keeps stating what is actually persisted — a group reading + `99999 min hold` after the save was refused would be reporting a value nothing stored. - **An over-budget label loses a value, not the tail.** Right-truncation would delete the last segment outright and leave a label that looks complete, so the truncation costs @@ -46,16 +41,9 @@ density win the surface allows. renders no edit forms at all) still states its kind. Nothing replaced the Title row with a Title input: `product_commerce.title` is a CMS-owned single-writer cache (ADR-0013) and `ProductEditWire` has no `title` member, so one would not compile. -- **A blank token submit stops claiming it saved something.** The token fields render - empty on every mount and a blank submit deliberately keeps the stored token, so the - receipt now says `Nothing entered — admin token unchanged` instead of `Admin token - saved` above a group labelled `token not set`. -A Settings render also stops re-reading kv for what it already has: seven sequential -`ctx.kv` gets become five, of which the last three run concurrently. Two were re-reads -of tokens the handler had fetched at the top of the request, and both booleans the -labels need are derivable from the tokens already in hand. A token save updates what its -own re-render is computed from, so a first-ever save reports the token it just persisted -as set rather than as missing. +A Settings render also stops re-reading kv for what it already has, collapsing the +sequential `ctx.kv` gets the handler had already made at the top of the request and +running what remains concurrently. -No service, wire, or schema change. +No wire or schema change. diff --git a/.changeset/admin-failed-load-clears.md b/.changeset/admin-failed-load-clears.md index 185de912..0f1b1708 100644 --- a/.changeset/admin-failed-load-clears.md +++ b/.changeset/admin-failed-load-clears.md @@ -16,7 +16,7 @@ manual page reload. It now answers a failure in one of three ways: - **stale** (a first page failed under rows) — the rows, the count and `Load more` are cleared in state; the filter bar and the filter summary stay, because the operator's typed filters are input rather than answer. The card - carries the service's own words plus a sentence saying the rows went and why, + carries the failure's own words plus a sentence saying the rows went and why, and focus moves to Retry, which was inside a row that no longer exists; - **partial** (a page behind a successful one failed) — every accumulated row and the count stand, and the card renders where `Load more` was, titled for diff --git a/.changeset/admin-orders-console.md b/.changeset/admin-orders-console.md index 2dcb44be..6c0bffb9 100644 --- a/.changeset/admin-orders-console.md +++ b/.changeset/admin-orders-console.md @@ -17,22 +17,16 @@ Add a WooCommerce-style admin Orders console — VIEW + STATUS-TRANSITION only suite pin the spec (empty, single/multi state, date boundary, search, pagination no-overlap/no-gap, identical-`created_at` tie-break, limit boundary). -- `@otta-sh/store-postgres`: implements `listOrders` as a single - `orders → order_totals` SELECT with a grouped keyset predicate, dialect-identical - on better-sqlite3 and Postgres. Adds forward-only migration `0009` (a - `orders(created_at, id)` index for the keyset order). -- `@otta-sh/service`: adds the internal-token-guarded `GET /admin/orders` (filters + - an OPAQUE base64url keyset cursor that embeds the active filter so it survives - paging; a malformed/tampered cursor fails CLOSED to 400 and the decoded limit is - re-clamped) and `GET /admin/orders/:id` (full order + `allowedTransitions` from - the domain state machine; 404 when absent). `serializeOrder` gains `createdAt` + - `customerId` additively. - `@otta-sh/plugin`: adds the Orders admin page (list with a status/date/search filter form, keyset "Load more", open-order → detail with line items, totals, and legal transition buttons — destructive cancel/refund guarded by a confirm - dialog). A new `AdminOrdersClient` reaches the service only via `ctx.http` + - `allowedHosts` with the write-only kv admin token; the plugin defines its own - local wire types and never imports `@otta-sh/domain` (now enforced by the + dialog). Paging rides an OPAQUE base64url keyset cursor that embeds the active + filter so it survives a "Load more"; a malformed or tampered cursor fails + CLOSED and the decoded limit is re-clamped. The detail read carries the full + order plus `allowedTransitions` derived from the domain state machine, and an + order summary now carries `createdAt` + `customerId`. The console reads + through a plugin-owned admin orders client; the plugin defines its own local + wire types and never imports `@otta-sh/domain` (now enforced by the dependency-cruiser sandbox-clean rule). The staging trusted descriptor registers the new page. diff --git a/.changeset/admin-orders-layout.md b/.changeset/admin-orders-layout.md index 1b474ce6..776fafe6 100644 --- a/.changeset/admin-orders-layout.md +++ b/.changeset/admin-orders-layout.md @@ -5,8 +5,7 @@ Re-lay the admin Orders console onto the design spec's §11 — the REFERENCE screen the other six pattern-match on. One flat full-width stack becomes a collapsed filter panel over the data (list) and five blocks plus four task-named panels -(detail). Presentation only: no port, wire-format or money-handling change, and -the service is untouched. +(detail). Presentation only: no port change and no money-handling change. **The list (§11.1).** `header` + one 101-char `context` + a **collapsed** 4-field `filterPanel` accordion + the table + the drill-in picker — nothing else above the @@ -65,9 +64,9 @@ instead of silently bouncing the operator to the list (DA-3b). **Status moves are one `actions` block with per-state ids derived from `ORDER_STATES`** (DA-6) — the old one-block-per-button split existed only because every button shared the literal id `orders:transition` and they collided as React -keys. `customActions` is derived from the same constant and a service-offered state -outside it renders **no button**, because `admin-route.ts` falls through an -unregistered id to `{blocks: []}` — a blank console. +keys. `customActions` is derived from the same constant and an offered state outside it +renders **no button**, because `admin-route.ts` falls through an unregistered id +to `{blocks: []}` — a blank console. Also: `formatTotal`'s catch branch renders `—` instead of raw minor units (a wrong number dressed as a formatted total, M-1) and the totals block says so when it @@ -102,7 +101,7 @@ unreadable payload rather than as licence to skip the comparison. Copy and layout follow-ups in the same pass: the DA-3a refusal restores its causal clause (*"someone else refunded this order since you started"*); the fail-closed -banner stops claiming the service is unreachable when a console bug lands on the +banner stops blaming an unreachable back end when a console bug lands on the same path (E-7/X-42); both destructive group labels carry their consequence (D-6a); `Remaining` becomes `Remaining refundable` and a total that disagrees with its capture is reconciled in one line (M-11/M-11a), with the degenerate `$0.00 of $0.00` @@ -128,6 +127,6 @@ clauses on each of the four refusal paths; a **positive** watermark assertion (t deliberate identical refunds derive **different** idempotency keys, so both apply — the property the whole no-nonce design rests on, and the one nothing asserted); a `shipped`-order assertion that `Mark refunded` really is offered, against a fixture -whose `allowedTransitions` is the domain state machine copied verbatim; and a -service-side assertion that `GET /admin/orders/:id` on a shipped order returns -`["delivered", "refunded"]`, which is the wire shape the watermark exists for. +whose `allowedTransitions` is the domain state machine copied verbatim; and an +assertion that reading a shipped order offers exactly +`["delivered", "refunded"]`, which is the shape the watermark exists for. diff --git a/.changeset/admin-price-save-guards.md b/.changeset/admin-price-save-guards.md index 29316ead..50641bbe 100644 --- a/.changeset/admin-price-save-guards.md +++ b/.changeset/admin-price-save-guards.md @@ -24,7 +24,7 @@ but it now has four states: storefront immediately, and `Discard` appears beside `Save`; - **in flight** — only the button that was clicked reads `Saving…`, and it stays that way until the re-read that follows the write lands, so no save button is - ever re-armed against a watermark the service has already superseded; + ever re-armed against a watermark the store has already superseded; - **saved** — a receipt renders inside the section, under the button, naming the two amounts and saying that orders already placed keep the price they were charged. It persists; nothing dismisses it. diff --git a/.changeset/admin-products-console-list.md b/.changeset/admin-products-console-list.md index 3c10f013..40da4f3b 100644 --- a/.changeset/admin-products-console-list.md +++ b/.changeset/admin-products-console-list.md @@ -27,23 +27,13 @@ VIEW-ONLY product list + read-only detail (admin-UX Increment 2, "product enumer `InMemoryInventoryStore` fakes and the contract suites pin both specs (empty, filters, pagination no-overlap/no-gap, identical-`created_at` tie-break, limit boundary, tombstone exclusion). -- `@otta-sh/store-postgres`: implements `listProducts` as a single - `product_commerce` SELECT (no join) with a keyset predicate dialect-identical on - better-sqlite3 and Postgres; the substring title search escapes SQL LIKE - metacharacters (`%`, `_`, `\`) so a literal search (e.g. "50% off") never - misfires as a wildcard. Implements `InventoryStore.getOnHand` as a bare - single-row `SELECT on_hand`. -- `@otta-sh/service`: adds the internal-token-guarded `GET /admin/products` (filters - + an OPAQUE base64url keyset cursor embedding the active filter, mirroring - `GET /admin/orders`'s cursor discipline — a malformed/tampered cursor fails - CLOSED to 400 and the decoded limit is re-clamped) and - `GET /admin/products/:id` (the full product detail plus the single-sku `onHand` - read; 404 for an unknown OR soft-deleted product — there is no admin surface for - browsing/restoring a tombstone yet). - `@otta-sh/plugin`: adds the Products admin page (list with an active/kind/search filter form, keyset "Load more", columns title/SKU/price/status/kind — stock deliberately OMITTED from the list; open-product → read-only detail showing the - full product fields incl. stock). A new `AdminProductsClient` reaches the - service only via `ctx.http` + `allowedHosts` with the write-only kv admin token; - the plugin defines its own local wire types and never imports `@otta-sh/domain` + full product fields incl. stock, via the single-sku `onHand` read). The list + cursor is OPAQUE and embeds the active filter, mirroring the Orders console's + cursor discipline — a malformed or tampered cursor fails CLOSED and the decoded + limit is re-clamped. An unknown OR soft-deleted product reads as not-found; + there is no admin surface for browsing or restoring a tombstone yet. The plugin + defines its own local wire types and never imports `@otta-sh/domain` (sandbox-clean). The staging trusted descriptor registers the new page. diff --git a/.changeset/admin-products-onhand-projection.md b/.changeset/admin-products-onhand-projection.md index 4973c4ae..625184ba 100644 --- a/.changeset/admin-products-onhand-projection.md +++ b/.changeset/admin-products-onhand-projection.md @@ -8,10 +8,9 @@ low-stock report (admin-UX INC-03). The Pricing & inventory screen already fetched a row per product but had to send the operator to the detail leaf to learn whether anything was in stock; the low-stock report listed bare SKUs. -`ProductSummary` (and the `GET /admin/products` wire) gains `onHand: number | -null`, and `LowStockRow` (and `GET /reports/low-stock`) gains `title: string | -null`. Both are REQUIRED fields on exported interfaces, hence `minor` for the -packages that export them. +`ProductSummary` gains `onHand: number | null` and `LowStockRow` gains +`title: string | null`. Both are REQUIRED fields on exported interfaces, hence +`minor` for the packages that export them. **`null` is not `0`.** `onHand: null` means there is no `inventory` record for the sku — "unknown" — while `0` means a known sku that is out of stock. Nothing @@ -23,34 +22,22 @@ two; the divergence is now documented on both sides of the port boundary. its own field on the row — substituting it would make "named SKU-42" indistinguishable from "name unknown". -**Shape, chosen from measurements, not estimates.** Postgres 16, 5,000 products -/ 3,997 inventory rows (~20% deliberately carrying no inventory record). +**Shape, chosen from measurements, not estimates.** Carrying stock on the list +projection itself was measured against the alternative of leaving each caller to +issue a per-row `getOnHand`: the N+1 cost several times the single joined read at +a 5,000-product catalog, in parallel and worse in sequence, on loopback and +before any real network. The projection is also unconditional rather than gated +on a "low stock only" filter — the gated variant measured *slower*, because it +must walk far more rows to fill a page. -*Products list, page size 25* — a single unconditional `LEFT JOIN` costs p50 -0.43 → 0.58 ms and p95 0.61 → 0.91 ms, where an N+1 of per-row `getOnHand` reads -cost 2.60 ms p50 in parallel and 6.36 ms sequential: 6x and 15x the baseline, on -loopback, before any real network. The join is therefore unconditional rather -than gated on a "low stock only" filter — the gated variant measured *slower* -(1.15 ms), because it must walk ~9x the rows to fill a page. **No index and no -migration**: the join's inner side is already `inventory`'s primary key, and a -covering index cut buffers 28% without moving wall-clock at all. +The low-stock report's title half is the more expensive one, disclosed as such: +its cost is linear in CATALOG size rather than in the number of low-stock rows. +At a 5,000-product catalog that is comfortably inside the report's budget. Named +follow-up if low-stock latency ever matters: **bound the low-stock report** — it +currently returns every row at or below the threshold, unpaginated. -*Low-stock report* — the title join is the more expensive half, disclosed as -such: p50 2.915 → 4.858 ms (+67%), p95 6.89 → 7.48 ms. The planner picks a Hash -Right Join whose build side is a **Seq Scan over `product_commerce`**, so this -query's cost is linear in CATALOG size, not in the number of low-stock rows. At -5,000 products that is 121 shared buffers and ~4.0 ms of execution, comfortably -inside the report's budget. The partial unique index -`product_commerce_live_sku_unique` remains available to the planner and should -flip it to a nested-loop index lookup once the catalog grows enough for the seq -scan to lose. No index was added, per the user's ruling on §5.1. Named follow-up -if low-stock latency ever matters: **bound the low-stock report** — it currently -returns every row at or below the threshold, unpaginated — before reaching for -an index. - -`lowStock`'s title join carries `AND product_commerce.deleted_at IS NULL` on its -ON clause. That predicate is load-bearing, not defensive: sku uniqueness on -`product_commerce` is a PARTIAL unique index over live rows, so a soft-deleted -product may legally hold a sku a live row also holds — without the predicate -such a sku would emit a DUPLICATE low-stock row and could be titled by the dead -product. Pinned by a contract case and an HTTP case on both dialects. +A soft-deleted product must not title a low-stock row or emit a second one. +Sku uniqueness is scoped to LIVE products, so a deleted product may legally hold +a sku a live product also holds; the report excludes deleted products from the +title lookup for that reason, and the exclusion is pinned by its own contract +case rather than left to the adapter. diff --git a/.changeset/admin-products-stock-column.md b/.changeset/admin-products-stock-column.md index caace388..a7d7bab2 100644 --- a/.changeset/admin-products-stock-column.md +++ b/.changeset/admin-products-stock-column.md @@ -58,7 +58,7 @@ bare SKUs, and the SKU→title mapping lived in the operator's head. reading aid, identity travels in the option's value, and nothing parses a label back into fields. -No service, wire, or schema change: this is the console rendering `onHand`, which the +No port, wire, or schema change: this is the console rendering `onHand`, which the admin products list projection already carries. Three consequences worth carrying forward, none of them blocking here: @@ -66,7 +66,7 @@ Three consequences worth carrying forward, none of them blocking here: - The filter panel is now AT `MAX_FILTER_FIELDS` (4). The next filter added to this screen makes `filterPanel` throw, so the increments that revisit filters have to cut a field or raise the cap deliberately. -- Each list and detail render now makes one extra, uncached `GET /settings`. It is +- Each list and detail render now makes one extra, uncached settings read. It is deliberate and cheap: it runs in parallel with the reads beside it, so it costs no added latency, and it cannot fail either screen. - The back button drops every filter on this screen, the low-stock toggle included. diff --git a/.changeset/admin-wire-completeness.md b/.changeset/admin-wire-completeness.md index 3899e8ff..ec7f4d9f 100644 --- a/.changeset/admin-wire-completeness.md +++ b/.changeset/admin-wire-completeness.md @@ -6,8 +6,9 @@ Close the three recorded gaps where the admin wire knew something the console could not say (INC-23). One theme — the wire stops lying by omission — across a refunded amount that existed nowhere, a stock count the detail collapsed, and a -set size the lists never sent. Three required port members, one required wire -field and one widened wire type, hence `minor` on both published packages. +set size the lists never sent. Three required port members, one required field +on a port type and one widened read type, hence `minor` on both published +packages. - `@otta-sh/domain`: `PeriodBucket` gains a required `refundedCents: Cents` beside `revenueCents` — money returned on the orders in that bucket, per @@ -30,32 +31,9 @@ field and one widened wire type, hence `minor` on both published packages. `CouponStore.countCoupons(filter)` join the existing `OrderStore.countOrders`, each sharing its list's exact predicate builder so a count can never disagree with the list it captions. -- `@otta-sh/store-postgres`: `revenueByPeriod` becomes a `UNION ALL` of two - contribution sets folded by one `GROUP BY` — the two halves carry different - predicates, and a bucket must survive when only the refunded half contributes, - which an inner join would drop and which `FULL OUTER JOIN` cannot portably - express (better-sqlite3 gained it only in 3.39). Measured on pg 16 (5,000 - orders over ~208 day buckets, 417 refund rows, 60 runs): p50 10.87 → 13.30 ms, - p95 13.99 → 15.52 ms — +2.4 ms p50 (~22%), tracking the REFUND count rather - than the order count. `countProducts`/`countCoupons` are single-table - `COUNT(*)`s under the list predicate (no stock join — a count has no columns): - p50 1.26 ms and 0.80 ms at 5,000 rows, against 39.3 ms and 0.60 ms page reads. - NO MIGRATION: the refunds ledger (0020), the inventory rows and every list - predicate already exist; no new index either. -- `@otta-sh/service`: `GET /reports/revenue` serializes `refundedCents` on every - bucket, zero included — presence of the KEY is what tells a client the service - reports refunds, never the value. `GET /admin/products/:id` returns - `onHand: number | null` with the LIST's semantics (it previously collapsed - both "no inventory row" and "no sku" to `0`, so one product read `—` in the - list and `0` on its own detail page). The three admin list endpoints - (`/admin/orders`, `/admin/products`, `/admin/coupons`) gain `total`, the exact - size of the filtered set, issued CONCURRENTLY with the page read. Note for - operators: each of those requests now holds TWO pool connections at its peak - rather than one — the queries are short and the pool default is 8, but a - deployment that has tuned the pool down should account for it. - `@otta-sh/plugin`: the Reports Refunded card renders the real figure through - `formatMoney`, including `$0.00` when the service reports zero — the em-dash - survives only for a service that predates the field. Because a bucket can now + `formatMoney`, including `$0.00` when the figure is genuinely zero — the + em-dash survives only where the field is absent. Because a bucket can now exist on refunds alone, the page derives its CURRENCY MODE from revenue-bearing buckets only: a single fully-refunded EUR order in a USD store used to raise a phantom `€0.00` revenue card that dashed out AOV and @@ -64,11 +42,13 @@ field and one widened wire type, hence `minor` on both published packages. Refund-only currencies are counted and stated separately, in their own currency, in one line. The card also discloses that its figure is retro-mutable (a July order refunded in September changes July) and that - in-progress refunds are excluded. The product detail wire widens to - `onHand: number | null` and renders it with the same helper as the list column; - a sku with no inventory record no longer offers stock-movement forms whose only - outcome is `UNKNOWN_SKU`. The list scaffold renders the EXACT count whenever a - `total` is present, on any page — page-scoped wording remains for a service - without one and for a screen that narrowed its own fetched page (the products - list's "Low stock only"), and a `total` that understates the rendered rows, or - is not a non-negative safe integer, falls back rather than lies. + in-progress refunds are excluded. The product detail reads `onHand` as + `number | null` with the LIST's semantics and renders it with the same helper + as the list column — it previously collapsed both "no inventory row" and "no + sku" to `0`, so one product read `—` in the list and `0` on its own detail + page; a sku with no inventory record no longer offers stock-movement forms + whose only outcome is `UNKNOWN_SKU`. The list scaffold renders the EXACT count + whenever a `total` is present, on any page — page-scoped wording remains where + no total is available and for a screen that narrowed its own fetched page (the + products list's "Low stock only"), and a `total` that understates the rendered + rows, or is not a non-negative safe integer, falls back rather than lies. diff --git a/.changeset/after-publish-activate.md b/.changeset/after-publish-activate.md index ecd81194..bc61a4f4 100644 --- a/.changeset/after-publish-activate.md +++ b/.changeset/after-publish-activate.md @@ -6,6 +6,4 @@ Syncs a product's commerce purchasability to its CMS publish lifecycle in both directions: publishing a product in EmDash makes it purchasable on the storefront, and unpublishing it makes it non-purchasable again (previously `active` was a one-way latch — an unpublished product stayed purchasable on a direct product-page hit). The publish and unpublish syncs are independent fire-and-forget calls, so they carry an ordering watermark (the content's `updatedAt`) and converge under out-of-order delivery: a delayed, stale publish can never re-latch a product an unpublish has since made non-purchasable, matching the convergence the content-save path already guarantees. - `@otta-sh/domain`: adds `ProductCommerceStore.activate` / `deactivate` port methods and the `activateProductCommerce` / `deactivateProductCommerce` use-cases — flips of the `active` publish gate, kept separate from `upsert` (which never touches `active`/`deletedAt`). Each carries a `contentUpdatedAt` ordering watermark; unknown, already-in-that-state, soft-deleted, and stale (out-of-order) calls are stable no-ops, and neither publish nor unpublish ever resurrects or re-stamps a soft-deleted product. -- `@otta-sh/store-postgres`: implements `activate` and `deactivate` as single guarded `UPDATE`s (`deleted_at IS NULL`, the state guard, and a dedicated `active_updated_at` watermark guard) on Postgres and SQLite. -- `@otta-sh/service`: adds `POST /products/:id/commerce/activate` and `POST /products/:id/commerce/deactivate` (`Idempotency-Key` header required; the request body carries the `contentUpdatedAt` watermark), dedicated action routes mirroring the port. -- `@otta-sh/plugin`: registers the `content:afterPublish` and `content:afterUnpublish` hooks and calls the matching service endpoints with lifecycle-derived idempotency keys and the content's `updatedAt` watermark, so a product's storefront purchasability follows its CMS publish state and converges even if the hook deliveries arrive out of order. +- `@otta-sh/plugin`: registers the `content:afterPublish` and `content:afterUnpublish` hooks and drives the matching activate/deactivate use-cases with lifecycle-derived idempotency keys and the content's `updatedAt` watermark, so a product's storefront purchasability follows its CMS publish state and converges even if the hook deliveries arrive out of order. diff --git a/.changeset/batch-product-snapshot.md b/.changeset/batch-product-snapshot.md index ab71343d..e359353d 100644 --- a/.changeset/batch-product-snapshot.md +++ b/.changeset/batch-product-snapshot.md @@ -2,8 +2,6 @@ "@otta-sh/domain": minor --- -Removes the per-cart-line N+1 product-snapshot read in both checkout paths by adding a single bulk store method and rewiring both callers to fetch once. Snapshot semantics are unchanged: an order line still snapshots price + title at purchase time, and every per-line null / price / currency / kind check is byte-for-byte identical. +Removes the per-cart-line N+1 product-snapshot read on the checkout path by adding a single bulk store method and rewiring the caller to fetch once. Snapshot semantics are unchanged: an order line still snapshots price + title at purchase time, and every per-line null / price / currency / kind check is byte-for-byte identical. -- `@otta-sh/domain`: adds `ProductCommerceStore.getManyByProductId(productIds)`, the bulk companion to `getByProductId` — a raw row read returning the FULL `ProductCommerce` (title / taxClass / productKind included, unlike the narrower `listCommerceByIds` view) keyed by id in a `Map`. It applies no `deleted_at` / sku / price guards (the callers do their own per-line checks); missing ids are absent from the Map, duplicate input ids collapse, and there is no ordering guarantee. `createOrderFromCart` now fetches every priced line's projection in one call instead of one `getByProductId` per line. -- `@otta-sh/store-postgres`: implements `getManyByProductId` as one `SELECT … WHERE product_id IN (:ids)` (no inventory join, no commerce-complete guards) on Postgres and SQLite; the empty id list short-circuits without touching the DB. Pinned by a store-level query-count test asserting exactly one statement for N ids. -- `@otta-sh/service`: `POST /checkout/quote` fetches every line's snapshot via one `getManyByProductId` before the loop instead of a per-line read, preserving its existing checks (including the deliberate absence of a `title === null` check). +`ProductCommerceStore.getManyByProductId(productIds)` is the bulk companion to `getByProductId` — a raw row read returning the FULL `ProductCommerce` (title / taxClass / productKind included, unlike the narrower `listCommerceByIds` view) keyed by id in a `Map`. It applies no `deleted_at` / sku / price guards (the callers do their own per-line checks); missing ids are absent from the Map, duplicate input ids collapse, and there is no ordering guarantee. `createOrderFromCart` now fetches every priced line's projection in one call instead of one `getByProductId` per line, and an adapter is expected to serve it as a single read — an empty id list short-circuits without touching the store at all. diff --git a/.changeset/batch-reservation-adopt-commit.md b/.changeset/batch-reservation-adopt-commit.md index 9ce7e65c..36ac50ae 100644 --- a/.changeset/batch-reservation-adopt-commit.md +++ b/.changeset/batch-reservation-adopt-commit.md @@ -28,13 +28,8 @@ state-machine semantics and anomaly detection byte-for-byte. `commitMany` catches `ReservationCommitLostError` per id → `lost` and continues. The digital `entitlement.grant` loop and the release path are untouched. -- **`@otta-sh/store-postgres`**: implements `adoptMany`/`commitMany` on - `KyselyInventoryStore` as the single guarded UPDATE + classification SELECT - described above (empty-ids short-circuit; `IN (:ids)`, never `= ANY`). - The contract suite gains adoptMany/commitMany cases (all-success, partial released/committed/expired, idempotent replay incl. adopted-past-deadline, empty, -and commitMany unknown-id-throws), run against the fake, SQLite, and Postgres. A -new Postgres-required multi-line no-oversell test races carts with 2–3 -distinct-sku physical lines and proves the batch never oversells or half-commits -(committed == fullWinners × linesPerOrder, each sku on_hand == 0). +and commitMany unknown-id-throws), run against every `InventoryStore` +implementation, so a batch that oversells or half-commits a multi-line order +fails the suite rather than the storefront. diff --git a/.changeset/cart-order-id.md b/.changeset/cart-order-id.md index 7e5fc40d..90c7f9dc 100644 --- a/.changeset/cart-order-id.md +++ b/.changeset/cart-order-id.md @@ -6,22 +6,17 @@ Give the cart the id of the order it became (issue #132). Nothing in the system resolved an order from a cart, so `/cart` had no way to -link a buyer to the purchase they had just made. `carts` gains a nullable -`order_id` (migration `0021_cart_order_id`), written by `CartStore.checkout` -and threaded to the wire as `Cart.orderId` / `CartWire.orderId`. - -The write is **one statement, two columns**: - -```sql -UPDATE carts SET state = 'checked_out', order_id = :orderId - WHERE id = :cartId AND state = 'active' -``` - -so the state and the order id are never observable apart, and the existing -`state = 'active'` predicate IS the compare-and-set that makes the stamp -write-once. No new constraint, no `WHERE order_id IS NULL`, no CHECK — the -"`active` ⟺ no order id" invariant is enforced by `checkout` being the column's -single writer, not structurally. +link a buyer to the purchase they had just made. A cart gains a nullable +`orderId`, written by `CartStore.checkout` and carried through as +`Cart.orderId` / `CartWire.orderId`. + +The write is **one conditional update over both fields** — the checked-out flag +and the order id move together, so the state and the order id are never +observable apart, and the existing "still active" predicate IS the +compare-and-set that makes the stamp write-once. No new constraint, no +"order id is still null" guard, no CHECK — the "`active` ⟺ no order id" +invariant is enforced by `checkout` being the field's single writer, not +structurally. Two things the column deliberately does **not** mean: @@ -34,29 +29,25 @@ Two things the column deliberately does **not** mean: `RESERVATION_LOST` abort, leaves a real `pending` order behind a permanently `active`, NULL cart. `orders.cart_id` remains the only complete answer. -`HttpCommerceClient.getCart` normalizes a missing, empty-string or non-string -`orderId` to `null`. Nothing on that path validates the cart body at runtime, -and unlike `state` (which fails safely — `isCartTerminal(undefined)` is false) -`orderId` fails unsafely: `undefined !== null` is true, so an un-normalized -consumer renders `/orders/undefined` as a primary action. +The cart read normalizes a missing, empty-string or non-string `orderId` to +`null`. Unlike `state` (which fails safely — `isCartTerminal(undefined)` is +false) `orderId` fails unsafely: `undefined !== null` is true, so an +un-normalized consumer renders `/orders/undefined` as a primary action. No backfill: the project is unreleased, so there is no production data and every existing `checked_out` cart predates the writer. -**Security consequence, accepted deliberately.** `GET /carts/:cartId` is -unauthenticated (`app.ts`, `routes/carts.ts`), so emitting `orderId` there makes -a cart id a *permanent* derivation path to an order id — and an order id is not -merely a read token: `GET /entitlements/check` treats a bare `orderId` as an -**open bearer capability** (ADR-0011 precedence rule 2), and -`GET /orders/:orderId` is itself an unauthenticated capability URL. This is -accepted because it grants no new principal: the cart id lives in an -`httpOnly` + `secure` + `sameSite` cookie, so anyone who can call -`GET /carts/:cartId` for a given cart is already the buyer or already holds the -cart id, and both orders reads are redacted (`serializePublicOrder` omits -`buyerRef`, `customerId` and `shippingAddress`), so no PII crosses. The -practical change is one of DURATION, not of audience — the derivation no longer -depends on a short-lived checkout stash. Any future widening of what an order -id alone unlocks must re-examine this route. +**Security consequence, accepted deliberately.** An unauthenticated cart read +that carries `orderId` makes a cart id a *permanent* derivation path to an +order id, and an order id is not merely a read token — the entitlement check +treats a bare `orderId` as an **open bearer capability** (ADR-0011 precedence +rule 2). This is accepted because it grants no new principal: the cart id lives +in an `httpOnly` + `secure` + `sameSite` cookie, so anyone who can read a given +cart is already the buyer or already holds the cart id, and the public order +projection is redacted (`buyerRef`, `customerId` and `shippingAddress` are +omitted), so no PII crosses. The practical change is one of DURATION, not of +audience — the derivation no longer depends on a short-lived checkout stash. +Any future widening of what an order id alone unlocks must re-examine this. At `0.x`, changesets map a **minor** bump to a breaking change (there is no major to take yet — semver's `0.x` carve-out). The `minor` here IS the breaking @@ -65,5 +56,5 @@ bump, not a feature bump. **BREAKING:** `CartStore.checkout` now takes a second, required argument — `checkout(cartId: string, orderId: OrderId)`. `Cart` (`@otta-sh/domain`) and `CartWire` (`@otta-sh/plugin`) both gain a required `orderId: string | null` -field, and `GET /carts/:cartId` now emits `orderId` on the cart body. Any -out-of-tree `CartStore` implementation or `CartWire` literal must be updated. +field, and the cart read now carries `orderId`. Any out-of-tree `CartStore` +implementation or `CartWire` literal must be updated. diff --git a/.changeset/cart-thread-productid.md b/.changeset/cart-thread-productid.md index c77ae9e3..3c74aee1 100644 --- a/.changeset/cart-thread-productid.md +++ b/.changeset/cart-thread-productid.md @@ -4,8 +4,8 @@ Thread `productId` through the storefront add-to-cart path so a storefront cart can be quoted and ordered (fixes #80). Previously the add-to-cart flow only ever -sent `sku`, so `cart_lines.product_id` persisted NULL and every -`POST /checkout/quote` 409'd `PRODUCT_NOT_PRICED` — the whole storefront funnel +sent `sku`, so a cart line's `productId` persisted NULL and every checkout quote +was refused with `PRODUCT_NOT_PRICED` — the whole storefront funnel (PDP → cart → checkout) was blocked even for a priced, active product. The `productId` (the CMS content id — the join key to `product_commerce`) is the @@ -15,20 +15,19 @@ piece that was missing. It is now carried end-to-end: `content.id`) alongside `sku`, and echoes it in the Block Kit button value. - The `storefront/cart/lines/add` route accepts an optional `productId` (validated: present-but-blank is `INVALID_INPUT`) and forwards it. -- `CommerceClient.addCartLine` / `HttpCommerceClient` gain a `productId: - string | null` parameter; the wire OMITS the field when null, so a bare/legacy - add stays byte-identical (absent ⇒ null at the service). +- `CommerceClient.addCartLine` gains a `productId: string | null` parameter; a + null is carried as an omission, so a bare/legacy add behaves exactly as before. -The service `addLine` route already accepted `productId` — the storefront was the -gap. The stale `cart-routes.ts` read-handler comment (which claimed the service -hardcodes `productId: null`) is corrected; a price-annotated `GET /carts/:cartId` -join remains a documented follow-up. +The add-line operation itself already accepted `productId` — the storefront was +the gap. The stale `cart-routes.ts` read-handler comment (which claimed the add +hardcodes `productId: null`) is corrected; a price-annotated cart read remains a +documented follow-up. SECURITY (surfaced in review, fixed here because threading `productId` makes it -reachable): the service `addLine` now RECONCILES the two independent client -inputs `sku` and `productId` against the trusted catalog. When a `product_commerce` -row exists for the `productId`, its `sku` must equal the submitted `sku`, else the -add is rejected with a new typed `409 SKU_MISMATCH` (mirrored into the plugin's +reachable): the add-line path now RECONCILES the two independent client inputs +`sku` and `productId` against the trusted catalog. When a commerce record exists +for the `productId`, its `sku` must equal the submitted `sku`, else the add is +rejected with a typed `SKU_MISMATCH` conflict (mirrored into the plugin's `CartFailureReason`) and no line is persisted. Without this, a caller could pair product A's `productId` (checkout takes price/title/entitlement from it) with product B's `sku` (order line + digital entitlement are keyed on the client `sku`) diff --git a/.changeset/checkout-address-capture.md b/.changeset/checkout-address-capture.md index 53ff8615..2ad46156 100644 --- a/.changeset/checkout-address-capture.md +++ b/.changeset/checkout-address-capture.md @@ -15,8 +15,8 @@ the already-explicit zone. This is the **capture + snapshot + display** slice. Per ADR-0009's sequencing the optional snapshot lands first; the **required-for-physical enforcement flip is deliberately deferred** until the storefront checkout UI actually collects the -address (enforcing "required" before the UI collects it would 400 every physical -checkout). Capture is therefore optional this slice — a physical order with no +address (enforcing "required" before the UI collects it would reject every +physical checkout). Capture is therefore optional this slice — a physical order with no address is still accepted. - **Domain (`[Domain]`).** New `OrderAddress` model — a single immutable slot @@ -30,20 +30,10 @@ address is still accepted. lengths) and rejects a malformed one with a new `INVALID_SHIPPING_ADDRESS` failure before minting anything. The customer-context `addresses` doc is retired from "NOT a per-order snapshot" to "profile book — prefill/context; the order's own ship-to - lives on `Order.shippingAddress`". -- **Adapters (`[Adapters]`).** New forward-only migration `0019_order_shipping_address` - — a 1:1 `order_shipping_address` table (PK/FK `order_id`), mirroring `order_totals`. - The Kysely adapter writes it in the SAME guarded transaction as the order + totals - (a replay re-inserts nothing — carried exactly once) and left-joins it on load; - historical orders read `null`. Insert-once — no code path UPDATEs it (immutability is - structural). Green against the extended `orderStoreContract` on better-sqlite3 and - Postgres. -- **Service (`[Service]`).** `POST /checkout/orders` accepts an optional validated - `shippingAddress` and forwards it (a logged-in checkout may prefill from the profile - book, but the order copies the SUBMITTED value). `INVALID_SHIPPING_ADDRESS` → 400. - `serializeOrder` (both the public order read and the admin detail) gains - `shippingAddress` and a display-only `totals.shippingZoneId` — the chosen zone read - off the totals' method snapshot, for the admin juxtaposition. + lives on `Order.shippingAddress`". The address is persisted in the SAME guarded + write as the order and its totals — carried exactly once on a replay, never + UPDATEd afterwards, so immutability is structural — and an order that predates + capture reads `null`. Green against the extended `orderStoreContract`. - **Plugin (`[Plugin]`).** The admin order detail gains a "Shipping address" section: the captured ship-to when present (with the country rendered next to the chosen shipping zone — display-only, no matching, so a human spots a "domestic zone / diff --git a/.changeset/coupon-admin-list.md b/.changeset/coupon-admin-list.md index f4108d64..b62c906a 100644 --- a/.changeset/coupon-admin-list.md +++ b/.changeset/coupon-admin-list.md @@ -12,8 +12,9 @@ coupon editing/creation UI, no new coupon fields — both are separate slices. keyset-paginated `CouponSummary` projection, ordered `created_at DESC, id DESC` (the only sort this slice offers). `coupons` had NO `created_at` column before this slice — `create()` now stamps one from the injected - `Clock` (`KyselyCouponStore`/`InMemoryCouponStore` both gain a required - `clock` constructor option). `CouponListFilter` is deliberately minimal: + `Clock` (`InMemoryCouponStore` and every `CouponStore` adapter gain a + required `clock` constructor option). `CouponListFilter` is deliberately + minimal: `search`, a case-insensitive EXACT match on `code` (the strictest `search` in the product — a coupon code is a structured identifier, not free text like a product title, so there is no substring half, and it did not follow the later @@ -27,19 +28,9 @@ coupon editing/creation UI, no new coupon fields — both are separate slices. pin the spec (empty, projection, ordering, identical-`created_at` tie-break, exact-code search, pagination no-overlap/no-gap, limit boundary). -- `@otta-sh/store-postgres`: migration `0018_coupons_admin_list` adds - `coupons.created_at` (`NOT NULL DEFAULT '1970-01-01T00:00:00.000Z'` — a - sentinel, not nullable, so a pre-migration row sorts deterministically to - the end of the DESC keyset on BOTH dialects; pg and better-sqlite3 order - NULLs oppositely in DESC, which a nullable sort-key column would have - exposed) plus a composite `(created_at, id)` index, mirroring `0015`'s - precedent for `listProducts`. `listCoupons` is a single `coupons` SELECT — - no join. -- `@otta-sh/service`: adds the internal-token-guarded `GET /admin/coupons` - (mounted alongside the existing coupon CRUD in `rules-admin.ts`) with the - same opaque base64url keyset cursor discipline as `GET /admin/products` — a - malformed/tampered cursor fails CLOSED to 400 and the decoded limit is - re-clamped, never trusted past 100. -- `@otta-sh/plugin`: adds `AdminRulesClient.listCoupons(filter, opts)` (client - method only — the admin UI screen is a follow-up slice), returning the - `CouponSummaryWire` projection + an opaque `nextCursor`. +- `@otta-sh/plugin`: the admin rules client gains `listCoupons(filter, opts)` + (client method only — the admin UI screen is a follow-up slice), returning + the `CouponSummaryWire` projection + an opaque `nextCursor`. The cursor keeps + the same base64url discipline as the admin products list: a malformed or + tampered cursor fails CLOSED and the decoded limit is re-clamped, never + trusted past 100. diff --git a/.changeset/coupon-admin-ui.md b/.changeset/coupon-admin-ui.md index 1a42ef75..f12e3670 100644 --- a/.changeset/coupon-admin-ui.md +++ b/.changeset/coupon-admin-ui.md @@ -7,12 +7,12 @@ admin screen — a keyset-paged coupons list (search = case-insensitive EXACT code match, the enumerate capability PR #74 added) drilling into a per-coupon detail/edit leaf, with create, LWW full-replace edit, and delete with the forbid-if-redeemed audit-trail conflict rendered honestly. Built entirely on -the existing list/detail scaffold and `AdminRulesClient` — no domain or -service change. +the existing list/detail scaffold and the admin rules client — no domain +change. UNCHANGED-vs-CLEAR, presented honestly: coupon UPDATE is the documented LWW -exception (PR #71) and its wire is a FULL replacement — the service coerces -every omitted field to null, so the wire cannot say "leave this field alone". +exception (PR #71) and it is a FULL replacement — every omitted field is +coerced to null, so the call cannot say "leave this field alone". The edit form therefore pre-fills EVERY editable field with the current value and always submits all of them (explicit null for a blanked field, never relying on omission): "leave unchanged" = don't touch the pre-fill, "clear" = @@ -21,7 +21,7 @@ no "unset" — the primary economic value (`amount` for fixed_amount, `rate` for percentage; the domain requires it) — refuses to blank at the plugin boundary. Identity/kind (`id`, `code`, `type`, fixed-amount `currency`) are immutable and render read-only. The detail load is the exact-code list search -(not `GET /coupons/:code`) because only the list projection carries +(not the single-coupon read) because only the list projection carries `startsAt`/`expiresAt` — a full-replace form that couldn't pre-fill the window would silently clear it on every save. Date bounds are normalized to ISO-8601 UTC at the boundary (the domain compares window strings @@ -37,7 +37,7 @@ dropped. Delete carries danger copy (in-flight carts recompute; placed orders keep their snapshotted discount); a redeemed coupon's detail withholds the delete -button and says why, and the server-side 409 renders the same audit-trail -copy for the race where a redemption lands after render. Every failure path -is a generic fail-closed banner — no raw HTTP status/URL ever reaches the -admin UI. +button and says why, and the forbid-if-redeemed refusal renders the same +audit-trail copy for the race where a redemption lands after render. Every +failure path is a generic fail-closed banner — no raw failure detail ever +reaches the admin UI. diff --git a/.changeset/coupons-detail-safety.md b/.changeset/coupons-detail-safety.md index ab619c13..c43a284e 100644 --- a/.changeset/coupons-detail-safety.md +++ b/.changeset/coupons-detail-safety.md @@ -10,8 +10,8 @@ keeps its current value. **The P0-class check passed, and is now pinned.** `PM §E3` flagged an `initial_value`-vs-`placeholder` hazard on this leaf: a form field that renders a coupon's CURRENT value as a grey `placeholder` submits it back as `""`, and -on a wire with no partial update (`PUT /admin/coupons/:id` coerces every -omitted key to null) that is a silent unset of a value the operator could see +against a full-replace update (the coupon edit coerces every omitted key to +null) that is a silent unset of a value the operator could see on screen when they pressed Save. Probed against a coupon with cap, minimum spend, both window bounds and both use bounds all set: every one already rode as a real `initial_value`, and an untouched save round-tripped all of them @@ -56,10 +56,10 @@ own `Valid` reading claims. Same-day windows are now expressible. Re-submitting the day a bound already falls on is NOT treated as an edit: an untouched save preserves canonically-stored bounds byte for byte, sub-day time included, so it cannot move a bound the screen only ever displayed to day precision. Legacy -non-canonical bounds — writable via the service, which only length-checks — -re-anchor to the displayed day's edge on first save instead: widening, to -match the display. A submitted day that -does not exist is REFUSED rather than rolled forward — `2027-02-30` parses +non-canonical bounds — writable through the underlying update, which only +length-checks — re-anchor to the displayed day's edge on first save instead: +widening, to match the display. A submitted day that does not exist is REFUSED +rather than rolled forward — `2027-02-30` parses happily and would otherwise be stored verbatim, then sort after every real day in February — and a date field arriving as a non-string is refused with a banner naming it, never read as a silent "unchanged". @@ -118,7 +118,7 @@ window instants must survive `parse → toISOString` unchanged, which is a stricter test than "it parsed" for the same reason as above. Anything else reads as "no current value" rather than reaching the record. `curCap` is carried only for the type that renders a cap field: a `fixed_amount` coupon -holding a stray `capCents` (reachable — the service validates each column, not +holding a stray `capCents` (reachable — the update validates each column, not the pair) would otherwise have had every save refused, naming a percentage-only field that is not on its screen, with no way out from the console. diff --git a/.changeset/cursor-in-the-url.md b/.changeset/cursor-in-the-url.md index 9bbc55ab..89ae590b 100644 --- a/.changeset/cursor-in-the-url.md +++ b/.changeset/cursor-in-the-url.md @@ -16,11 +16,11 @@ which is the same reset the list already performs in memory. Carrying the token in a public address is safe because of what the route does with it, not because of what it looks like: the token is unsigned base64url -JSON, so it can be read and written by anyone, and the service re-validates the +JSON, so it can be read and written by anyone, and the route re-validates the filter it carries through the same schema a query string is held to and re-clamps its page limit, both failing closed. A hand-written token can therefore only restate a query the operator was already permitted to make. The -console itself never parses or mints one — the encoding belongs to the service — +console itself never parses or mints one — the encoding belongs to the plugin — and it writes the value through the query encoder, so a future token whose alphabet is less forgiving than today's base64url still survives the round trip. @@ -38,11 +38,11 @@ relative period the instants sent beside the cursor are the ones it was minted with, which holds by construction: presets resolve to whole-day bounds, so two requests on the same UTC day resolve identically. -A refused cursor is recovered where the service's own error code can be read. +A refused cursor is recovered where the route's own error code can be read. `cursor filter mismatch` and `invalid cursor` both mean "drop the token and re-issue page one with these parameters", so the client does exactly that, once, and reports it as a flag on a successful page rather than as an error. That is -what lets the console tell a refused PAGE from an unreachable SERVICE — the two +what lets the console tell a refused PAGE from an unreachable ROUTE — the two want opposite treatments of the address bar — and it is why the Pricing & inventory route now resolves the low-stock threshold before paging too: a paged request that omitted it would describe fewer axes than its token and be refused @@ -51,7 +51,7 @@ every time. An address naming a page that will not open degrades to the first page of those filters, with a notice that says so and deliberately does not say why: every failure reaches this tier in one shape, so a rejected token, an expired session, -a failing service and a dropped connection are indistinguishable here, and copy +a failing route and a dropped connection are indistinguishable here, and copy naming one of them would send an operator to fix the wrong thing. Only a genuine cursor refusal resets, because only that one arrives as a page rather than as a failure; everything else leaves the cursor in the address, so a reload after @@ -69,7 +69,7 @@ ended. A filter change or a reload starts a fresh scan. This is also what a transient settings blip on a low-stock continuation now costs: the ability to page further, never the scan. -**Follow-up, not done here (service-side).** The gate compares a cursor against +**Follow-up, not done here (route-side).** The gate compares a cursor against the request's filter params only when the request states at least one axis; absent params still claim nothing. So a token minted under a filter, sent beside a request naming no filter at all, is still answered from the token — the one diff --git a/.changeset/delete-service-and-store-postgres.md b/.changeset/delete-service-and-store-postgres.md new file mode 100644 index 00000000..62dc87d8 --- /dev/null +++ b/.changeset/delete-service-and-store-postgres.md @@ -0,0 +1,46 @@ +--- +"@otta-sh/plugin": minor +--- + +Delete the HTTP transport, and with it the last two packages that only existed +to serve it. + +**`@otta-sh/service` and `@otta-sh/store-postgres` are removed from the +workspace and will not be published again.** Neither can be named in this +changeset's frontmatter — changesets refuses a release plan for a package whose +directory is gone — so their removal is recorded here, in the prose of the +package that outlived them. The commerce use-cases they wrapped were folded into +the plugin in the preceding slices: `@otta-sh/store-emdash` holds the state the +Kysely adapter used to hold, and the in-process clients answer the calls the +Hono app used to answer. Nothing was dropped on the way across; what is deleted +is the transport and its two homes, not the behaviour. + +Anyone still depending on either package should stop: there is no successor +published under those names. The last published versions remain installable but +are frozen, and their migrations no longer track the schema `@otta-sh/store-emdash` +writes. + +**`minor`, not `patch`: the package index loses public exports.** Six names are +gone from `@otta-sh/plugin`'s entry point, all of them the HTTP clients or their +options: + +- `HttpCommerceClient` +- `AdminOrdersClient` +- `AdminProductsClient` +- `AdminRulesClient` +- `ReportingSettingsClient` +- the `AdminRulesClientOptions` type + +Each had an in-process twin that has been the only implementation constructed +since the mode collapse, so no plugin code path changes. Only an importer that +reached past `makeCommerceClient(ctx)` / `makeAdminClients(ctx)` for a concrete +class is affected, and that importer should be taking the factory instead — it +returns the port, which is what the call sites were always typed against. + +The wire tests, the live-service test harness and the REST route surface they +exercised go with the clients. The behavioural contract they enforced does not: +it still runs, against the in-process clients, in the same shared suite. The two +Postgres-required concurrency races the deleted adapter suite held — a +once-only note append under a shared idempotency key, and a single audit event +under racing state flips — were re-pointed at `@otta-sh/store-emdash`'s own +stores rather than retired with it. diff --git a/.changeset/delete-unreached-review-pair.md b/.changeset/delete-unreached-review-pair.md index f42ed8ab..4c628f75 100644 --- a/.changeset/delete-unreached-review-pair.md +++ b/.changeset/delete-unreached-review-pair.md @@ -26,7 +26,7 @@ refused a blank `Refunded by`. All three lived only on the refund review step. The reachable refund confirm keeps its stale-watermark refusal (re-read the ledger, refuse on a mismatch, refuse a missing watermark fail-closed) and its money validation (integer minor units, a positive amount, no float laundered -into cents); an over-ceiling amount is refused by the service as +into cents); an over-ceiling amount is refused by the domain as `REFUND_EXCEEDS_TOTAL` / `REFUND_EXCEEDS_CAPTURED`. Re-introducing a server-side two-step confirm means writing all three against that flow's shape, not restoring them. diff --git a/.changeset/entitlements-check-auth.md b/.changeset/entitlements-check-auth.md index e1282bc4..ba1a769b 100644 --- a/.changeset/entitlements-check-auth.md +++ b/.changeset/entitlements-check-auth.md @@ -3,20 +3,14 @@ "@otta-sh/plugin": minor --- -Authenticate `GET /entitlements/check` — close the unauthenticated email existence oracle (#33, ADR-0011). +Authenticate the entitlement check — close the unauthenticated email existence oracle (#33, ADR-0011). - `@otta-sh/domain`: **contract tightening.** `EntitlementStore.check` now requires CASE-INSENSITIVE `buyerRef` matching (email semantics), enforced by the shared contract suite that every downstream adapter must pass — hence a minor. -- `@otta-sh/store-postgres`: **matching-semantics change** (precedent: `checkout-address-capture.md`). - The Kysely adapter folds case (`lower(buyer_ref) = lower(?)`) to conform to the tightened - contract — a `check` call that previously returned `false` for a case-differing `buyerRef` can - now return `true`, hence a minor rather than a patch. -- `@otta-sh/service`: **WIRE BREAK.** `GET /entitlements/check` is no longer an anonymous oracle - over email. Presence-based scope precedence: a query containing `buyerRef` now requires - `X-Internal-Token` (**401** on mismatch, **503** when unconfigured — never silently open); a - sku-only request requires a customer session (`Authorization: Bearer`); the `orderId` scope is - unchanged (open bearer capability). Callers probing by email must now send `X-Internal-Token`. -- `@otta-sh/plugin`: **WIRE BREAK.** The `entitlements/download` route input drops `buyerRef` in - favor of `sessionToken`; `HttpCommerceClient.checkEntitlement` gains an optional `sessionToken` - and now returns a typed `{ ok: false, reason: "UNAUTHENTICATED" }` on 401 instead of a boolean. +- `@otta-sh/plugin`: **WIRE BREAK.** Checking an entitlement by buyer email is no longer an + anonymous probe: the `entitlements/download` route input drops `buyerRef` in favor of + `sessionToken`, and the commerce client's `checkEntitlement` takes an optional `sessionToken` + and now returns a typed `{ ok: false, reason: "UNAUTHENTICATED" }` instead of a bare boolean, + so "could not ask" is distinguishable from "not entitled". The `orderId` scope is unchanged — + it stays an open capability read. diff --git a/.changeset/fix-admin-route-dispatch.md b/.changeset/fix-admin-route-dispatch.md index 4843a7bc..970cfa37 100644 --- a/.changeset/fix-admin-route-dispatch.md +++ b/.changeset/fix-admin-route-dispatch.md @@ -24,7 +24,7 @@ plugin previously registered per-page keys `"admin/reports"`/`"admin/settings"` (`/settings`, Gear icon) added to the trusted descriptor's `adminPages` alongside `REPORTS_PAGE`. - **Admin token via a write-only kv secret.** EmDash's `page_load` carries no - token, so the guarded `/reports/*` reads and `PUT /settings` failed auth. A + token, so the guarded reports reads and the settings write failed auth. A masked `secret_input` field (`internalToken`) on the Settings form persists the token write-only to `ctx.kv` under `settings:internalToken` (the webhook-notifier pattern): saved only on a non-empty submit (a blank submit @@ -32,7 +32,7 @@ plugin previously registered per-page keys `"admin/reports"`/`"admin/settings"` and the operational save now source the token from kv, not the interaction. - **No raw HTTP status/URL in error banners.** The Reports and Settings load-tier failure banners now show a generic remediation message instead of - echoing the service's HTTP status/URL. + echoing a raw transport status or URL. Capabilities stay exactly `content:read` + `network:request`; the dispatcher is IO-free and adds no egress — all proven under the workerd-on-Node sandbox. diff --git a/.changeset/fix-price-activate-published.md b/.changeset/fix-price-activate-published.md index dcb7b0c8..eacbf0c7 100644 --- a/.changeset/fix-price-activate-published.md +++ b/.changeset/fix-price-activate-published.md @@ -18,12 +18,12 @@ What this change resolves: the state is visible and the remedy is named. - **Auto-activation on the host-invoked content hooks.** `content:afterSave` and `content:afterPublish` both now activate a currently-PUBLISHED product's row via - the DEDICATED, guarded `POST /products/:id/commerce/activate` route (never a - field on the blanket `upsert`, which must never touch `active`/`deletedAt`). - They share one publish idempotency key + ordering watermark, so the two hooks - converge to a single applied flip and can never resurrect a SOFT-DELETED row + the DEDICATED, guarded activation path (never a field on the blanket `upsert`, + which must never touch `active`/`deletedAt`). They share one publish + idempotency key + ordering watermark, so the two hooks converge to a single + applied flip and can never resurrect a SOFT-DELETED row (the store's `activate` no-ops on a tombstone — the load-bearing invariant, - proven on SQLite + Postgres). + pinned by the store contract). Honest limitation: **pricing alone does NOT instantly flip the row active.** The row activates on the NEXT content save/republish of the published product (which @@ -32,10 +32,10 @@ the meantime. Fully-automatic activation directly from the pricing action remain a documented em-dash HOST follow-up: the stock admin renders the sandboxed field-widget from static manifest elements and does not drive it through the plugin's panel-state/route interaction pipeline, so the panel Save cannot yet -carry the document's publish signal back to the service. The plugin side of that -path (the panel-state route baking the signal into the Save button `value`, and -the route activating when it is present) is wired and tested, ready for when the -host threads it. +carry the document's publish signal through to the activation path. The plugin +side of that path (the panel-state route baking the signal into the Save button +`value`, and the route activating when it is present) is wired and tested, ready +for when the host threads it. Capabilities stay exactly `content:read` + `network:request`; proven under the workerd-on-Node sandbox. diff --git a/.changeset/fix-reservation-not-found-404.md b/.changeset/fix-reservation-not-found-404.md index ada67d57..b05ddf56 100644 --- a/.changeset/fix-reservation-not-found-404.md +++ b/.changeset/fix-reservation-not-found-404.md @@ -2,38 +2,30 @@ "@otta-sh/domain": minor --- -Typed 404 for `POST /inventory/commit` and `POST /inventory/release` against an -unknown `reservationId` (previously an untyped 500). +Typed "reservation not found" on the `InventoryStore` port: committing or +releasing an unknown `reservationId` now raises something a caller can +recognise, where before it was an untyped `Error` indistinguishable from a +store fault. At `0.x`, changesets map a **minor** bump to a breaking change (there is no major to take yet — semver's `0.x` carve-out). The `minor` here IS the breaking bump, not a feature bump. -- **`@otta-sh/domain`** — new exported `ReservationNotFoundError` on the - `InventoryStore` port, thrown from `commit`/`release` (and `commitMany`) - when `reservationId` was never created — distinct from - `ReservationCommitLostError`, the existing loud anomaly for a reservation - that existed but is no longer committable/releasable. The port docblock - above `commit` documents both, plus a known asymmetry: `adjust` shares the - same store choke point and throws the same typed error, but nothing at the - HTTP boundary maps it, so a cart `PATCH /carts/:id/lines/:lineId` against a - vanished reservation still 500s (deliberate, out of scope — the cart - failure taxonomy has no "reservation vanished" member). -- **`@otta-sh/store-postgres`** — the Kysely adapter's `#selectById` choke point - (reached by `commit`, `release`, `adjust`) and `commitMany`'s unknown-id - branch now throw `ReservationNotFoundError` instead of a bare `Error`. No - control-flow change, only a richer type. -- **`@otta-sh/service`** — `POST /inventory/commit` and `POST /inventory/release` - now catch `ReservationNotFoundError` and return **404** - `{ ok: false, reason: "RESERVATION_NOT_FOUND" }` (matching the repo's - `{ok:false,reason:…}` 404 convention) instead of falling through to the - generic 500 envelope. Any caller polling for a status code to distinguish - "unknown reservation" from a DB fault now gets one; a caller that only - checked `!response.ok` sees no change. `ReservationCommitLostError` keeps - its existing 500 anomaly semantics — a reservation that existed but was - lost (released/failed) is still an operational anomaly, not a client error. +The new exported `ReservationNotFoundError` is thrown from `commit`/`release` +(and `commitMany`) when `reservationId` was never created — distinct from +`ReservationCommitLostError`, the existing loud anomaly for a reservation that +existed but is no longer committable/releasable. The port docblock above +`commit` documents both, plus a known asymmetry: `adjust` shares the same store +choke point and throws the same typed error, but nothing on the cart path reads +it, so adjusting a cart line against a vanished reservation still surfaces as a +generic fault (deliberate, out of scope — the cart failure taxonomy has no +"reservation vanished" member). + +A caller that only asked "did this throw" sees no change; a caller that needs +to tell "unknown reservation" from a store fault now has the type to do it. **Known follow-up (not in this change):** `release` against a reservation that exists but is in a non-releasable state still throws an **untyped** -`Error` and 500s (the sibling of `ReservationCommitLostError` that was never -given a type). Typing it, and deciding 409-vs-500, is its own change. +`Error` (the sibling of `ReservationCommitLostError` that was never given a +type). Typing it, and deciding whether it reads as a caller error or an +operational anomaly, is its own change. diff --git a/.changeset/in-process-admin-orders.md b/.changeset/in-process-admin-orders.md index 6ce64721..799f9b3f 100644 --- a/.changeset/in-process-admin-orders.md +++ b/.changeset/in-process-admin-orders.md @@ -4,17 +4,16 @@ Run the admin Orders console on the plugin's own store. -`InProcessAdminOrdersClient` is the in-process twin of `AdminOrdersClient` — the -same twelve methods (`listOrders`, `getOrder`, `transitionOrder`, +`InProcessAdminOrdersClient` serves the whole admin-orders client contract — +twelve methods (`listOrders`, `getOrder`, `transitionOrder`, `resolveReconciliation`, `recordFulfillment`, `cancelOrder`, `getCustomerContext`, `getTimeline`, `getRefunds`, `refundOrder`, `listNotes`, -`addNote`), the same argument shapes and the same return values field for field, -with the `@otta-sh/domain` use-cases composed over the `@otta-sh/store-emdash` -adapters bound to `ctx.storage` instead of a commerce service. No egress: -`ctx.http` is never touched. +`addNote`) — with the `@otta-sh/domain` use-cases composed over the +`@otta-sh/store-emdash` adapters bound to `ctx.storage`. No egress: `ctx.http` is +never touched. -No field is narrowed. `ListPayload.total` is always present on a page this tier -served and an absent total is never spelled `0`; `cursorRejected` is only ever +No field is narrowed. `ListPayload.total` is always present on a page it +serves and an absent total is never spelled `0`; `cursorRejected` is only ever `true`; the detail's `transitions` stay derived from the domain state machine rather than re-listed; `deletedAt` keeps its tombstone semantics; and `shippingAddress` stays the immutable checkout snapshot (ADR-0009), never a @@ -22,32 +21,29 @@ re-read of a live address record. The refunds summary keeps both `refundedTotalCents` — the watermark the refund action reads — and the gateway's honest `refundable`. -Three pieces of the service's route layer are behaviour rather than framing and -are mirrored here: the orders list's opaque cursor (position + filter + limit) -with its re-validation on decode and the fail-closed filter/limit disagreement -check plus the one-shot page-one recovery, the wire serializers, and ADR-0008's +Three pieces are behaviour rather than transport framing, and so live on the +client itself: the orders list's opaque cursor (position + filter + limit) with +its re-validation on decode and the fail-closed filter/limit disagreement check +plus the one-shot page-one recovery, the payload serializers, and ADR-0008's refund ceiling — `computeRefundCeiling(Σ captured, frozen total)` less `Σ` -non-voided refunds, floored at zero — which lives in the route and so is ported -here rather than routed through a helper that does not exist. Route-level -idempotency fallbacks (`admin:transition:…`, `admin:resolve-reconciliation:…`, -`admin:fulfillment:…`, `admin:cancel:…`, `admin:note:…`) are preserved, and a -refund still REQUIRES a key (`MISSING_IDEMPOTENCY_KEY`) because it is additive. +non-voided refunds, floored at zero. The idempotency-key fallbacks +(`admin:transition:…`, `admin:resolve-reconciliation:…`, `admin:fulfillment:…`, +`admin:cancel:…`, `admin:note:…`) are preserved for a caller that supplies none, +and a refund still REQUIRES a key (`MISSING_IDEMPOTENCY_KEY`) because it is +additive. -Refund EXECUTION is not wired on this tier yet: no payment gateway has moved -in-process (INC-C1/C3), so a well-formed refund against a real order answers the -route's own `409 REFUND_GATEWAY_UNAVAILABLE` where the HTTP tier, which composes -a gateway, answers `409 REFUND_EXCEEDS_CAPTURED`. Both refuse, both leave the -ledger untouched, and each side is pinned by its own gated case. +Refund EXECUTION is not wired yet: no payment gateway has moved in-process +(INC-C1/C3), so a well-formed refund against a real order answers +`REFUND_GATEWAY_UNAVAILABLE`. It refuses, it leaves the ledger untouched, and it +is pinned by its own gated case. `makeAdminClients` now routes `orders` as well as `products`; rules and reporting arrive with their own increment and an absent surface stays absent rather than being stubbed. The admin Orders console route reads its client through the -factory instead of constructing an HTTP one directly. +factory instead of constructing one directly. -The client contract's admin-orders slice now runs on BOTH transports from the -same cases — the in-process tier over a real per-collection repository on SQLite, -the HTTP tier over a live service on Postgres. Order search is asserted at the -ADR-0019 §6 floor (id prefix, folded buyer-ref prefix, exact folded line sku) and -never at a tier's ceiling: the Postgres dialect's unanchored buyer-ref substring -is a sanctioned superset, so each tier pins its own side of that divergence in -its own file. +The client contract's admin-orders slice runs against this client over a real +per-collection repository. Order search is asserted at the ADR-0019 §6 floor (id +prefix, folded buyer-ref prefix, exact folded line sku) and never at an +implementation's ceiling — a store that can match more is a sanctioned superset, +and pins that in its own file. diff --git a/.changeset/in-process-admin-products.md b/.changeset/in-process-admin-products.md index 0f51882f..7f2ff6e1 100644 --- a/.changeset/in-process-admin-products.md +++ b/.changeset/in-process-admin-products.md @@ -4,12 +4,11 @@ Run the admin Products console on the plugin's own store. -`InProcessAdminProductsClient` is the in-process twin of `AdminProductsClient` — -the same six methods (`listProducts`, `getProduct`, `updateProduct`, `restock`, -`removeStock`, `getTaxClasses`), the same argument shapes and the same return -values field for field, with the `@otta-sh/domain` use-cases composed over the -`@otta-sh/store-emdash` adapters bound to `ctx.storage` instead of a commerce -service. No egress: `ctx.http` is never touched. +`InProcessAdminProductsClient` answers the console's six admin products methods +(`listProducts`, `getProduct`, `updateProduct`, `restock`, `removeStock`, +`getTaxClasses`) with the `@otta-sh/domain` use-cases composed over the +`@otta-sh/store-emdash` adapters bound to `ctx.storage`. No egress: `ctx.http` +is never touched. No field is narrowed, because the React screens consume these results through structural mirrors rather than an imported wire type, so a dropped field would @@ -17,27 +16,22 @@ be invisible to the compiler: `onHand` stays `number | null` and is never coerced to `0`, `deletedAt` is always present, and every `reason` member keeps its operands. -Three pieces of the service's route layer are behaviour rather than framing and -are mirrored here: the products list's opaque cursor (position + filter + limit) -with its re-validation on decode and the fail-closed filter/limit disagreement -check, the two wire serializers, and `getTaxClasses` — the unfiltered registry -read that lives in the service's rules route even though it is a products -method. Inputs are refused at the boundary through the plugin's own -`commerce-input` mirrors, returning the typed `{ ok: false, reason: "invalid" }` -where the other transport's 400 produced one and rejecting where it threw. +Three pieces are behaviour rather than framing and are kept here: the products +list's opaque cursor (position + filter + limit) with its re-validation on +decode and the fail-closed filter/limit disagreement check, the two wire +serializers, and `getTaxClasses` — the unfiltered registry read that sits with +the rules surface even though it is a products method. Inputs are refused at the +boundary through the plugin's own `commerce-input` mirrors, returning the typed +`{ ok: false, reason: "invalid" }` rather than throwing. `makeAdminClients` is the admin composition root — the console's twin of -`makeCommerceClient` — so which tier answers a console read is one factory's +`makeCommerceClient` — so which client answers a console read is one factory's decision rather than each route's. Only `products` is routed through it today; orders, rules and reporting arrive with their own increments and an absent -surface stays absent rather than being stubbed. The route now reads the admin -tokens once per request and hands them to the factory, instead of each of them -reading write-only kv separately. +surface stays absent rather than being stubbed. -There is no admin auth in the in-process branch, deliberately (ADR-0014 D3): the -`X-Internal-Token` / `X-Service-Token` pair authenticates a caller to the -service, and in-process there is no service to authenticate to. +There is no admin auth on this path, deliberately (ADR-0014 D3): the console +runs inside the plugin, so there is no remote caller left to authenticate. -The client contract's admin-products slice now runs on BOTH transports from the -same cases — the in-process tier over a real per-collection repository, the HTTP -tier over a live service. +The client contract's admin-products slice runs against this client over a real +per-collection repository, from the same cases the console's own screens use. diff --git a/.changeset/in-process-admin-rules.md b/.changeset/in-process-admin-rules.md index fe6f1274..f635d1a4 100644 --- a/.changeset/in-process-admin-rules.md +++ b/.changeset/in-process-admin-rules.md @@ -4,23 +4,22 @@ Run the admin Shipping, Tax and Coupons consoles on the plugin's own store. -`InProcessAdminRulesClient` is the in-process twin of `AdminRulesClient` — the -same twenty-five methods (`listZones`, `createZone`, `updateZone`, `deleteZone`, +`InProcessAdminRulesClient` is the console's whole rules surface in one client — +twenty-five methods (`listZones`, `createZone`, `updateZone`, `deleteZone`, `listMethods`, `createMethod`, `updateMethod`, `deleteMethod`, `getRate`, `createRate`, `updateRate`, `deleteRate`, `listTaxClasses`, `createTaxClass`, `updateTaxClass`, `deleteTaxClass`, `listTaxRates`, `createTaxRate`, `updateTaxRate`, `deleteTaxRate`, `listCoupons`, `getCoupon`, `createCoupon`, -`updateCoupon`, `deleteCoupon`), the same argument shapes and the same return -values field for field, with the `@otta-sh/domain` ports composed over the -`@otta-sh/store-emdash` adapters bound to `ctx.storage` instead of a commerce -service. No egress: `ctx.http` is never touched. +`updateCoupon`, `deleteCoupon`), with the `@otta-sh/domain` ports composed over +the `@otta-sh/store-emdash` adapters bound to `ctx.storage`. No egress: nothing +here leaves the process. No field is narrowed, and two shapes stay deliberately apart: the coupon detail -read omits `startsAt`/`expiresAt` exactly as the service's serializer does, while -the list row carries them plus `createdAt`, because the console renders the -validity window straight off the list rather than fetching each row's detail. -`CouponsListResult.total` is present on every page this tier serves and an absent -total is never spelled `0`. +read omits `startsAt`/`expiresAt`, while the list row carries them plus +`createdAt`, because the console renders the validity window straight off the +list rather than fetching each row's detail. +`CouponsListResult.total` is present on every page and an absent total is never +spelled `0`. Last-writer-wins versus compare-and-set stays per entity rather than being homogenized. Zones, shipping methods, tax classes and coupons carry no money and @@ -32,14 +31,14 @@ full-replace edits keep their required-nullable keys — `regions`, silently wiping a zone's match list, a free-shipping threshold or a rate's shipping behaviour. -Two pieces of the service's route layer are behaviour rather than framing and are -mirrored here. The coupons list's opaque cursor (position + filter + limit) is -re-validated on decode and its limit re-clamped, and the predicate comes solely -from the token when one is present, as the route does. And the coupon-economics -rule that closed issue #75 — a `fixed_amount` coupon may not lose its -`amountCents`, a `percentage` coupon may not lose its `rateBps` — is replicated -as the route's fetch-then-validate: the coupon is read to learn its immutable -type, then the edit is refused before any write. +Two pieces that used to sit in a route layer are behaviour rather than framing, +and so live in the client. The coupons list's opaque cursor (position + filter + +limit) is re-validated on decode and its limit re-clamped, and the predicate +comes solely from the token when one is present. And the coupon-economics rule +that closed issue #75 — a `fixed_amount` coupon may not lose its `amountCents`, +a `percentage` coupon may not lose its `rateBps` — is enforced as +fetch-then-validate: the coupon is read to learn its immutable type, then the +edit is refused before any write. `deleteTaxClass` keeps its own result type because it is the one delete on this surface composed over two aggregates: it counts referencing products first, then @@ -48,22 +47,19 @@ is in the way. The leaf rate deletes never answer `in_use`, and every delete is idempotent. Input-shape refusals reject rather than resolving to a synthesized status, so -`RulesCreateResult`'s reason-less `{ ok: false, status }` arm stays HTTP-only and -is genuinely untested in-process rather than faked. The coupon-economics refusal -is the exception and answers identically on both tiers, because it is ported route -behaviour rather than a boundary check. +`RulesCreateResult`'s reason-less `{ ok: false, status }` arm goes unused here +rather than being faked. The coupon-economics refusal is the exception and +answers with a reason, because it is ported route behaviour rather than a +boundary check. The in-process client takes no admin or service token (ADR-0014 D3): EmDash's own admin auth and CSRF gate the console routes, and there is no service to authenticate to. `makeAdminClients` now routes `rules` alongside `products` and `orders`; reporting arrives with its own increment and an absent surface stays absent rather than being stubbed. The Shipping, Tax and Coupons console routes -read their client through the factory instead of constructing an HTTP one -directly, reading this request's tokens once and passing them in. +read their client through the factory instead of constructing one directly. -The client contract's admin-rules slice now covers all twenty-five methods and -runs on BOTH transports from the same cases — the in-process tier over a real -per-collection repository on SQLite, the HTTP tier over a live service on -Postgres — including the registry reads, the LWW method and tax-class edits, the -per-currency rate read whose absence is `null`, both referential arms of the -tax-class delete, and the #75 coupon rule. +The client contract's admin-rules slice now covers all twenty-five methods, +running over a real per-collection repository — including the registry reads, +the LWW method and tax-class edits, the per-currency rate read whose absence is +`null`, both referential arms of the tax-class delete, and the #75 coupon rule. diff --git a/.changeset/in-process-commerce-client-storefront.md b/.changeset/in-process-commerce-client-storefront.md index 463784a0..b8d48971 100644 --- a/.changeset/in-process-commerce-client-storefront.md +++ b/.changeset/in-process-commerce-client-storefront.md @@ -32,9 +32,8 @@ egress (ADR-0018). not make a consumer resolve a package this one does not depend on. The mirror is drift-checked at the composition root (both directions), the published types name no host package, and a test asserts that of every emitted declaration. Optional because - the HTTP transport never reads it and the unit suites that hand-build a context have - none to offer; the in-process composition demands it by name and fails loudly without - it. No new capability: the host builds the store on an always-available path and + the unit suites that hand-build a context have none to offer; the in-process composition + demands it by name and fails loudly without it. No new capability: the host builds the store on an always-available path and there is no capability string for it. Declaration emit for the package now runs in TypeScript project mode, which is what a value-level import of a workspace source package requires. @@ -49,8 +48,7 @@ egress (ADR-0018). - The packaging guard builds what the package's own build builds, declarations included — it had been skipping them, which is why it stayed green against a build that could not run at all. -- The client contract's storefront slice now runs on BOTH transports from the same - cases — the in-process tier over a real per-collection repository on SQLite, the - HTTP tier over a live service — and the workerd suites carry a real +- The client contract's storefront slice now runs against the in-process tier over a + real per-collection repository on SQLite, and the workerd suites carry a real `ctx.storage`, with a new suite driving a commerce write, read and join read from inside the isolate. diff --git a/.changeset/in-process-email-and-x402-settlement.md b/.changeset/in-process-email-and-x402-settlement.md index f3bb4a1b..fb136da0 100644 --- a/.changeset/in-process-email-and-x402-settlement.md +++ b/.changeset/in-process-email-and-x402-settlement.md @@ -9,12 +9,11 @@ Dispatch order emails and settle x402 payments from inside the plugin, over and `X402Facilitator` ports, the rendered wire bodies, the `Idempotency-Key` dedupe hinge and `refundable = false` (ADR-0008) are all unchanged. -- `@otta-sh/domain`: `renderEmail` / `customerSafeCancellationCopy` move here - verbatim from `@otta-sh/service`, beside `buildOrderEmailData` and the - `EmailTemplate` union. They are pure functions of a template plus explicit - data — no IO, no store reach-back — so the purity contract is unchanged; they - had to move because BOTH `EmailSender` adapters now need them and they live in - packages that cannot import each other. Money still renders from integer minor +- `@otta-sh/domain`: `renderEmail` / `customerSafeCancellationCopy` now live + here, beside `buildOrderEmailData` and the `EmailTemplate` union. They are + pure functions of a template plus explicit data — no IO, no store reach-back — + so the purity contract is unchanged; the domain is the one place every + `EmailSender` adapter can reach them from. Money still renders from integer minor units, and now renders a NEGATIVE amount correctly (`-550` was "-6.-50") and a non-integer not at all. `PaymentEventStore` also grows `orderForDedupeKey(key)`: `dedupe`'s boolean says a row EXISTS, not whose it @@ -40,8 +39,8 @@ dedupe hinge and `refundable = false` (ADR-0008) are all unchanged. timeout-bounded) and the x402 wiring (`wireX402Gateway` / `x402GatewayFromCtx`), both reaching their provider only via `ctx.http` + `allowedHosts`. Adds the PUBLIC `entitlements/x402/settle` route — the - in-process equivalent of the service's `POST /entitlements/grant`, behind the - SAME two layers the Stripe webhook route uses — the shared edge token + in-process entitlement grant, behind the SAME two layers the Stripe webhook + route uses — the shared edge token (`settings:edgeToken`, pass-through when unset) as a cheap outer gate, then the real check: the order must be `paymentMethod: "x402"`, the proof must verify through the configured facilitator, and the on-chain `transaction` must @@ -57,12 +56,6 @@ dedupe hinge and `refundable = false` (ADR-0008) are all unchanged. override) and reports `skipped` when no email URL was baked in. Secrets stay in write-only kv; every kv read is fail-soft and every missing-config path yields no sender / no gateway rather than an unverified settlement. -- `@otta-sh/service`: imports `renderEmail` from the domain instead of its own - deleted copy. No behavior change. NOTE that the service is untouched - otherwise: it still wires only the offline `createTestFacilitator` behind - `X402_ALLOW_TEST_FACILITATOR`, and that gate still guards exactly what it - always did for as long as the service runs — the in-process path simply cannot - reach that facilitator. ACTION REQUIRED ON UPGRADE — RE-PROVISION THE x402 FACILITATOR CREDENTIAL. The kv key is now `settings:x402FacilitatorApiKey`; the old diff --git a/.changeset/in-process-reporting-settings.md b/.changeset/in-process-reporting-settings.md index 2e8473ef..f96fbe9d 100644 --- a/.changeset/in-process-reporting-settings.md +++ b/.changeset/in-process-reporting-settings.md @@ -3,25 +3,18 @@ --- Serve the admin Reports screen, the Settings form and the Products console's -low-stock band from the plugin's own document store when the plugin runs -in-process, instead of calling the commerce service over HTTP. +low-stock band from the plugin's own document store. The reporting + settings surface (`getRevenue`, `getOrdersByStatus`, -`getTopProducts`, `getLowStock`, `getSettings`, `updateSettings`) now has both -tiers behind `makeAdminClients`, and the transport-agnostic contract suite runs -every one of those six methods against both of them. +`getTopProducts`, `getLowStock`, `getSettings`, `updateSettings`) now sits +behind `makeAdminClients`, and the transport-agnostic client contract suite runs +every one of those six methods against it. A settings save that fails now states WHY structurally, on `UpdateSettingsResult.reason` (`"validation"`, `"superseded"`, `"unavailable"`). This is a RATIFIED change to a published surface — proposed and approved 2026-09-16 under work order 02, not an incidental widening. -Callers should branch on `reason` first; the HTTP-only `status` stays as a -legacy fallback and is now optional, since the in-process tier has no HTTP -status and will not synthesize one. A lost compare-and-set is reported as -`"superseded"` and the Settings form now says so rather than inviting a retry -that cannot win. - -Also fixes `ReportingSettingsClient.updateSettings`, which sent -`X-Internal-Token` only when a token was passed per call and ignored the one the -client was constructed with — a save could take a 401 on a screen whose reads -all succeeded. +Callers should branch on `reason` first; the numeric `status` stays as an +optional legacy fallback, and nothing synthesizes one any more. A lost +compare-and-set is reported as `"superseded"`, and the Settings form now says so +rather than inviting a retry that cannot win. diff --git a/.changeset/list-counts-and-empty-states.md b/.changeset/list-counts-and-empty-states.md index bc89adff..8f6ea050 100644 --- a/.changeset/list-counts-and-empty-states.md +++ b/.changeset/list-counts-and-empty-states.md @@ -19,7 +19,7 @@ same count reads `25 orders on this page`, which is the smaller claim and the true one. Page 3 of 3 knows nothing about pages 1 and 2 (keyset paging carries no running offset, and the scaffold deliberately does not accumulate one across stateless interactions), so it stays page-scoped too. A whole-store total needs -the service to return one alongside `nextCursor`; until it does, a number an +the port to return one alongside `nextCursor`; until it does, a number an operator would reconcile against must not be invented here. **Zero renders no count at all.** Never `0 orders` — at zero the state below diff --git a/.changeset/list-refresh-window.md b/.changeset/list-refresh-window.md index 34e3137b..a682df85 100644 --- a/.changeset/list-refresh-window.md +++ b/.changeset/list-refresh-window.md @@ -35,7 +35,7 @@ cursor, leaving everything above it exactly as stale as it was. The ruling is the stack truncated to match — a window half reconciled would carry one count line over rows read at two different moments, and a stack claiming the old depth would number them wrongly. A walk that re-read *nothing* leaves the window entirely alone under its own - title, because the rows on screen are still coherent. A page the service refuses mid-walk + title, because the rows on screen are still coherent. A page the plugin refuses mid-walk is discarded rather than merged: the recovered first page answers a different question, and merging it would silently relocate a window that opens elsewhere — and because the committed window then ends on the very token that was refused, paging is withdrawn there @@ -47,7 +47,7 @@ cursor, leaving everything above it exactly as stale as it was. The ruling is pre-refresh verdict (and the withheld exact count) in rather than believing the value a continuation reports by contract. - **A walk that was REFUSED gets its own sentence.** It ends on a window with fewer pages - than it had *and* on the token the service just rejected, so neither of the other two + than it had *and* on the token the plugin just rejected, so neither of the other two notices may stand there: the paging-stopped one opens by promising the rows on screen are unaffected, and the partial-refresh one ends by naming `Load more`, which would re-send that token. Both stop notices are announced, because either way rows the operator had are @@ -69,6 +69,6 @@ the walk trades depth for latency, and letting Apply cancel it needs a rule for half-rebuilt window then shows. Reachable today only at depths no fixture exercises; recorded so it is a decision rather than a discovery. -No service or plugin API changes — a refresh is built from requests the service already +No plugin API changes — a refresh is built from requests the plugin already answers, and the browser still never parses a cursor. The Block Kit lists replace rather than accumulate and are untouched. diff --git a/.changeset/low-stock-list-predicate.md b/.changeset/low-stock-list-predicate.md index fec5e271..ff9e03ea 100644 --- a/.changeset/low-stock-list-predicate.md +++ b/.changeset/low-stock-list-predicate.md @@ -15,19 +15,17 @@ existing caller keeps seeing exactly what it saw before, and a caller that cannot resolve a threshold should simply omit the field rather than filter to nothing. -The field's domain is a non-negative integer (mirroring the HTTP boundary's own -`z.number().int().nonnegative()` validation). A value outside it throws the new +The field's domain is a non-negative integer. A value outside it throws the new `InvalidLowStockThresholdError`, exported alongside its `isValidLowStockThreshold` guard, on every adapter alike — checked before any comparison or query runs, so -a fractional or non-finite threshold can never get three different answers from -the fake, SQLite, and Postgres. +a fractional or non-finite threshold can never get one answer from the in-memory +fake and a different one from a store that has to resolve the stock count. -`store-postgres` reuses `listProducts`'s existing `inventory` LEFT JOIN (no new -join, no new index — the join already carries `on_hand`) and adds the SAME join -to `countProducts`, but only when this filter is set, so every other predicate -keeps its join-free plan. The in-memory fake mirrors both dialects byte-for-byte, -pinned by the shared contract suite across every case: the boundary (inclusive), -zero-on-hand, the two "unknown" shapes, the empty-match shape, out-of-domain -rejection, filter composition, and pagination. +Resolving the count is the adapter's own business, and an adapter that already +reads `on_hand` for the list's `onHand` projection pays nothing new for the +filter. Every implementation is held to the same answers by the shared contract +suite, across every case: the boundary (inclusive), zero-on-hand, the two +"unknown" shapes, the empty-match shape, out-of-domain rejection, filter +composition, and pagination. Port-level only — no consumer wires this filter up yet. diff --git a/.changeset/low-stock-server-side-predicate.md b/.changeset/low-stock-server-side-predicate.md index 84885a0d..7b9ef08e 100644 --- a/.changeset/low-stock-server-side-predicate.md +++ b/.changeset/low-stock-server-side-predicate.md @@ -17,7 +17,7 @@ while the plugin only gains an optional field. field, carried on the admin Products list request once the console has resolved the store's threshold and the operator has asked to filter by it. The count line's `total` is now shown for a genuinely filtered page (the - service's exact count describes the same rows on screen) and withheld only + exact count describes the same rows on screen) and withheld only when the threshold could not be resolved and the request never carried a predicate — the inverse of the old narrowing days. The degradation banner now reports the threshold-unreadable and on-hand-unreadable causes @@ -26,22 +26,16 @@ while the plugin only gains an optional field. rode inside the cursor, so a settings read that fails only while paging can no longer claim the filter was skipped over a list that really was filtered. - `@otta-sh/domain`: `isValidLowStockThreshold` gains an upper bound, exported - as `MAX_LOW_STOCK_THRESHOLD`. `inventory.on_hand` is a Postgres `integer` and - the threshold is bound against it, so a value above `int4` was refused by - Postgres and ACCEPTED by SQLite and the fake — the same three-way adapter - disagreement the guard exists to make unreachable, and one that surfaced as a - 500 through the catch that turns a bad threshold into a 400. Pinned in the - contract suite, so every adapter refuses it identically. -- `@otta-sh/service`: the admin Products list query and its opaque keyset - cursor both accept `lowStockThreshold` (a non-negative integer, mirroring - the existing settings/report fields), and a value outside that domain is a - 400, not a 500. The query-string form is gated on plain digits rather than - coerced, so `?lowStockThreshold=` is a 400 instead of `Number("")`'s zero — - which would have silently narrowed the list to out-of-stock rows — and `0x10` - and `1e2` no longer mean 16 and 100. All three threshold schemas — the list - query, the cursor-embedded filter and the settings WRITE — now carry the - domain's `int4` ceiling; the settings write matters most, because the saved - value is what every later list read binds without ever appearing in a URL. + as `MAX_LOW_STOCK_THRESHOLD`. The threshold is bound against an on-hand count + an adapter may keep in a 32-bit integer column, so a value above `int4` was + refused by one adapter and ACCEPTED by the others — the same three-way + adapter disagreement the guard exists to make unreachable, and one that + surfaced as a crash through the catch that turns a bad threshold into a plain + refusal. Pinned in the contract suite, so every adapter refuses it + identically. The threshold is validated in all three places it travels — the + list filter, the cursor-embedded filter and the settings WRITE; the settings + write matters most, because the saved value is what every later list read + binds without the operator ever retyping it. - `@otta-sh/admin-react`: the Pricing & inventory list declares the shared count ladder's `service-filtered` scope unconditionally now that "Low stock only" is a real server-side predicate, so a filtered page that exhausts the diff --git a/.changeset/lowstock-page-scope-count.md b/.changeset/lowstock-page-scope-count.md index 6bb5823a..879d8ca8 100644 --- a/.changeset/lowstock-page-scope-count.md +++ b/.changeset/lowstock-page-scope-count.md @@ -6,8 +6,8 @@ Fix the Pricing & inventory list stating a page-scoped "Low stock only" count as if it described the whole catalogue. -`listOutcome` inferred a count line was "complete" — and dropped its "on this page" / "loaded so far" qualifier — whenever the render held the first page and no next cursor remained. That inference holds for a service-side filter, where the fetched page and the filtered set are the same collection, but "Low stock only" narrows an already-fetched page client-side, so the fetch being done says nothing about whether the narrowed set is. Whenever a narrowed result happened to fit on one page (or a scan reached the end of the catalogue), the count could lose its qualifier and read as a whole-catalogue claim. +`listOutcome` inferred a count line was "complete" — and dropped its "on this page" / "loaded so far" qualifier — whenever the render held the first page and no next cursor remained. That inference holds for a filter the list read applied itself, where the fetched page and the filtered set are the same collection, but "Low stock only" narrows an already-fetched page client-side, so the fetch being done says nothing about whether the narrowed set is. Whenever a narrowed result happened to fit on one page (or a scan reached the end of the catalogue), the count could lose its qualifier and read as a whole-catalogue claim. `listOutcome` now takes a **required** `countScope: "service-filtered" | "narrowed-after-fetch"` in place of an opt-in boolean, so a caller cannot omit it and quietly inherit the larger, whole-set-capable default. Setting `"narrowed-after-fetch"` keeps the qualifier regardless of `firstPage`/`hasNext`, and refuses to honour a `total` even if one is present — both enforced inside `listOutcome`, not left to the caller. `orders-list.tsx` and `@otta-sh/plugin`'s Block Kit `listResult` (and its one caller, Coupons) state `"service-filtered"` explicitly; no behaviour change for either, since neither narrows a fetched page. -The Pricing & inventory list sets `"narrowed-after-fetch"` from whether the low-stock narrowing **actually applied to the page being rendered**, not from whether the operator checked the box: the plugin can leave a request unnarrowed (`stock.filterUnavailable`, when the low-stock threshold can't be read) while still returning every product and the service's own exact `total`, and the count now reads "products" — with that real total — on exactly that page, never "low-stock products" beside a total that describes a different set of rows. +The Pricing & inventory list sets `"narrowed-after-fetch"` from whether the low-stock narrowing **actually applied to the page being rendered**, not from whether the operator checked the box: the plugin can leave a request unnarrowed (`stock.filterUnavailable`, when the low-stock threshold can't be read) while still returning every product and the read's own exact `total`, and the count now reads "products" — with that real total — on exactly that page, never "low-stock products" beside a total that describes a different set of rows. diff --git a/.changeset/merchant-restock.md b/.changeset/merchant-restock.md index 1aeb5261..68fee4ae 100644 --- a/.changeset/merchant-restock.md +++ b/.changeset/merchant-restock.md @@ -22,16 +22,6 @@ reservation-scoped, so a merchant had no safe path to change a live sku's raw on is a typed `StockMovementMismatchError`. An unknown sku is a clean `UNKNOWN_SKU` that does NOT consume the key (mirrors `reserve`'s parity) and never auto-creates the row — `seedOnHand` stays the sole create path. -- **Adapters (`[Adapters]`).** Kysely implementation (sqlite + pg) with the atomic movement as - a single guarded UPDATE inside the claim transaction (no read-modify-write). Forward-only - migration `0016_inventory_stock_movements`. The Postgres no-oversell races are green - (restock +N racing M reservations; N guarded removals racing M reservations; concurrent - same-key restock/removal replays applied exactly once). -- **Service (`[Service]`).** `POST /admin/products/:id/restock` and `.../remove-stock` under - the `X-Service-Token` write gate + internal token, resolving the productId to its - authoritative sku (never trusting a client-supplied one). A restock is additive (not - idempotent by nature), so the `Idempotency-Key` header is REQUIRED — there is no safe - content-only fallback. - **Plugin (`[Plugin]`).** Restock + remove-stock forms on the product detail (integer-only qty inputs — same integer discipline as money, never a float widget; clear copy showing current available and danger copy on removal). Each carries a per-render nonce so a diff --git a/.changeset/one-commerce-client-factory.md b/.changeset/one-commerce-client-factory.md index 87dc68d4..d1c6aaaf 100644 --- a/.changeset/one-commerce-client-factory.md +++ b/.changeset/one-commerce-client-factory.md @@ -4,20 +4,14 @@ Route every commerce-client construction through one factory. -The six modules that each hand-rolled the same `new HttpCommerceClient({ fetch: -ctx.http.fetch, baseUrl, …serviceToken })` — the PDP loader, the cart, checkout -and account routes, the entitlement download route and the content sync hooks — -now call a single `makeCommerceClient(ctx)` composition root, across all -nineteen call sites. Nothing about the wire changes: the same base URL, the -same `ctx.http.fetch` as the only egress, and the same write-gate token read -from write-only plugin kv, with an unset token still attaching no header at all. +The six modules that each hand-rolled their own commerce client — the PDP +loader, the cart, checkout and account routes, the entitlement download route +and the content sync hooks — now call a single `makeCommerceClient(ctx)` +composition root, across all nineteen call sites. Nothing observable changes: +the same client, built the same way, from the same plugin context. What it buys +is one place to change how a commerce client is made, instead of nineteen. `checkEntitlement` is now declared on the `CommerceClient` port rather than only -on the HTTP adapter, so the download route can be handed the port instead of the -concrete class. The adapter already implemented exactly that signature. - -A build-time `__OTTA_COMMERCE_MODE__` define selects the transport, defaulting to -`"http"` wherever no bundler sets it. It is deliberately temporary: it exists -only so an upcoming in-process commerce client can be run against the same -behavioural contract as the HTTP one before the HTTP transport is removed, and -both the flag and the branch it drives are deleted with that transport. +on the adapter that happened to implement it, so the download route can be +handed the port instead of a concrete class. The adapter already implemented +exactly that signature. diff --git a/.changeset/one-home-per-field-remove-commerce-bag.md b/.changeset/one-home-per-field-remove-commerce-bag.md index 65fe4517..2d8cc5a0 100644 --- a/.changeset/one-home-per-field-remove-commerce-bag.md +++ b/.changeset/one-home-per-field-remove-commerce-bag.md @@ -45,8 +45,8 @@ Behavioural changes worth knowing: guard and the ordering-watermark guard sit on the same conditional update, so a same-key no-op would freeze `content_updated_at` and let a reordered older save win permanently, corrupting the value order lines snapshot. The correct fix is in the store adapter, tracked as issue #153. -- No migration. No change to `PUT /products/:id/commerce`, which keeps carrying `title` — it is - the sync's channel. +- No migration, and no change to the sync's own write path, which keeps carrying `title` — that + write is the sync's channel. **Upgrading.** Removing the field from the seed does not remove it from a database that already has it: EmDash's seed applier creates and updates fields but never deletes one the seed stopped diff --git a/.changeset/order-cancel-with-reason.md b/.changeset/order-cancel-with-reason.md index 5d26b6b5..bf4ebb06 100644 --- a/.changeset/order-cancel-with-reason.md +++ b/.changeset/order-cancel-with-reason.md @@ -5,8 +5,8 @@ Cancel an order WITH a structured reason (detail optional), and make the cancelled- notification email carry WHY instead of a reason-free notice (admin-UX Increment 1, -"cancel with reason" slice). Before this slice, cancelling was a bare `POST -.../transition {toState:"cancelled"}` — no reason captured, and the cancelled email said +"cancel with reason" slice). Before this slice, cancelling was a bare transition to +`cancelled` — no reason captured, and the cancelled email said only "Your order has been cancelled." Discovery: cancelling has never released reserved stock in this domain (only `pending → expired`'s guarded sweep and settle's failed-payment path do that, per the Phase 5 design doc); this slice does not change that — it is @@ -25,7 +25,7 @@ The core design decisions: so it automatically covers every state the machine allows to cancel. Mutable-envelope only — it NEVER touches line items, prices, or totals (the snapshot invariant); it does NOT release inventory (that gap, if any, is unchanged and out of scope). The bare - `POST .../transition` stays available for other callers/back-compat — a cancellation via + transition stays available for other callers/back-compat — a cancellation via that path carries no reason (`cancellation === null`), mirroring `recordFulfillment`'s shipped-without-tracking case. @@ -38,15 +38,12 @@ The core design decisions: new pure use-case `cancelOrder` (validate → derive legality → delegate; idempotent replay + the stale-race disambiguation mirror `recordFulfillment`/`transitionOrder`). `buildOrderEmailData` now carries the cancellation so the cancelled template can render it. -- **Adapters (`[Adapters]`).** Forward-only migration `0013_order_cancellation` adds four - nullable columns to `orders` (portable text DDL, identical on better-sqlite3 + pg). Both - adapters green against the new `orderCancellationContract`; Postgres additionally runs the - concurrency races — N concurrent cancels resolve to exactly one winner, and cancelling +- **Adapters (`[Adapters]`).** Every `OrderStore` adapter carries the four nullable + cancellation fields and is green against the new `orderCancellationContract`, which + pins the races too — N concurrent cancels resolve to exactly one winner, and cancelling racing `recordFulfillment` resolves to exactly one outcome (the order is never both cancelled and shipped) — extending PR #63's record-vs-cancel race to the reasoned path. -- **Service (`[Service]`).** `POST /admin/orders/:id/cancel` mirrors the use-case 1:1 under - the internal-token guard + the X-Service-Token write gate (a non-GET); `serializeOrder` - gains `cancellation` (additive). `renderEmail`'s `order-cancelled` template renders the +- **Customer email (`[Domain]`).** `renderEmail`'s `order-cancelled` template renders the reason ONLY through an explicit CUSTOMER-SAFE allowlist (`customerSafeCancellationCopy`): `customer_request` → "at your request", `out_of_stock` → "an item was unavailable"; everything else — `fraud_suspected`, `pricing_error`, `other`, or any unknown value — @@ -58,9 +55,9 @@ The core design decisions: cancellable order shows a danger-styled alert + the cancel form (reason select, optional detail, cancelledBy) and the bare "Mark cancelled" one-click is HIDDEN from the transition buttons (UI steering, extending PR #63's shipped-steering precedent — cancelling goes - through the form so an order is never cancelled without a reason; the service still - accepts the bare transition for other callers); a cancelled order shows the recorded + through the form so an order is never cancelled without a reason; the bare transition + is still accepted for other callers); a cancelled order shows the recorded reason read-only; a cancelled-without-reason order gets an honest note. A - `NOT_CANCELLABLE` conflict surfaces a "reload" notice, not a token-check error. Typed - `ctx.http` client method threads both tokens like the transition; sandbox-clean - (Block Kit only) — verified in the workerd-on-Node sandbox. + `NOT_CANCELLABLE` conflict surfaces a "reload" notice, not a token-check error. The + cancel travels through the console's admin orders client like the transition does; + sandbox-clean (Block Kit only) — verified in the workerd-on-Node sandbox. diff --git a/.changeset/order-customer-context.md b/.changeset/order-customer-context.md index 2f9fc327..c5a163a1 100644 --- a/.changeset/order-customer-context.md +++ b/.changeset/order-customer-context.md @@ -25,18 +25,11 @@ safe because `linkGuestOrders` already treats that email match as ownership proo (`claimed`/`unclaimed`/`guest`), and aggregates addresses, sessions, order count, and recent orders (excluding the viewed order, capped) under the union key — identical context from ANY of the person's orders. -- **Adapters (`[Adapters]`).** One shared `orderFilterConditions` builder feeds both - `listOrders` and `countOrders` (case-folding kept in sync structurally); - `listForCustomer` never selects `token_hash`. Green against the extended contracts on - better-sqlite3 and Postgres. No index exists yet on `orders.customer_id`/`buyer_ref` - (pre-existing debt — tracked in the indices follow-up), so the new predicates seq-scan. -- **Service (`[Service]`).** `GET /admin/orders/:id/customer-context` mirrors the use-case - 1:1 under the internal-token guard (a read — the write gate does not apply). This is the - first admin-surface routing of customer PII (email, address book, session metadata): - token-gated, token-free on the wire, and never logged. - **Plugin (`[Plugin]`).** The order detail gains a read-only "Customer" section: identity with honest linkage copy ("order not yet claimed" / "Guest — no account"), the profile address book behind a prominent "NOT the address this order shipped to" disclaimer (orders capture no shipping address), token-free session history, and the person's other recent orders. Fetched in parallel with notes; a failed read degrades to an explicit - "unavailable" body — never a blank section, never a blanked detail page. Sandbox-clean. + "unavailable" body — never a blank section, never a blanked detail page. This is the + first admin surface to render customer PII (email, address book, session metadata): + token-free throughout, and never logged. Sandbox-clean. diff --git a/.changeset/order-fulfillment-tracking.md b/.changeset/order-fulfillment-tracking.md index 732d5861..ed01ad1e 100644 --- a/.changeset/order-fulfillment-tracking.md +++ b/.changeset/order-fulfillment-tracking.md @@ -34,24 +34,19 @@ The core design decisions: idempotent replay + the stale-race disambiguation mirror `transitionOrder`/`resolveReconciliation`). `buildOrderEmailData` now carries the fulfillment so the shipped template can render it. -- **Adapters (`[Adapters]`).** Forward-only migration `0012_order_fulfillment` adds six - nullable columns to `orders` (portable text DDL, identical on better-sqlite3 + pg). Both - adapters green against the new `orderFulfillmentContract`; Postgres additionally runs the - concurrency races — N concurrent record-fulfillment ship exactly once (one shipped email), - and record-vs-cancel resolves to exactly one winner (the order is never both). -- **Service (`[Service]`).** `POST /admin/orders/:id/fulfillment` mirrors the use-case 1:1 - under the internal-token guard + the X-Service-Token write gate (a non-GET); - `serializeOrder` gains `fulfillment` (additive). `trackingUrl` is scheme-bound to http(s) - at the boundary (defense-in-depth — the value is emailed to the buyer; `javascript:`/ - `data:` URIs are a 400, never storable). `renderEmail`'s `order-shipped` template now - renders the recorded carrier / tracking number / tracking URL (escaped), degrading to - the plain body when an order shipped without fulfillment. + The store adapter is green against the new `orderFulfillmentContract`, concurrency races + included — N concurrent record-fulfillments ship exactly once (one shipped email), and + record-vs-cancel resolves to exactly one winner (the order is never both). +- **Validation + email.** `trackingUrl` is scheme-bound to http(s) where it enters + (defense-in-depth — the value is emailed to the buyer; `javascript:`/`data:` URIs are + refused, never storable). The `order-shipped` email template now renders the recorded + carrier / tracking number / tracking URL (escaped), degrading to the plain body when an + order shipped without fulfillment. - **Plugin (`[Plugin]`).** The order detail gains a "Fulfillment" section: a `processing` order shows the record-fulfillment form (honest copy that recording ships the order and emails tracking) and the bare "Mark shipped" one-click is HIDDEN from the transition buttons (UI steering — shipping goes through the form so an order is never shipped - without tracking; the service still accepts the bare transition for other callers); a + without tracking; the bare transition stays legal for other callers); a shipped order shows the recorded tracking read-only; a shipped-without- tracking order gets an honest note. A `NOT_FULFILLABLE` conflict surfaces a "reload" - notice, not a token-check error. Typed `ctx.http` client method threads both tokens like - the transition; sandbox-clean (Block Kit only). + notice, not a token-check error. Sandbox-clean (Block Kit only). diff --git a/.changeset/order-notes-walking-skeleton.md b/.changeset/order-notes-walking-skeleton.md index 86b34d93..573551da 100644 --- a/.changeset/order-notes-walking-skeleton.md +++ b/.changeset/order-notes-walking-skeleton.md @@ -14,17 +14,9 @@ this slice. Every append carries an `idempotencyKey`; the store enforces once-on `appendOrderNote` / `listOrderNotes` use-cases (validate + trim author/body, reject a note on a non-existent order). Behavioral contract suite `orderNotesStoreContract` is the spec — append, chronological append order (`created_at ASC, id ASC`), per-order scoping, and the - once-only replay case — green against the in-memory fake first. -- **Adapters (`[Adapters]`).** `KyselyOrderNotesStore` over better-sqlite3 + pg, green against - the contract suite. Forward-only migration `0010_order_notes` (guarded by `idempotency_key` - UNIQUE; `(order_id, created_at, id)` index = the list order). The concurrent-replay race — - N concurrent appends with one key land exactly one row — runs **against Postgres**. -- **Service (`[Service]`).** `GET`/`POST /admin/orders/:orderId/notes` mirroring the port 1:1: - the GET is internal-token guarded (read); the POST is additionally covered by the - `X-Service-Token` write gate (any non-GET). The client-side behavior runs over HTTP against a - live Postgres-backed server (append, chronological list, idempotent replay, validation → 400, - unknown order → 404, auth + write-gate guards). + once-only replay case, including the concurrent race where N concurrent appends carrying one + key land exactly one row — green against the in-memory fake first. - **Plugin (`[Plugin]`).** The Block Kit order-detail page gains a Notes section: a display-only - notes table (append order) + an add-note form, threading the admin + service tokens like the - transition action. Stays sandbox-clean (blocks from the local mirror only; service reached - only via `ctx.http` + `allowedHosts`), verified under the workerd-on-Node sandbox. + notes table (append order) + an add-note form, following the transition action's pattern. + Stays sandbox-clean (blocks from the local mirror only), verified under the workerd-on-Node + sandbox. diff --git a/.changeset/order-refunds.md b/.changeset/order-refunds.md index 0a32170b..fde7583b 100644 --- a/.changeset/order-refunds.md +++ b/.changeset/order-refunds.md @@ -22,7 +22,7 @@ preserves every invariant. (capacity kept — the safe direction — pending a human re-check). So no interleaving can let money leave the gateway without a ledger row already holding its capacity — ceiling arbitration always precedes issuance (proven by - gateway-interleaved Postgres races). ACTIVE = every non-`voided` row (finalized + + gateway-interleaved concurrency races). ACTIVE = every non-`voided` row (finalized + held reservations); the `→ refunded` flip counts FINALIZED (`recorded`) rows only. The manual/record-only path (x402) stays the one-shot atomic `recordRefund` (reserve+finalize collapsed). `UNIQUE(idempotency_key)` is the @@ -40,9 +40,9 @@ preserves every invariant. "unverified, re-check"). `secretKey` unset ⇒ `refundable:false`. x402 declares `refundable:false` and records a manual, out-of-band refund. Contract-tested offline via an injected mock transport. -- **Service:** `POST /admin/orders/:id/refund` (write-gated, Idempotency-Key - required) + `GET /admin/orders/:id/refunds` (ledger + ceiling/remaining + - honest capability). +- **Admin surface:** issuing a refund is an idempotency-keyed write on the order, + and the order detail reads the ledger back with the ceiling, the remaining + refundable amount and the gateway's honest capability flag. - **Plugin:** a Refunds section on the admin order detail — the ledger, remaining refundable, a money-input refund form whose framing is honest per gateway (real Stripe refund vs record-a-manual x402/off-platform refund), and refreshed diff --git a/.changeset/order-timeline-audit.md b/.changeset/order-timeline-audit.md index f9a206b6..95cb86b9 100644 --- a/.changeset/order-timeline-audit.md +++ b/.changeset/order-timeline-audit.md @@ -22,7 +22,7 @@ The core design decisions: event is written only after the guarded flip matched a row — a replayed or lost-race flip is a 0-row miss that records NO event (audit never double-counts a replay; this falls straight out of the choke-point design and is tested, - including under a Postgres race). + including under a concurrent-flip race). - **Merge vs. write, per artifact.** `order_events` is kept lean — it is ONLY the state-change spine (the history nothing else recorded before). Everything that @@ -36,7 +36,7 @@ The core design decisions: (`markPaid`/`expire`/generic) have no modeled actor and record `null`. - **Graceful degradation for historical orders.** Orders whose transitions - predate this migration have no `order_events` rows. The timeline read-model + predate this change have no `order_events` rows. The timeline read-model degrades: their creation moment, notes, and any recorded fulfillment/ cancellation/resolution still populate the view, and a `stateChangesAudited` flag (false) lets the surface say the state-change history is partial. Events @@ -46,19 +46,10 @@ The core design decisions: listEventsForOrder` port read; new pure use-case `getOrderTimeline` (merges the event spine with the derived artifacts into one chronological view with a stable same-timestamp tie-break: `at` ASC, then a kind rank, then insertion order). -- **Adapters (`[Adapters]`).** Forward-only migration `0014_order_events` adds the - append-only `order_events` table (portable text DDL, identical on better-sqlite3 - + pg) with the `(order_id, at, id)` list index. The event INSERT rides - `#flipAndEnqueue`'s transaction; both adapters green against the new - `orderTimelineContract`, and Postgres additionally proves exactly-one audit - event under a concurrent-flip race (extending the fulfillment race too). -- **Service (`[Service]`).** New `GET /admin/orders/:id/timeline` — read-only, - internal-token guarded like the other admin reads — mirrors the use-case 1:1 - (structured entries on the wire; no presentation strings, no money, no PII - beyond what the order detail + notes already show). - **Plugin (`[Plugin]`).** The order detail gains a read-only "Timeline" section: one chronological when/what/who/detail table merging the state changes with the notes and recorded actions, an honest caption when the state-change history is partial, and independent degradation (a failed timeline read renders an - "unavailable" section, never blanking the detail). Typed `ctx.http` client - method; sandbox-clean (Block Kit only) — verified in the workerd-on-Node sandbox. + "unavailable" section, never blanking the detail). The section shows no money + and no PII beyond what the order detail and notes already carry. Sandbox-clean + (Block Kit only) — verified in the workerd-on-Node sandbox. diff --git a/.changeset/orders-search-prefix-and-substring.md b/.changeset/orders-search-prefix-and-substring.md index bd86ecea..6ef0f7bd 100644 --- a/.changeset/orders-search-prefix-and-substring.md +++ b/.changeset/orders-search-prefix-and-substring.md @@ -32,7 +32,7 @@ served by an index and the new predicate scans (see below). Results preserved, c escaped first so it cannot re-escape the other two rules' output. The empty string, by the same logic, matches EVERYTHING — every string starts with and contains `""` — which is the inverted reading of "search for nothing" and is now pinned rather than left to be discovered. - The service's query schema requires `min(1)`, so the wire cannot send it. + A caller with nothing to search for is expected to omit the field rather than send it empty. - **The sequential scan is the design.** An unanchored substring cannot be served by a b-tree, so this predicate no longer uses `idx_orders_buyer_ref_lower`, and the anchored id half cannot use the primary key under a default collation. A trigram or full-text index was declined at this @@ -50,8 +50,7 @@ served by an index and the new predicate scans (see below). Results preserved, c likewise untouched. The two predicates now differ on purpose, and a contract case pins the difference. - **The cursor gate is unaffected.** It compares the search STRING, not what the string selects, - so the canonical form on the wire is identical before and after. The admin Orders HTTP suite is - unchanged apart from added cases. + so the canonical form of a cursor is identical before and after. No wire, schema or migration change, and no console copy change — the search label already named both columns rather than promising exactness. diff --git a/.changeset/orders-write-path-extraction.md b/.changeset/orders-write-path-extraction.md index 9e8c1eab..f1645823 100644 --- a/.changeset/orders-write-path-extraction.md +++ b/.changeset/orders-write-path-extraction.md @@ -53,7 +53,7 @@ makes; both are things it would be wrong to leave unwritten. check does not run for the React console. That is pre-existing rather than introduced here: the reachable confirm re-reads the refund ledger and refuses on a watermark mismatch, and an over-ceiling amount surviving that is refused by - the service itself. What is lost is the earlier, better-worded refusal naming + the domain itself. What is lost is the earlier, better-worded refusal naming the remaining balance, not the ceiling. - Resolving a reconciliation flag derives its idempotency key from the order id alone, so two resolutions of two different anomalies on the same order collide diff --git a/.changeset/payment-secrets-write-only-kv.md b/.changeset/payment-secrets-write-only-kv.md index 4f659fb9..cfb5b541 100644 --- a/.changeset/payment-secrets-write-only-kv.md +++ b/.changeset/payment-secrets-write-only-kv.md @@ -3,29 +3,30 @@ --- Provision the payment/email credentials in write-only plugin kv, and widen the egress -allowlist per commerce mode (work order 02, INC-C3). +allowlist to the hosts that now need reaching (work order 02, INC-C3). -Folding `@otta-sh/service` into the plugin moves the calls the service used to make — -Stripe's API, the email provider, the x402 facilitator — to the plugin itself. Those calls -need two things the plugin did not have: the credentials, and permission to reach the hosts. +Running commerce inside the plugin moves the outbound calls that used to be made +server-side — Stripe's API, the email provider, the x402 facilitator — to the plugin itself. +Those calls need two things the plugin did not have: the credentials, and permission to +reach the hosts. - **Secrets (`payment-secrets.ts`).** Four write-only kv keys, following the existing `settings:serviceToken` pattern exactly (ADR-0007): `settings:stripeSecretKey`, `settings:stripeWebhookSecret`, `settings:emailApiKey` and `settings:x402FacilitatorSecret` - — the plugin-side homes of the service's `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, - `EMAIL_API_KEY` and `X402_FACILITATOR_SECRET`. Each has a fail-closed reader: a kv read that + — the plugin-side homes of what used to be the server-side `STRIPE_SECRET_KEY`, + `STRIPE_WEBHOOK_SECRET`, `EMAIL_API_KEY` and `X402_FACILITATOR_SECRET` environment + variables. Each has a fail-closed reader: a kv read that rejects degrades to `undefined` (never throws, never substitutes an empty value that could read as "configured"), and one failing read cannot disarm the other three. Values are never baked into the bundle and never rendered back into a block — the Settings page grows a "Payments & email" group whose fields are plain always-empty text inputs, a blank submit keeps the current value, and only a derived boolean ("configured" / which ones are missing) ever reaches a label. -- **Egress (`resolveAllowedHosts`).** `allowedHosts` is now resolved per mode from one pure +- **Egress (`resolveAllowedHosts`).** `allowedHosts` is now resolved from one pure function shared by the bundle's `ALLOWED_HOSTS` and the site descriptor, so the two cannot - drift. `"http"` (still the default, and byte-identical to what shipped before) is exactly - the commerce service's host. `"in-process"` is exactly `api.stripe.com` plus whichever of the + drift. What it grants is exactly `api.stripe.com` plus whichever of the email/facilitator hosts the deployment supplied via the new `__OTTA_EMAIL_API_URL__` / - `__OTTA_X402_FACILITATOR_URL__` build defines — the service host disappears, and an absent or + `__OTTA_X402_FACILITATOR_URL__` build defines — an absent or unparseable URL grants no host rather than guessing one. New exports: `PAYMENT_SECRET_KEYS` and the per-secret key constants and readers, diff --git a/.changeset/phase-0-atomic-inventory.md b/.changeset/phase-0-atomic-inventory.md index 1ce58d33..73230c27 100644 --- a/.changeset/phase-0-atomic-inventory.md +++ b/.changeset/phase-0-atomic-inventory.md @@ -4,17 +4,10 @@ Phase 0 — atomic inventory skeleton. -- `@otta-sh/domain`: export the reusable `inventoryStoreContract` (and its - `InventoryStoreHarness`/options) from the testing barrel so every adapter runs - the same behavioral spec. -- `@otta-sh/store-postgres`: `KyselyInventoryStore` over better-sqlite3 (local) and - pg (CI/prod), a forward-only Phase-0 migration (`inventory` + `reservations` - with `UNIQUE(idempotency_key)` and a `reservations.sku → inventory.sku` FK - enforced on both dialects), and the reserve finalize choreography that - guarantees no oversell under concurrency (`held ⟺ a durable decrement`, with - replay-by-state and crash-window healing). Also exports a `./testing` subpath - (`createIsolatedPgSchema`) for per-schema-isolated Postgres tests. -- `@otta-sh/service`: a thin Hono REST API mirroring the inventory port 1:1 - (`POST /inventory/reserve|commit|release`), Zod-validated, `Idempotency-Key` - header → domain key, no status-code-as-logic, and an `onError` envelope that - never leaks internal messages/stacks. +`@otta-sh/domain` exports the reusable `inventoryStoreContract` (and its +`InventoryStoreHarness`/options) from the testing barrel, so every adapter runs +the same behavioral spec against the `InventoryStore` port: the +reserve/commit/release choreography that guarantees no oversell under +concurrency (`held ⟺ a durable decrement`), once-only idempotency with +replay-by-state, and crash-window healing. The contract is written against the +port before any adapter exists, and an adapter is done when it is green. diff --git a/.changeset/phase-1-plugin.md b/.changeset/phase-1-plugin.md index b7c8e7c8..9921703d 100644 --- a/.changeset/phase-1-plugin.md +++ b/.changeset/phase-1-plugin.md @@ -6,13 +6,12 @@ Phase 1 — `@otta-sh/plugin`, the first Otta EmDash plugin package: sandbox-cle (workerd, Block Kit, no React), proven under a real `workerd` process, not trusted in-process. -- `CommerceClient` transport port (ADR-0002 §3) + `HttpCommerceClient`, the - only adapter this phase builds — a straight 1:1 mirror of - `@otta-sh/service`'s `PUT`/`GET`/`DELETE /products/:id/commerce` (money as - integer + ISO-4217 string, `Idempotency-Key` header, structured - `CommerceClientError` on any non-2xx response). Proven against the real, - Postgres-backed `@otta-sh/service` over a live test server - (`http-commerce-client.test.ts`) — the wire has not drifted from the port. +- The `CommerceClient` port (ADR-0002 §3): upsert, read and soft-delete a + product's commerce row, with money as an integer plus an ISO-4217 string, a + caller-supplied idempotency key on every write, and a structured + `CommerceClientError` for any refusal. The port is what the widget and the + sync hooks are written against, so the console never depends on which + implementation answers. - A from-scratch workerd-on-Node sandbox test harness (`test/sandbox/harness.ts`): boots the real public `workerd` binary as a child process (not Node `vm`/`worker_threads`, not trusted in-process), @@ -45,26 +44,23 @@ trusted in-process. `plugin-is-sandbox-clean` dependency-cruiser rule (`.dependency-cruiser.cjs`, wired into `pnpm lint`) forbidding any DB/storage/filesystem import in `packages/plugin/src`. -- `@otta-sh/service` additively exports `./app` (`createApp`) — new public - export surface, hence the minor bump — so the plugin's own tests can boot - a live, Postgres-backed instance without duplicating route-mounting logic. - Review round 1: the panel Save route derives a STABLE content-derived idempotency key (hash of productId + submitted form state — em-dash's `FormSubmit` exposes no event/delivery id), so a host retry/double-submit of the same click dedupes to one applied write; the route returns structured `INVALID_FIELDS` per-field errors for bad numerics/currency/ - floats instead of an opaque 500; `content:afterSave` forwards + floats instead of an opaque failure; `content:afterSave` forwards `contentUpdatedAt` as the sync-ordering watermark (a delayed out-of-order - older save is a stale no-op at the service); and the sandbox-clean guard + older save is a stale no-op at the store); and the sandbox-clean guard now also forbids undici/node-fetch/axios/ws/hono imports AND direct `fetch`/`globalThis.fetch`/`self.fetch`/`window.fetch`/`XMLHttpRequest` usage in plugin src outside the sanctioned `ctx.http` implementation - (grep-guard test). The panel route surfaces a service 409 `SKU_TAKEN` + (grep-guard test). The panel route surfaces a `SKU_TAKEN` refusal (live-SKU conflict — the most likely merchant input error) as a structured per-field error next to the SKU input, and `content:afterSave` normalizes the CMS `updatedAt` to strict `Date.toISOString()` form before - sending it as the sync watermark (the service now validates that format - hard at the boundary). + sending it as the sync watermark (the format is validated hard at the + commerce boundary). Deferred (plan §6 step 9 / §2, both explicitly optional/out-of-scope this phase): the reconcile cron and `content:afterPublish` → `activate` — the diff --git a/.changeset/phase-1-product-model-and-sync.md b/.changeset/phase-1-product-model-and-sync.md index 52c3868f..2d1d51ff 100644 --- a/.changeset/phase-1-product-model-and-sync.md +++ b/.changeset/phase-1-product-model-and-sync.md @@ -2,26 +2,15 @@ "@otta-sh/domain": minor --- -Phase 1 — product model + sync (domain/adapter/service slice). +Phase 1 — product model + sync (domain slice). - `@otta-sh/domain`: add the `ProductCommerceStore` port (`upsert`/`getByProductId`/ `softDelete`), branded `UpsertProductCommerceInput`/`ProductCommerce` (money as `Cents` + `Currency`, never a raw number), the `MissingProductIdError` "create then price" guard, an in-memory fake, and the reusable `productCommerceStoreContract`. `InventoryStore` additively grows - `seedOnHand(sku, qty)` — a create-if-absent initial-stock write - (`ON CONFLICT (sku) DO NOTHING`) that can never clobber a concurrent - reserve/release, with its own contract cases on every dialect. -- `@otta-sh/store-postgres`: a forward-only `0002_product_commerce` migration and - `KyselyProductCommerceStore` — a single conditional - `INSERT … ON CONFLICT (product_id) DO UPDATE … WHERE idempotency_key != :key` - implementing per-row compare-on-write replay dedupe (distinct from Phase 0's - globally-unique `reservations.idempotency_key`), plus `seedOnHand` on - `KyselyInventoryStore`. -- `@otta-sh/service`: `PUT`/`GET`/`DELETE /products/:id/commerce`, a 1:1 - serialization of the port (`Idempotency-Key` header, zod-validated body, money - on the wire as integer + ISO-4217 string, `MISSING_PRODUCT_ID` → 400), wired to - seed initial `on_hand` via the create-if-absent `seedOnHand`. + `seedOnHand(sku, qty)` — a create-if-absent initial-stock write that can never + clobber a concurrent reserve/release, with its own contract cases. - Review round 1: the `seedOnHand` seed is attempted on EVERY save carrying a stock figure (create-if-absent makes it a no-op once the row exists), so a partial failure after the product upsert can no longer permanently strand a @@ -30,14 +19,11 @@ Phase 1 — product model + sync (domain/adapter/service slice). content's own `updatedAt`, sent by sync upserts) and a strictly-older sync is a stale no-op, so out-of-order hook delivery converges; panel saves omit the watermark (last-writer-wins, documented + pinned). `sku` uniqueness is - now a PARTIAL unique index over live rows (`WHERE deleted_at IS NULL`), so - a soft-deleted product's SKU is reusable by a new product while two live - products still cannot share one — enforced identically on Postgres and - SQLite and mirrored by the in-memory fake. A live-SKU conflict is the - structured domain `SkuConflictError` (caught narrowly on the partial-index - violation in the Kysely store, thrown directly by the fake) and maps to - HTTP 409 `{ok:false, error:"SKU_TAKEN", sku}` at the service — never an - opaque 500. The sync-ordering watermark is strictly validated at the wire - boundary as `Date.toISOString()`-format UTC (it feeds a raw lexicographic - SQL comparison; one garbage high-sorting value stored once would make - every future legitimate sync stale forever) — anything else is a 400. + now scoped to LIVE rows, so a soft-deleted product's SKU is reusable by a + new product while two live products still cannot share one — pinned by the + store contract and mirrored by the in-memory fake. A live-SKU conflict is + the structured domain `SkuConflictError` carrying the offending `sku`, never + an opaque store failure. The sync-ordering watermark is strictly validated + at the boundary as `Date.toISOString()`-format UTC (it feeds a raw + lexicographic comparison; one garbage high-sorting value stored once would + make every future legitimate sync stale forever) — anything else is refused. diff --git a/.changeset/phase-2-catalog-display.md b/.changeset/phase-2-catalog-display.md index ba946078..5f9e8350 100644 --- a/.changeset/phase-2-catalog-display.md +++ b/.changeset/phase-2-catalog-display.md @@ -19,22 +19,12 @@ Phase 2 — catalog display (batch commerce read + storefront PDP/PLP). store's inventory join) and pinned by five new `productCommerceStoreContract` cases; harnesses grow `seedStock` and `activate`. -- `@otta-sh/store-postgres`: `KyselyProductCommerceStore.listCommerceByIds` as - ONE statement — `product_commerce LEFT JOIN inventory` with the - commerce-complete guards inline, identical on sqlite + pg. The §6 - "inStock is one intra-service statement, never a second inventory round - trip" invariant is enforced by a query-count test (a Kysely plugin counts - root statement executions: exactly 1 per batch, 0 for an empty batch). -- `@otta-sh/service`: `POST /catalog/commerce/batch` (own route file), a 1:1 - serialization of the port: Zod-validated `{ productIds }` capped at 100 - (a request-size guard ≥2× the PLP page cap, not pagination — 400 over - cap), `{ items }` response with money as integer + ISO-4217 string. - Live-server contract test on Postgres. - `@otta-sh/plugin`: the catalog-display stack, all behavior proven under the - REAL workerd sandbox. `getCommerceBatch` on `CommerceClient`/ - `HttpCommerceClient` (over `ctx.http` + `allowedHosts` only); a + REAL workerd sandbox. `getCommerceBatch` on `CommerceClient`, with the batch + capped at 100 ids (a request-size guard ≥2× the PLP page cap, not + pagination — over the cap is refused); a request-scoped DataLoader-style `CommerceBatchLoader` (same-tick lookups - coalesce to one HTTP call; intra-render dedupe only — no cross-request + coalesce to one batch call; intra-render dedupe only — no cross-request cache in v1); the pure `joinProduct` content+commerce join (`purchasable ⟺ commerce !== null`, one computed truth); `formatMoney` + `majorUnits` behind the plugin's own branded `Cents`/`Currency` (a @@ -49,8 +39,8 @@ Phase 2 — catalog display (batch commerce read + storefront PDP/PLP). returning localized, RTL-safe JSON view models (+ JSON-LD graph) for a thin theme page to render; availability is a semantic token themes localize; the PLP page cap (48) plus the loader guarantee the headline - N+1 gate — one page render issues exactly ONE commerce-batch HTTP call - and ZERO inventory-only calls (both pinned by call-count sandbox tests); + N+1 gate — one page render issues exactly ONE commerce-batch lookup + and ZERO inventory-only lookups (both pinned by call-count sandbox tests); non-purchasable items — the no-commerce AND the inactive kind alike — are shown and flagged, not filtered; unexpected render failures collapse to a structured, message-free `RENDER_FAILED` instead of leaking internals diff --git a/.changeset/phase-3-cart-and-inventory.md b/.changeset/phase-3-cart-and-inventory.md index da5e72f9..211eabea 100644 --- a/.changeset/phase-3-cart-and-inventory.md +++ b/.changeset/phase-3-cart-and-inventory.md @@ -2,41 +2,21 @@ "@otta-sh/domain": minor --- -Phase 3 — cart + inventory (service-side; plugin/storefront deferred to Wave 3). +Phase 3 — cart + inventory (domain-side; plugin/storefront deferred to Wave 3). -- `@otta-sh/domain`: additive `InventoryStore.adjust(reservationId, newQty, key)` - (delta reserve / partial release) — **exactly-once, ledger-first**: the key is - claimed before any movement, a stale replay returns the recorded result (ok or - OUT_OF_STOCK) and moves nothing, and a hold that left `held` throws the typed - `ReservationNotHeldError`; `reserve/commit/release` stay byte-for-byte. A new - `CartStore` port (claim/complete `cart_mutations` ledger, guarded `expireHold` - flip) and IO-free cart use-cases (create/get with lazy-on-read expiry, add, - delta update, remove, and the `expireHolds` sweep) orchestrating `CartStore` + - `InventoryStore` + `Clock` with no cross-store transaction; the reusable - `cartStoreContract`, fence guards (`LINE_CHECKED_OUT` / `CART_CHECKED_OUT`), - and reserve↔cart-line + remove crash-window healing — including the - "visible line ⟺ live hold" attach guard: a late add replay whose crashed hold - the sweep already reaped returns a typed `HOLD_EXPIRED` (409 over HTTP) - instead of resurrecting a line over dead stock, and a mis-keyed adjust replay - against the wrong reservation is a typed rejection. Cart lines snapshot no - price (an order invariant, Phase 4). -- `@otta-sh/store-postgres`: forward-only migration `0003_cart` (`carts`, - `cart_lines` with `UNIQUE(cart_id, sku)` and nullable `reservation_id`/ - `expires_at`, the claim/complete `cart_mutations` idempotency ledger, the - `inventory_adjustments` per-mutation claim ledger, and an ALTER adding - nullable `expires_at` to `reservations`); `KyselyInventoryStore.adjust` as a - claim + guarded-CAS + movement single transaction (exactly-once under real - concurrency); a Kysely `CartStore` whose expiry is the guarded `held → - released` flip that re-checks the deadline atomically (a TTL-reset hold is - never reaped; raw non-cart reserves are never swept). Green on better-sqlite3 - and pg, including the **no-oversell-through-cart** Postgres acceptance gate - and same-key/different-key adjust races. `migrateToLatest` accepts - `migrationTableSchema` so schema-isolated test databases don't collide on the - Migrator's bookkeeping tables. -- `@otta-sh/service`: cart REST endpoints (`POST /carts`, `GET /carts/:id`, - `POST/PATCH/DELETE /carts/:id/lines[/:lineId]`) mirroring the use-cases 1:1 - with `Idempotency-Key` → domain key and `OUT_OF_STOCK` as a typed 200 body; - the internal `POST /internal/expire-holds` sweep trigger guarded by an - `X-Internal-Token` shared secret compared in constant time - (`INTERNAL_API_TOKEN`; unset ⇒ 503 disabled); - a self-scheduled Node sweep interval; and `CART_HOLD_TTL_MS` for the hold TTL. +Additive `InventoryStore.adjust(reservationId, newQty, key)` +(delta reserve / partial release) — **exactly-once, ledger-first**: the key is +claimed before any movement, a stale replay returns the recorded result (ok or +OUT_OF_STOCK) and moves nothing, and a hold that left `held` throws the typed +`ReservationNotHeldError`; `reserve/commit/release` stay byte-for-byte. A new +`CartStore` port (claim/complete `cart_mutations` ledger, guarded `expireHold` +flip) and IO-free cart use-cases (create/get with lazy-on-read expiry, add, +delta update, remove, and the `expireHolds` sweep) orchestrating `CartStore` + +`InventoryStore` + `Clock` with no cross-store transaction; the reusable +`cartStoreContract`, fence guards (`LINE_CHECKED_OUT` / `CART_CHECKED_OUT`), +and reserve↔cart-line + remove crash-window healing — including the +"visible line ⟺ live hold" attach guard: a late add replay whose crashed hold +the sweep already reaped returns a typed `HOLD_EXPIRED` +instead of resurrecting a line over dead stock, and a mis-keyed adjust replay +against the wrong reservation is a typed rejection. Cart lines snapshot no +price (an order invariant, Phase 4). diff --git a/.changeset/phase-3-storefront-cart.md b/.changeset/phase-3-storefront-cart.md index 474e4e1c..0a53b815 100644 --- a/.changeset/phase-3-storefront-cart.md +++ b/.changeset/phase-3-storefront-cart.md @@ -2,22 +2,18 @@ "@otta-sh/plugin": minor --- -Phase 3 (Wave 3) — storefront cart: the `@otta-sh/plugin` half the service-side -Phase 3 changeset deferred. +Phase 3 (Wave 3) — the storefront cart half of `@otta-sh/plugin`. - Adds five plugin-owned **public** storefront cart routes (workerd sandbox-clean, per ADR-0003) — `storefront/cart/create`, `.../cart/read`, and - `.../cart/lines/{add,update,remove}` — each a pure proxy over `ctx.http` to - `@otta-sh/service`'s `/carts` REST surface. The plugin holds no cart or stock - state: input is hand-validated (the routes are public), forwarded with the - caller's `Idempotency-Key`, and the already-typed result is returned verbatim. - Typed cart outcomes (`OUT_OF_STOCK`, `CART_NOT_FOUND`, `LINE_NOT_FOUND`, …) - ride through as a `{ ok: false; reason }` value regardless of the underlying - HTTP status — callers branch on the token, never the status code. Exercised - end-to-end under the real workerd binary against a stub service. -- Adds `HttpCommerceClient` cart methods (`createCart`, `getCart`, `addCartLine`, - `adjustCartLine`, `removeCartLine`) — 1:1 mirrors of `routes/carts.ts`, - wire-tested against a live Postgres-backed `@otta-sh/service`. + `.../cart/lines/{add,update,remove}`. Input is hand-validated (the routes are + public), carries the caller's idempotency key, and the already-typed result is + returned verbatim. Typed cart outcomes (`OUT_OF_STOCK`, `CART_NOT_FOUND`, + `LINE_NOT_FOUND`, …) ride out as a `{ ok: false; reason }` value rather than a + status code — callers branch on the token. Exercised end-to-end under the real + workerd binary. +- Adds the `CommerceClient` cart methods (`createCart`, `getCart`, `addCartLine`, + `adjustCartLine`, `removeCartLine`), contract-tested against the port. - Fills the Phase 2 add-to-cart extension seam on the product view model: a purchasable product now carries a **Block Kit** add-to-cart affordance (a quantity stepper + submit button, not React), gated on the same `purchasable` @@ -26,11 +22,10 @@ Phase 3 changeset deferred. - Exports a `totalQty(cart)` helper and the cart wire/result types (`CartWire`, `CartLineWire`, `CartResult`, `CartFailureReason`) for theme use. -Known follow-ups flagged for a small `[Service]` change, out of scope here -(plugin package only): cart lines carry no `productId`/price on the wire, so the -read route cannot join a live price total — `totalQty` is the one honest total -today. And a sandboxed route cannot emit `Set-Cookie` (the runner serializes its -return value to plain JSON) or read the inbound `Cookie` header, so `cart/create` -returns a cookie **descriptor** for a first-party theme shim to apply on its own -response rather than setting the cart cookie itself — a documented deviation from -plan §4's literal wording, a candidate follow-up ADR. +Two known follow-ups, out of scope here: a cart line carries no `productId` or +price, so the read route cannot join a live price total — `totalQty` is the one +honest total today. And a sandboxed route cannot emit `Set-Cookie` (the runner +serializes its return value to plain JSON) or read the inbound `Cookie` header, +so `cart/create` returns a cookie **descriptor** for a first-party theme shim to +apply on its own response rather than setting the cart cookie itself — a +documented deviation from plan §4's literal wording, a candidate follow-up ADR. diff --git a/.changeset/phase-4-checkout-and-gateways.md b/.changeset/phase-4-checkout-and-gateways.md index 0f325304..f511cd7e 100644 --- a/.changeset/phase-4-checkout-and-gateways.md +++ b/.changeset/phase-4-checkout-and-gateways.md @@ -21,33 +21,18 @@ Phase 4 — checkout + payment gateways. `commit`/`release`, `CartStore.checkout`, the cart add/increase digital branch, and `product_commerce.title`. In-memory fakes + `orderStoreContract`/`entitlementStoreContract`/`paymentGatewayContract`. -- `@otta-sh/store-postgres`: forward-only migration `0005_orders` (`orders` with no - money column, insert-once `order_items`, 1:1 `order_totals` authoritative - totals home, `payments`, `payment_events` dedupe+anomaly, `entitlements`; - additive `reservations.order_id`/`adopted` + `product_commerce.title`). Kysely - order/entitlement/payment-event adapters, `adopt`/`checkout` guarded flips. - Green on better-sqlite3 and Postgres including **no-oversell-through-checkout**, - snapshot immutability, the adopted-hold sweep invisibility, the double-sweep - expiry race, the cart fences, and the loud commit-lost anomaly. - `@otta-sh/payments-stripe` (new): raw-body HMAC-verifying Stripe adapter + the - offline fake-Stripe driver `signStripeWebhook`. Webhook secret is service-env - only. + offline fake-Stripe driver `signStripeWebhook`. The webhook secret comes from + the host environment only, never the wire. - `@otta-sh/payments-x402` (new): page-gate adapter that re-verifies the facilitator receipt SERVER-SIDE via an injected `X402Facilitator` (never trusting the plugin) + an offline HMAC facilitator. `transaction` is the dedupe key. -- `@otta-sh/service`: `POST /checkout/orders`, `GET /orders/:id`, - `POST /internal/expire-orders`, the raw-body `POST /webhooks/stripe`, - `POST /entitlements/grant`, and `GET /entitlements/check`; the cart add route - resolves fulfillment kind server-side; product-commerce carries `title`. Live - HTTP contract green. -- `@otta-sh/plugin`: sandbox-clean PUBLIC entitlement-gated download route; - `HttpCommerceClient.checkEntitlement`. **The Stripe webhook endpoint is the - SERVICE's public URL (`POST /webhooks/stripe`)** — there is deliberately no - plugin proxy route: EmDash's sandboxed-route bridge JSON-parses the request - body (destroying the raw bytes the HMAC verifies) and pins the HTTP response - to a wrapped 200 (Stripe retries key on status), so a byte-exact proxy is - structurally impossible; direct-to-service is the plan's preferred design - (§9 Risk 1). +- `@otta-sh/plugin`: sandbox-clean PUBLIC entitlement-gated download route, which + checks the entitlement before serving a byte. **The Stripe webhook cannot be a + plugin route** — EmDash's sandboxed-route bridge JSON-parses the request body + (destroying the raw bytes the HMAC verifies) and pins the HTTP response to a + wrapped 200 (Stripe retries key on status), so a byte-exact proxy is + structurally impossible, and the plan says so (§9 Risk 1). Entitlements are keyed on `order_id` + `buyer_ref` (email/session claim token); Phase 5 re-associates them to customer accounts. @@ -64,10 +49,9 @@ Review-round hardening (settle-path defect family): loud as finding the order already terminal: a new `PAID_FLIP_LOST` `payment_events` anomaly + the manual-reconciliation flag (money captured, stock released — never silent). -- `KyselyInventoryStore.commit` is guard-first (conditional - `UPDATE … WHERE state IN ('held','adopted') RETURNING`; 0 rows re-reads to - distinguish the benign already-`committed` replay from the loud lost-hold - anomaly). +- `InventoryStore.commit` is guard-first: it flips only a `held` or `adopted` + hold, and a no-op re-reads to distinguish the benign already-`committed` + replay from the loud lost-hold anomaly. - Stripe webhook verification enforces a configurable **freshness window** on the signed `t` (default 300s, injectable Clock) and checks **all** `v1` signatures (secret rotation). @@ -79,9 +63,9 @@ Review-round hardening (settle-path defect family): Review round G (second review): -- **Stripe webhooks are direct-to-service** — the plugin proxy route was - removed (see the `@otta-sh/plugin` bullet above; the host bridge destroys the - raw bytes and the status code, so the proxy validated a fictional contract). +- **The plugin's Stripe webhook proxy route was removed** — the host bridge + destroys the raw bytes and the status code, so the proxy validated a + fictional contract (see the `@otta-sh/plugin` bullet above). - `createOrderFromCart` enforces the **cart-state fence**: a checked-out cart with a distinct idempotency key is rejected `CART_CHECKED_OUT` (same-key replays still honored via `OrderStore.getByIdempotencyKey`); order-driven @@ -95,11 +79,10 @@ Review round G (second review): - Settle short-circuits **terminal states before the amount check**, so a mismatched-amount stray duplicate on an already-paid order no-ops instead of recording a false `AMOUNT_MISMATCH` anomaly. -- The service bin **fails closed on x402**: configuring `X402_PAYTO` + - `X402_FACILITATOR_SECRET` without `X402_ALLOW_TEST_FACILITATOR=true` refuses - to start (the only wireable facilitator is the offline test one); the opt-in - warns loudly that it is not production-safe. +- Wiring x402 **fails closed**: the only facilitator that can be wired is the + offline test one, so enabling it is an explicit opt-in that warns loudly it + is not production-safe. -Known deferrals (Phase 5+): Stripe `createIntent` offline stub; -`GET /entitlements/check` buyerRef enumeration oracle (closed by Phase-5 claim -tokens; marked in-code). +Known deferrals (Phase 5+): Stripe `createIntent` offline stub; the entitlement +check's buyerRef enumeration oracle (closed by Phase-5 claim tokens; marked +in-code). diff --git a/.changeset/phase-5-orders-customers-emails.md b/.changeset/phase-5-orders-customers-emails.md index 6dbdb1aa..e7ff3e44 100644 --- a/.changeset/phase-5-orders-customers-emails.md +++ b/.changeset/phase-5-orders-customers-emails.md @@ -16,30 +16,21 @@ Phase 5 — order lifecycle, storefront customers, and transactional emails. magic-link flow (first login creates the account, links matching guest orders, mints a session). New branded `CustomerId`/`Email` (normalized). In-memory fakes + five contract suites (`orderTransitionContract`, `customerStoreContract`, `addressBookContract`, `sessionContract`, - `credentialVerifierContract`) + `FakeEmailSender`. Still IO-free. -- `@otta-sh/store-postgres`: migration `0006` (customers, addresses, customer_sessions, - login_challenges, order_emails_outbox with `UNIQUE(order_id, to_state)`) and the four new Kysely - adapters + the extended order store. The guarded state `UPDATE` and outbox `INSERT` run in one - real transaction on one connection (exactly-once enqueue, proven by a forced-rollback contract - case); the dispatcher claim is a lease-based conditional `UPDATE` (exactly-once claim — only one - dispatcher ever wins a row). Delivery itself is at-least-once: a crash between `send()` and - marking the row sent re-leases it for retry on a later tick; dedup down to effectively-once - relies on the transactional-API provider's `Idempotency-Key` (wired in `HttpEmailSender`). - Tokens are stored only as SHA-256 hashes. All contract suites run on SQLite + Postgres. -- `@otta-sh/service`: `POST /auth/login/request|verify`, `POST /auth/logout`, `GET /me`, - `GET /me/orders(/:id)` (foreign id ⇒ 404, never 403 — no existence leak), `GET/POST/PUT/DELETE - /me/addresses` (session-derived identity only, never a client-supplied id), `POST - /admin/orders/:id/transition` (privileged), and `POST /internal/dispatch-emails`. The Phase-4 - webhook/expiry flips now also enqueue their status email atomically (no call-site rewrite — - `markPaid`/`expire` route through the shared transactional primitive). Concrete `EmailSender` - adapters (`ConsoleEmailSender`/`HttpEmailSender`) + a template renderer; the login-link email is - wired. + `credentialVerifierContract`) + `FakeEmailSender`. Still IO-free. The guarded state flip and + the outbox insert are one atomic unit (exactly-once enqueue, proven by a forced-rollback + contract case) and the dispatcher claim is a lease (only one dispatcher ever wins a row); + delivery itself is at-least-once — a crash between `send()` and marking the row sent re-leases + it for retry on a later tick, and dedup down to effectively-once relies on the transactional + email provider's `Idempotency-Key`. Session and login tokens are stored only as SHA-256 + hashes. The Phase-4 paid/expiry flips now enqueue their status email atomically too, with no + call-site rewrite — `markPaid`/`expire` route through the same transactional primitive. - `@otta-sh/plugin`: PUBLIC storefront account routes (`/account/login/*`, `/account/orders`, - `/account/order`, `/account/addresses`) — thin HTTP-only proxies over `ctx.http` to the `/auth` - + `/me` surface, proven under the workerd-on-Node sandbox. No new capability beyond - `network:request`/`allowedHosts`. Per the em-dash cookie-blindness verified in ADR-0003/cart + `/account/order`, `/account/addresses`) over the auth + account surface, proven under the + workerd-on-Node sandbox. A foreign order id reads as not-found, never forbidden (no existence + leak), and an address is always resolved from the session's identity, never from a + client-supplied id. Per the em-dash cookie-blindness verified in ADR-0003/cart routes, login returns a session-cookie descriptor for the theme's first-party layer and the bearer token is threaded in as route input. Two draft ADRs recorded (proposed, pending sign-off): 0004 (magic-link customer auth) and 0005 -(service sends transactional email directly). +(the transactional email transport). diff --git a/.changeset/phase-6-shipping-tax-coupons.md b/.changeset/phase-6-shipping-tax-coupons.md index c30340b0..17f562c9 100644 --- a/.changeset/phase-6-shipping-tax-coupons.md +++ b/.changeset/phase-6-shipping-tax-coupons.md @@ -11,31 +11,15 @@ no-float-drift / sum-of-parts / determinism invariants across thousands of generated carts; it is added as a new dev-dependency pinned in the workspace `catalog:`. -- `@otta-sh/domain`: new pure, IO-free pricing engines — `allocateCents` - (largest-remainder discount apportionment, BigInt-exact so `Σ === total` - always), `computeLineTax` (half-up per-line, integer bps), `computeCouponDiscount` - (fixed-amount clamped at subtotal / percentage with cap, currency-checked), - `resolveShippingRate` (flat / free-shipping with a post-discount threshold), - and `computeTotals` composing them. New ports `ShippingRulesStore`, - `TaxRulesStore`, `CouponStore` (each with an in-memory fake + a reusable - contract suite), the `computeQuote` read-side use-case, coupon validation, the - `reconcileCouponRedemptions` crash-recovery sweep, and the extension of - `createOrderFromCart` to compute the full breakdown, redeem a coupon atomically - under the same idempotency key (releasing it synchronously if order creation - then fails), and snapshot the whole breakdown immutably into `order_totals`. -- `@otta-sh/store-postgres`: forward-only migration `0007_shipping_tax_coupons` - (shipping zones/methods/rates, tax classes/rates, coupons + coupon_redemptions) - and the `KyselyShippingRulesStore` / `KyselyTaxRulesStore` / `KyselyCouponStore` - adapters on better-sqlite3 + Postgres. Coupon redemption is a single guarded - `UPDATE coupons SET uses_count = uses_count + 1 WHERE uses_count < max_uses` - coupled with an idempotency-guarded redemption insert — the exact shape of the - no-oversell inventory reserve. A Postgres-required no-over-redeem concurrency - test proves exactly `M` of `N` concurrent redeems succeed at `maxUses = M`. - `order_totals` gets no new migration: the phase only writes richer values into - its existing columns. -- `@otta-sh/service`: `POST /checkout/quote` (read-only totals preview, no - redemption), the admin CRUD surface for shipping/tax/coupon config, and the - extension of `POST /checkout/orders` in place (accepts a shipping method + - coupon code, redeems atomically, persists the breakdown) — never renamed - `/checkout/complete`. Wire format mirrors the ports 1:1, asserted by a - live-server HTTP contract test. +New pure, IO-free pricing engines — `allocateCents` +(largest-remainder discount apportionment, BigInt-exact so `Σ === total` +always), `computeLineTax` (half-up per-line, integer bps), `computeCouponDiscount` +(fixed-amount clamped at subtotal / percentage with cap, currency-checked), +`resolveShippingRate` (flat / free-shipping with a post-discount threshold), +and `computeTotals` composing them. New ports `ShippingRulesStore`, +`TaxRulesStore`, `CouponStore` (each with an in-memory fake + a reusable +contract suite), the `computeQuote` read-side use-case, coupon validation, the +`reconcileCouponRedemptions` crash-recovery sweep, and the extension of +`createOrderFromCart` to compute the full breakdown, redeem a coupon atomically +under the same idempotency key (releasing it synchronously if order creation +then fails), and snapshot the whole breakdown immutably into `order_totals`. diff --git a/.changeset/phase-7-reports-and-settings.md b/.changeset/phase-7-reports-and-settings.md index a5137b29..8936ff34 100644 --- a/.changeset/phase-7-reports-and-settings.md +++ b/.changeset/phase-7-reports-and-settings.md @@ -6,8 +6,9 @@ Phase 7 — reports / settings / polish (the final planned phase). Adds merchant visibility and control WITHOUT any new money-moving surface: reporting is strictly read-only, and settings prove a three-tier split (plugin `ctx.kv` for -non-secret display prefs, service DB for operational config the domain depends -on, service env for secrets). The two disciplines this phase enforces: revenue +non-secret display prefs, the commerce store for operational config the domain +depends on, deployment env for secrets). The two disciplines this phase enforces: +revenue aggregates stay integer `Cents` (never floats), and secrets never leak into `ctx.kv` or any settings response body. @@ -21,28 +22,13 @@ aggregates stay integer `Cents` (never floats), and secrets never leak into SNAPSHOT (never a live product join, Phase-4 rule). A `MAX_REPORT_RANGE_DAYS` (400) guard rejects unbounded ranges. A shared deterministic fixture (14 orders, all ten states, 2 currencies, 4 products) is the single source of truth for both - the fake and dialect tests. -- `@otta-sh/store-postgres`: forward-only migration `0008_settings_and_reporting_indices` - (single-row `settings` table + `settings_mutations` idempotency ledger; reporting - indices on `orders(created_at,state)`, `order_items(order_id,product_id)`, - `inventory(on_hand)`). `KyselyReportingStore` runs the four aggregates on - better-sqlite3 + Postgres with one dialect-branched period-bucket helper - (`date_trunc` vs `strftime`, both truncating `week` to the ISO Monday); - `KyselySettingsStore` is an idempotency-ledgered upsert (a replay returns the - recorded result and never clobbers a newer write). The shared contract suites, - the headline seeded-aggregate test, and a randomized large-cents property test - proving no float drift all pass on BOTH dialects. -- `@otta-sh/service`: read-only `/reports/{revenue,orders-by-status,top-products, - low-stock}` (money as integer cents + ISO-4217 on the wire; the three ranged - endpoints reject a >400-day window with a `400` + structured error), and - `GET`/`PUT /settings` (`PUT` is a privileged admin write — internal token + - `Idempotency-Key` — zod-validated, invalid values are a `400`, never clamped). A - live-server HTTP contract test proves wire ⇄ port fidelity, plus a security test - asserting no secret-shaped field ever appears in a `/settings` response. -- `@otta-sh/plugin`: an admin Reports Block Kit page (four report sections over - `ctx.http`, fails closed with an error banner) and a Settings form with two - visible save paths — `storeDisplayName` via `ctx.kv` (no service call) and the - operational fields via `PUT /settings` over `ctx.http` (surfacing the service's - validation error inline). `ctx.kv` is added to the plugin context (ungated per - EmDash); capabilities stay exactly `content:read` + `network:request` — no - storage/db/kv capability, proven under the workerd-on-Node sandbox. + the fake and the adapter tests. +- `@otta-sh/plugin`: an admin Reports Block Kit page (four report sections, each + failing closed with an error banner) and a Settings form with two visible save + paths — `storeDisplayName` via `ctx.kv` and the operational fields via the + settings write, which is idempotency-keyed and validated rather than clamped, + surfacing its validation error inline. A security test asserts that no + secret-shaped field ever appears in a settings read. `ctx.kv` is added to the + plugin context (ungated per EmDash); capabilities stay exactly `content:read` + + `network:request` — no storage/db/kv capability, proven under the + workerd-on-Node sandbox. diff --git a/.changeset/plugin-settings-admin-token-on-read.md b/.changeset/plugin-settings-admin-token-on-read.md deleted file mode 100644 index 2565b7ce..00000000 --- a/.changeset/plugin-settings-admin-token-on-read.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@otta-sh/plugin": patch ---- - -Settings page: send the admin token on the `GET /settings` read, not only on the write. - -`createSettingsFormHandler` built its `ReportingSettingsClient` with the **service** token -only, so `client.getSettings()` went out with no `X-Internal-Token` — while -`updateSettings` took the admin token per-call. That worked only because the read was -ungated; with `GET /settings` now behind the internal token (ADR-0010) the page would fail -closed on every load. - -Both tokens now come from `readAdminTokens(ctx)` — the same helper the Shipping, Tax and -Coupons pages already use — and the `save-operational` path reuses that `adminToken` -instead of re-reading kv, so the read and the write cannot disagree. With no token -provisioned the page still fails closed to a generic banner (no leaked status or URL) and -still renders both token forms, so there is no bootstrap lockout. diff --git a/.changeset/plugin-title-sync.md b/.changeset/plugin-title-sync.md index 52ccb780..9e8d63b4 100644 --- a/.changeset/plugin-title-sync.md +++ b/.changeset/plugin-title-sync.md @@ -8,7 +8,7 @@ Fix: products created through the CMS were unpurchasable — the plugin never sy Every product synced by the plugin was born with `product_commerce.title = NULL`, and an order line snapshots the product title at purchase time, so `createOrderFromCart` rejected the checkout with `PRODUCT_NOT_PRICED`. The buyer saw a checkout failure on a product the storefront had -happily shown as in stock and priced. The service, its request schema and the store all handled +happily shown as in stock and priced. The commerce upsert and the store both handled `title` correctly the whole time; the plugin's derive simply never sent it. The sync now sends the title on every commerce upsert, read from the collection's own **Title @@ -24,8 +24,8 @@ top of the product editor. Because it lives in the shared derive, both `content: carries SKU, price, kind and stock; only the title is omitted, with a specific warning logged naming `data.title`. Vetoing the upsert instead would mean such a collection silently loses *all* commerce sync, a worse failure than an untitled product. The title is never sent as an - empty or over-long string either: both are 400s at the service, and a 400 is a transport - failure, which at publish fails closed and skips the activation. + empty or over-long string either: both are refused at the commerce write, and a refused + upsert fails closed at publish and skips the activation. - Omitting is also safe against data loss: the store preserves a stored title when the field is absent from the body, so a momentarily blank title can never blank a good one. diff --git a/.changeset/prev-next-and-page-of.md b/.changeset/prev-next-and-page-of.md index bd11821a..3fa12099 100644 --- a/.changeset/prev-next-and-page-of.md +++ b/.changeset/prev-next-and-page-of.md @@ -11,7 +11,7 @@ console keeps the cursors it has already been handed and replays one to go back, so there is no new query, no reverse keyset read, and nothing new on the wire. `Previous` re-requests the page rather than restoring the rows it had in hand. -The stack holds cursors, not pages: a request under a token the service already +The stack holds cursors, not pages: a request under a token the plugin already issued answers with the collection as it stands now, agrees with a reload of the same address, and does not grow without bound down a long scan. @@ -25,13 +25,13 @@ screen the position states the window it describes (`Pages 2–3 of 6`), because "Page 3" over fifty rows beginning at page two tells whoever is reading the top of that list the wrong number. -**The page count is derived from what the list already holds.** The service -counts the filtered set alongside the page it returns and the plugin states the +**The page count is derived from what the list already holds.** The plugin +counts the filtered set alongside the page it returns and states the page size it pages by, so the count is arithmetic over two values already on screen — never a second request. It consumes the figure the count line actually stated rather than the raw payload number, so a total the caption withheld cannot reappear underneath it; the two lines can still drift if the store -changes between the count and the page, but only for that reason. A service that +changes between the count and the page, but only for that reason. A read that reports no total leaves an em dash rather than a guess — absent is not one, and it is not zero. A render standing on the last page states that page as the count; where the arithmetic insists there are more pages than the one being @@ -70,7 +70,7 @@ Paging forward from such a page still comes back to it, and a link to the LAST page keeps its pager rather than vanishing at the moment it is the only thing that could say where the operator is. -Where paging has stopped — a page that failed, or a continuation the service +Where paging has stopped — a page that failed, or a continuation the plugin refused mid-scan — the whole pager is withdrawn along with `Load more`, and the rows stay exactly where they are. A failed page never clears the rows now, whichever direction it was asked for; the refusal is drawn beside them, and its diff --git a/.changeset/product-data-model-adds.md b/.changeset/product-data-model-adds.md index c37ba031..f6867586 100644 --- a/.changeset/product-data-model-adds.md +++ b/.changeset/product-data-model-adds.md @@ -28,21 +28,12 @@ no storefront rendering, and NO change to reservation semantics. live-only, registry delete-in-use) plus two fast-follow pins: a soft-deleted row is always inactive, and keyset pagination works under the archive (`deleted`) view. -- `@otta-sh/store-postgres`: forward-only migration `0017` adds - `compare_at_cents`/`compare_at_currency`, `unit_cost_cents`/`unit_cost_currency` - (nullable, `>= 0` CHECK), and `inventory_policy text NOT NULL DEFAULT 'deny'`, - additively (no backfill). The Kysely store rows/edits carry the new fields and - the extended currency guards, dialect-identical on better-sqlite3 and Postgres; - `KyselyTaxRulesStore.deleteClass` and `countByTaxClass` implement the guards. -- `@otta-sh/service`: `PATCH /admin/products/:id` accepts the new fields; - `editProductCommerceBody` bounds compare-at/cost (non-negative money) and - `inventoryPolicy` (`"deny"` enum). The internal-token admin detail serializes - unit cost; the PUBLIC `GET /products/:id/commerce` (an un-gated, storefront- - reachable GET) and the catalog view DELIBERATELY OMIT unit cost — admin-only - margin data never reaches a buyer, pinned by a test. - `@otta-sh/plugin`: the product edit form surfaces the four fields via Block Kit — compare-at + unit cost as TEXT money inputs (integer-string parsed, never a - float), a tax-class SELECT sourced from the live registry (`GET - /admin/tax/classes`, static-seeded fallback, best-effort so a registry read + float, and bounded as non-negative money), a tax-class SELECT sourced from the + live registry (static-seeded fallback, best-effort so a registry read failure degrades rather than breaks the detail), and a DENY-ONLY inventory- - policy select. Sandbox-clean (local wire types, `ctx.http`-only egress). + policy select. Unit cost is serialized to the admin detail only: the + storefront-reachable product commerce view and the catalog view DELIBERATELY + OMIT it — admin-only margin data never reaches a buyer, pinned by a test. + Sandbox-clean (local wire types). diff --git a/.changeset/product-edit-page.md b/.changeset/product-edit-page.md index 940d74b7..246a2df2 100644 --- a/.changeset/product-edit-page.md +++ b/.changeset/product-edit-page.md @@ -22,15 +22,12 @@ CMS document and the title by renaming it. concurrent edit is a `stale` result the caller reloads on, never a silent clobber. Idempotent replay dedupes a double-submit; currency integrity is atomic (a price edit can never silently switch an already-priced product's - currency); `price > 0` and non-negative dimensions are validated + currency); a sku already held by another live product is a typed `SKU_TAKEN` + rejection; `price > 0` and non-negative dimensions are validated (`InvalidProductFieldError`). Never touches `active`/`deletedAt`/watermarks. -- **Adapters** — the fake and the Kysely store (sqlite + Postgres) implement the - guarded update as a single atomic conditional `UPDATE` + a classify-the-no-op - re-read, contract-pinned to identical guard order across all three. -- **Service** — `PATCH /admin/products/:id` mirroring the port under the - X-Service-Token write gate (+ the admin X-Internal-Token): stale → 409 - `STALE_EDIT` with the current watermark, currency → 409 `CURRENCY_MISMATCH`, - SKU collision → 409 `SKU_TAKEN`, non-positive price → 400, unknown → 404. + The guarded update is a single atomic conditional write plus a + classify-the-no-op re-read, contract-pinned so every adapter applies the + guards in the same order and a stale edit reports the current watermark. - **Plugin** — an edit form on the product detail leaf. Money is a TEXT input parsed to integer minor units by exact integer string math (never a Block Kit `number_input`, which hands back a JS float); currency is fixed for an diff --git a/.changeset/product-lifecycle-surfacing.md b/.changeset/product-lifecycle-surfacing.md index 88effed5..7608be72 100644 --- a/.changeset/product-lifecycle-surfacing.md +++ b/.changeset/product-lifecycle-surfacing.md @@ -24,16 +24,16 @@ gap; it adds no new writer of `active`/`deletedAt`. with the live view — never both on one page). `ProductSummary` gains `deletedAt: string | null`, present on every row (null on a live row, set only in the archive view) so a consumer never has to guess whether the field exists. -- **Adapters** — the fake and the Kysely store (sqlite + Postgres) flip the same +- **Adapters** — the fake and every `ProductCommerceStore` adapter flip the same base `deleted_at` predicate the filter now parameterizes, contract-pinned (`listProducts filter.deleted:true is the archive view`, `...composes with active/productKind/search like every other axis`). -- **Service** — `GET /admin/products?deleted=true` is the archive-view query - param; `GET /admin/products/:id` no longer collapses a soft-deleted row into - the SAME 404 an unknown id gets — it now returns 200 with `deletedAt` set (the - honest read-only tombstone), while the WRITE routes (`PATCH`, `restock`, - `remove-stock`) remain 404 for a deleted row via their own pre-existing - not_found guards — this is visibility only, never a path back to editability. +- **Admin reads** — the archive view is the `deleted` flag on the admin products + list filter; reading a soft-deleted product by id no longer collapses into the + SAME not-found an unknown id gets — it now answers with `deletedAt` set (the + honest read-only tombstone), while the WRITES (update, restock, remove stock) + still refuse a deleted row via their own pre-existing not-found guards — this + is visibility only, never a path back to editability. - **Plugin** — the Products console's "Status" filter gets a 4th, mutually exclusive option, "Archived (deleted)", so a merchant can never combine it with Active/Inactive into a filter contradiction. A `deletedAt`-outranks-`active` @@ -42,7 +42,7 @@ gap; it adds no new writer of `active`/`deletedAt`. a read-only tombstone banner (deletion timestamp + a note that existing orders are unaffected, since an order snapshots price/title at purchase time) with NO edit form and NO stock forms — editing or restocking a deleted product is - meaningless, and the write routes would 404 it anyway. + meaningless, and the writes would refuse it anyway. Known, deliberately out-of-scope gap this slice surfaces but does not fix: restoring a CMS document from the trash does NOT undo a soft delete — `upsert` (the @@ -52,12 +52,11 @@ domain-owned RESTORE command, a separate, larger change (its own idempotency / ordering-watermark story), not a read-surfacing slice; flagged here for a follow-up decision, not built. -Verification: the full `productCommerceStoreContract` (130 tests, sqlite + Postgres -dialects), `admin-products-http.test.ts` against a live Postgres-backed server (incl. -the new archive-filter and tombstone-detail cases, and the write-route -still-blocked-for-deleted regression), and the plugin's workerd-on-Node sandbox +Verification: the full `productCommerceStoreContract` (130 tests, incl. the new +archive-filter and tombstone-detail cases and the write-still-blocked-for-deleted +regression) and the plugin's workerd-on-Node sandbox (`products-page.sandbox.test.ts`, incl. the archived-filter query and the no-edit/no-stock-forms tombstone render) all pass. No new mutating command exists to -race checkout, so no new Postgres concurrency test was needed; `listCommerceByIds` +race checkout, so no new concurrency test was needed; `listCommerceByIds` already omits soft-deleted rows (pre-existing, unchanged) so a deleted product was already unpurchasable before this change. diff --git a/.changeset/products-react-console.md b/.changeset/products-react-console.md index 17cb0c2a..4f158da5 100644 --- a/.changeset/products-react-console.md +++ b/.changeset/products-react-console.md @@ -10,7 +10,7 @@ Migrate the Pricing & inventory admin screen to the React console — the second **Identity on this screen is the SKU, and it renders in full.** The UUID display rule governs opaque ids, and this screen shows none in a list row — the product uuid lives in the link's target. A SKU is a natural key, the thing low stock is reported by and the thing a purchase order is written against, so it renders whole with a copy button beside it rather than truncated to a prefix. (One footnote for exactness: a product whose CMS title is null falls back to the uuid in the detail's H1, which is the Block Kit screen's behaviour verbatim — `p.title ?? id` — and is why the two surfaces still disagree about that one cell's fallback with the list, which shows `(untitled)`. Recorded as a follow-up rather than changed here, because changing it is a deviation from the screen being migrated.) -**`Low stock only` stays page-scoped, and the row count stays honest.** The filter narrows the page a request fetched rather than the query (the products list has no stock predicate), so the service's exact count describes a different set of rows than the ones on screen and is withheld while it is on — both surfaces make that call in one place. There is no Title field and no Status field on either surface: `product_commerce.title` and `active` are CMS-owned, and a Playwright spec now asserts their absence on the React side, where the type system cannot. +**`Low stock only` stays page-scoped, and the row count stays honest.** The filter narrows the page a request fetched rather than the query (the products list has no stock predicate), so the read's exact count describes a different set of rows than the ones on screen and is withheld while it is on — both surfaces make that call in one place. There is no Title field and no Status field on either surface: `product_commerce.title` and `active` are CMS-owned, and a Playwright spec now asserts their absence on the React side, where the type system cannot. **`@otta-sh/admin-presentation` gains the products vocabulary** both surfaces render through: `statusLabel`, `onHandCell` (with the null-vs-zero-vs-missing distinction intact), `parseStockQty`, the screen's authored copy, the stock-degradation banner's composition, the remove-stock confirm's sentence, the D-6 group labels and `formatOptionalAmount`. The last of those deleted the second money renderer **on this screen**, whose Intl-failure branch printed raw minor units into a money field; `coupons-page.ts` and `shipping-page.ts` still carry a private `formatCentsForDisplay` with the same hand-assembled catch, and the React order detail still renders one amount *in prose* through `formatMinorUnitsInput` (`order-detail.tsx`'s "the remaining refundable amount is …"), which is the money INPUT formatter and carries no currency — correct for a field's initial value, thin for a sentence. Retiring the first two and giving the third a currency-bearing renderer is recorded as the cross-screen follow-up this increment does not reach. The Orders detail's roughly-a-dozen hand-copied strings moved here too, closing the rider INC-20 recorded — and finding four places the two Orders surfaces had already drifted: typographic quotes in the cancel copy, a reconciliation note that had lost its next step, an over-refund refusal that stated the fact without the instruction, and an additive-refunds warning whose step reference is true on only one surface. diff --git a/.changeset/products-write-path-extraction.md b/.changeset/products-write-path-extraction.md index 240ce82d..a89dc150 100644 --- a/.changeset/products-write-path-extraction.md +++ b/.changeset/products-write-path-extraction.md @@ -25,7 +25,7 @@ removing it is a rewrite and not a deletion. moves, and refuses on a mismatch, with an absent watermark refused fail-closed and with no re-read); the **edit watermark** (`expectedUpdatedAt` is mandatory, and a save without one — or with a blank one — refuses rather than clobbering, - guarded at the same tier as the stock watermark rather than left to the service + guarded at the same tier as the stock watermark rather than left to the store to reject); **money as integer minor units** (an exact decimal parse, a positive amount, a required ISO-4217 currency, and a blank compare-at as an explicit clear rather than a zero); and @@ -51,7 +51,7 @@ removing it is a rewrite and not a deletion. ran for any shipped surface: the **DA-3c bound check** of the requested quantity against the on-hand just re-read, the **`REMOVE_STOCK_INVALID_QTY`** field-level refusal, and the **`remove-draft`/`remove-staged` render state**. - What protects the reachable path instead is the service's guarded decrement, + What protects the reachable path instead is the inventory store's guarded decrement, which refuses an over-removal with the real on-hand and is surfaced as a named refusal quoting that count (asserted by the new suite), plus the inventory-store contract suite pinning that an over-removal removes nothing and never goes @@ -63,14 +63,11 @@ existed to tell this screen apart from the Block Kit screen at the same path; with that screen gone, a single entry marked new against nothing is the misleading thing (ADR-0015 Decision 1). -**Three read-path assertions were rescued from the deleted suite rather than -written off as render-only**, because each is a claim about what the SERVICE is -asked for and outlives the renderer: the internal admin token travelling on the -list and detail GETs (every surviving header assertion was on a write); the -absent-token → 401 fail-closed trigger (the anti-leak contract was otherwise -exercised only through a 500, and an unconfigured token is the failure an -operator actually meets); and the three filter axes — `active`, `productKind` -and `search` — travelling together in ONE query rather than only one at a time. +**Read-path assertions were rescued from the deleted suite rather than written +off as render-only**, because each is a claim about what the READ is asked for +and outlives the renderer: chiefly that the three filter axes — `active`, +`productKind` and `search` — travel together in ONE request rather than only one +at a time. **The block-tree half of `console-transport.ts` now has no callers** — `firstNotice`, `forwardConsoleAct`, `forwardedFormSubmit` and `nothingApplied`, diff --git a/.changeset/promote-create-actions.md b/.changeset/promote-create-actions.md index 17c2515b..62120022 100644 --- a/.changeset/promote-create-actions.md +++ b/.changeset/promote-create-actions.md @@ -40,7 +40,7 @@ construction rather than by arithmetic). **A refusal no longer costs the operator their typing, and now that is a property of the response rather than of the client.** Every create refusal — a blank id, an unparseable percent, a cross-type field, a duplicate id -rejected by the service — re-renders the create screen with everything that +rejected by the store — re-renders the create screen with everything that was submitted put back as `initial_value` (DA-3a-i). Before this, the values survived only as unsubmitted state in a form the client happened to keep mounted, and the E-2 path did not keep it: clicking a create button from an diff --git a/.changeset/reports-low-stock-titles.md b/.changeset/reports-low-stock-titles.md index 33d2fba8..4e002daf 100644 --- a/.changeset/reports-low-stock-titles.md +++ b/.changeset/reports-low-stock-titles.md @@ -2,10 +2,10 @@ "@otta-sh/plugin": patch --- -Reports low-stock table: carry the product title (admin-UX INC-05). The wire -already carried `LowStockRow.title` (INC-03), but the Reports screen never -read it — an operator staring at a bare `SKU-A` still had to keep a -SKU-to-title map in their head to know what was running out. +Reports low-stock table: carry the product title (admin-UX INC-05). The +low-stock read already carried `LowStockRow.title` (INC-03), but the Reports +screen never read it — an operator staring at a bare `SKU-A` still had to keep +a SKU-to-title map in their head to know what was running out. The `reports:low-table` columns change from `SKU` -> `On hand` to `Title` -> `SKU` -> `On hand`. A `null` title renders `(untitled)`, and never falls @@ -18,7 +18,7 @@ increment's `products-page.ts` `On hand` column has not merged as of this change, so this is not a mirror of shipped code; when it lands, its column is this one's sibling, not its source. Deliberately plain text rather than `format: "badge"`: every row here already sits at or below some threshold -by construction of `GET /reports/low-stock`, so a badge column could -legitimately render the identical value on every row in a given response — -exactly the case `ADMIN-CONSOLE.md`'s X-4 (T-5) forbids. Presentation only: -no port, wire-format, or money-handling change. +by construction of the low-stock read, so a badge column could legitimately +render the identical value on every row in a given response — exactly the case +`ADMIN-CONSOLE.md`'s X-4 (T-5) forbids. Presentation only: no port change and +no money-handling change. diff --git a/.changeset/reports-period-and-kpis.md b/.changeset/reports-period-and-kpis.md index 775d886f..197b797c 100644 --- a/.changeset/reports-period-and-kpis.md +++ b/.changeset/reports-period-and-kpis.md @@ -14,15 +14,15 @@ equally well as all-time or as today. Now: submit id is registered in `REPORTS_ACTION_IDS`, so a period change can never fall through the dispatcher to a blank console. The form carries the bucket interval, so changing the period on a weekly report keeps it weekly. An - unusable range (backwards, incomplete, wider than the service's 400-day cap) - renders the default period with a banner saying why — always a 200. + unusable range (backwards, incomplete, wider than the 400-day reporting cap) + renders the default period with a banner saying why — never an error screen. - Every period is WHOLE DAYS, default included: `from` at the start of its day, `to` at the end of its. The default and a hand-entered identical period are therefore the same query, and "last 30 days" is exactly 30 day-rows. - All four `stats` slots are used: Revenue, Orders, AOV and Refunded, each labelled with the period and, for money, its currency once. Money renders only through `formatMoney`; an average with no orders to average renders an - em-dash, never `$0.00`. The refunded AMOUNT is absent from the reporting wire, + em-dash, never `$0.00`. The refunded AMOUNT is absent from the reporting read, and the tile says so rather than showing a figure it cannot know. Four filled slots is the SINGLE-CURRENCY case: a multi-currency window spends cards on revenue it cannot combine into one figure, and the cards that fall off the end @@ -30,10 +30,10 @@ equally well as all-time or as today. Now: - Revenue by day emits the zero-revenue days, so a month of steady sales and a month with a three-week hole no longer render identically — for periods up to 92 days in a single currency, where the fill shows shape rather than becoming - the table. Otherwise the wire's sparse series renders and the group states the + the table. Otherwise the sparse series renders as it comes and the group states the omission. The label drops the internal "(N buckets)" vocabulary. - The low-stock group states the threshold its rows were selected by - (`Low stock (3) — at or below 5`), read from `GET /settings`; a failed settings + (`Low stock (3) — at or below 5`), read from settings; a failed settings read drops the threshold from the label instead of taking the screen down. Also corrects a false claim in this file's own documentation: Block Kit does ship diff --git a/.changeset/resolve-reconciliation.md b/.changeset/resolve-reconciliation.md index 22b82e95..54ecfab0 100644 --- a/.changeset/resolve-reconciliation.md +++ b/.changeset/resolve-reconciliation.md @@ -24,20 +24,13 @@ admin's disposition and clears the flag. `outcome ∈ {refunded, fulfilled, written_off}` RECORDS the disposition — it moves no money; an actual refund/cancel stays the separate `transitionOrder` command. Resolving a never-flagged order is `NOT_IN_RECONCILIATION`; an already-resolved order is a benign - idempotent no-op (mirrors `transitionOrder`'s already-at-target no-op). -- **Adapters (`[Adapters]`).** Forward-only migration `0011` adds four nullable - `reconciliation_*` columns; the Kysely adapter implements the guarded flip and hydrates the - resolution. Green against the shared `orderStoreContract` on better-sqlite3 and Postgres, - plus a Postgres race test: N concurrent resolves on one flagged order yield exactly one - winner and write the disposition exactly once. -- **Service (`[Service]`).** `POST /admin/orders/:id/resolve-reconciliation` mirrors the port - 1:1 (body requires `expectedFlag`), under the internal-token + `X-Service-Token` write gate; - `RECONCILIATION_FLAG_CHANGED` and `NOT_IN_RECONCILIATION` map to 409; the order wire gains - `reconciliationResolution`. + idempotent no-op (mirrors `transitionOrder`'s already-at-target no-op). The recorded + disposition is hydrated onto the order read as `reconciliationResolution`. Green against + the shared `orderStoreContract`, including the race where N concurrent resolves on one + flagged order yield exactly one winner and write the disposition exactly once. - **Plugin (`[Plugin]`).** The order detail page surfaces an open flag with an alert banner + a resolve form (outcome/reason/resolvedBy; the displayed flag rides along as - `expectedFlag`), shows the recorded disposition once resolved, and threads the tokens via - `readAdminTokens`. The outcome copy makes explicit that resolving records a disposition and - does NOT move money ("refunded (recorded only — issue the refund separately)" + a context - caption); a stale-review 409 surfaces a dedicated "reconciliation state changed — reload" - notice. Sandbox-clean. + `expectedFlag`) and shows the recorded disposition once resolved. The outcome copy makes + explicit that resolving records a disposition and does NOT move money ("refunded (recorded only — issue the refund separately)" + a context + caption); a stale-review conflict surfaces a dedicated "reconciliation state changed — + reload" notice. Sandbox-clean. diff --git a/.changeset/retire-service-deployment.md b/.changeset/retire-service-deployment.md index e306ae6c..749eeab0 100644 --- a/.changeset/retire-service-deployment.md +++ b/.changeset/retire-service-deployment.md @@ -56,5 +56,6 @@ folds per-transport duplicates into single cases. rode on the HTTP tier's request log, so it is no longer covered past the point where the Stripe gateway is called. Tracked as `#286`. -`@otta-sh/service` loses its `wrangler.jsonc` and its `wrangler dev` / `wrangler -deploy` scripts — it is no longer a deployable. +With the plugin no longer calling out to it, the commerce service stops being a +separately deployed Worker: there is one deployable left, and it is the site the +plugin runs in. diff --git a/.changeset/rules-update-delete.md b/.changeset/rules-update-delete.md index f68ea507..e026090b 100644 --- a/.changeset/rules-update-delete.md +++ b/.changeset/rules-update-delete.md @@ -6,7 +6,7 @@ Rules UPDATE/DELETE capabilities + a typed plugin rules-client (admin-UX Increment 3, slice 1). Closes the capability gap the admin audit flagged: tax/shipping/coupon config was create/read-only, blocking every tax & shipping -admin screen. This slice adds the missing domain/service mutations plus one +admin screen. This slice adds the missing domain mutations plus one sandbox-clean plugin client; the drill-down UIs consume it in later slices (no UI here). @@ -26,19 +26,18 @@ Per-entity design (decision table, with rationale): edit); DELETE forbid-if-redeemed (`in_use_by_redemptions`), preserving the FK + reconciliation trail. -Referential deletes are ATOMIC (`DELETE ... WHERE NOT EXISTS child`, FK-backed -for methods/rates/redemptions) so a concurrent child insert can never orphan. -The CAS money edits are once-only under replay (a blind retry is reported -`stale`, never double-applied) and verified by a Postgres N-way race +Referential deletes are ATOMIC — the child check and the delete are one +operation for methods/rates/redemptions — so a concurrent child insert can +never orphan. The CAS money edits are once-only under replay (a blind retry is +reported `stale`, never double-applied) and verified by an N-way race (exactly-one-winner, the no-oversell analogue for admin edits). Deletes are idempotent (`not_found` no-op). Snapshot invariant: an order snapshots its totals at creation, so deleting a rate/coupon never rewrites an existing order; an in-flight cart recomputes on its next quote/checkout and sees the deletion (a deleted rate resolves to 0 bps / -unavailable). No schema change (forward-only migrations untouched) — the CAS -tokens are existing readable columns. +unavailable). No schema change — the CAS tokens are fields that were already +readable. -Service adds PATCH/PUT + DELETE routes mirroring the ports 1:1 under the write -gate; the plugin gains `AdminRulesClient` (discriminated results, admin + -service token threading, 404/409 mapping) covering the full rules surface. +The plugin gains a typed admin rules client with discriminated results (a +refusal is a named reason, never an exception) covering the full rules surface. diff --git a/.changeset/seed-inventory-on-first-sku.md b/.changeset/seed-inventory-on-first-sku.md index 756a5df8..9961e96e 100644 --- a/.changeset/seed-inventory-on-first-sku.md +++ b/.changeset/seed-inventory-on-first-sku.md @@ -8,29 +8,29 @@ Fix: a product priced in the admin console could never be stocked. Setting a SKU on the **Pricing & inventory** page wrote only `product_commerce` — nothing ever created the product's inventory record. The merchant's next step, Restock, then failed with "No stock record yet" (`NO_INVENTORY_ROW`), permanently, with no way forward from the admin UI. -`initialOnHand` on the integrator `PUT /products/:id/commerce` was the only thing in the whole -system that had ever created one. +`initialOnHand` on the integrator commerce upsert was the only thing in the whole system that +had ever created one. The invariant is now **a product with a SKU has an inventory record**, held by the data rather than by one caller, so *both* write paths seed it: - the admin commerce edit seeds a zero record for the resulting SKU after an applied edit; -- `PUT /products/:id/commerce` seeds `0` when it carries a SKU and no `initialOnHand`, so the - integrator path can no longer mint a SKU with nothing behind it either. +- the integrator commerce upsert seeds `0` when it carries a SKU and no `initialOnHand`, so that + path can no longer mint a SKU with nothing behind it either. -The seed is the existing create-if-absent `INSERT … ON CONFLICT (sku) DO NOTHING`, so it can -never clobber a live or already-decremented count. +The seed is the existing create-if-absent write — it takes effect only when the SKU has no +record at all — so it can never clobber a live or already-decremented count. **One behaviour change to know about: initial stock now only lands on the first save that carries the SKU.** Because the seed is create-if-absent and now runs as soon as a SKU exists, an `initialOnHand` sent on a *later* save is silently discarded — the record is already there at `0`. Previously that later save was the only way to heal a product whose stock record had gone missing. -In practice this only affects the integrator `PUT /products/:id/commerce`: send `initialOnHand` +In practice this only affects the integrator commerce upsert: send `initialOnHand` with the first SKU-bearing call, or add stock afterwards with **Restock** on Pricing & inventory, which now always has a record to add to. Nothing is lost. (The CMS "Product data" panel also had a Stock input with this hazard, but it is deleted in the same release — see "one home per field" — -so the only stock paths that ship are the integrator PUT and Restock.) +so the only stock paths that ship are the integrator upsert and Restock.) The discard is deliberate: the seed must never overwrite a live or already-decremented count. diff --git a/.changeset/service-token-gate.md b/.changeset/service-token-gate.md deleted file mode 100644 index 6f3eb923..00000000 --- a/.changeset/service-token-gate.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -"@otta-sh/plugin": minor ---- - -Move the machine write-gate token to a dedicated `X-Service-Token` header (ADR-0007), -freeing `Authorization: Bearer` for customer session auth, and thread it from write-only -plugin kv. - -The `SERVICE_API_TOKEN` write gate previously consumed `Authorization: Bearer` — which is -also the customer session credential. Because the gate runs first for every non-GET, enabling -the service secret would 401 every session route (`/auth/logout`, `/me/*` mutations) before -session auth ran. The token now rides its own header; the two no longer collide. - -- **Service (`[Service]`).** `requireBearerToken` → `requireServiceToken`: reads only - `X-Service-Token` (never `Authorization`), and its 401 drops `WWW-Authenticate: Bearer` - (a custom header has no registered challenge) — now byte-identical to the `X-Internal-Token` - gate. The `SERVICE_API_TOKEN` env-var name and the Stripe-webhook exemption are unchanged. - Routes that also carry `X-Internal-Token` (`PUT /settings`, `POST /admin/orders/:id/transition`, - rules-admin POSTs, `/internal/*`, `/entitlements/grant`) now require BOTH headers when both - secrets are set. -- **Plugin (`[Plugin]`).** All three clients (`HttpCommerceClient`, `ReportingSettingsClient`, - `AdminOrdersClient`) forward the token as `X-Service-Token`, sourced at runtime from - write-only `ctx.kv` (`settings:serviceToken`) via the new fail-closed `serviceTokenFromKv` - helper — never baked into the bundle (stays sandbox-clean). A new masked, write-only - "Service token (X-Service-Token)" field on the Settings page provisions it. Note the gate - blocks POST *reads* too (`getCommerceBatch` for PDP/PLP, the login pre-auth POSTs), so those - paths now depend on kv provisioning when the service secret is set. - -Deploy ordering and rotation guidance are documented in ADR-0007 and `sites/staging/README.md`: -provision the kv token before flipping the service secret, and rotate the two in lockstep -(sync hooks are fire-and-forget with no reconcile cron, so a mismatch drops writes silently). diff --git a/.changeset/shipping-admin-drilldown.md b/.changeset/shipping-admin-drilldown.md index 26c1b58f..0f040f88 100644 --- a/.changeset/shipping-admin-drilldown.md +++ b/.changeset/shipping-admin-drilldown.md @@ -6,9 +6,8 @@ Shipping admin drill-down UI (admin-UX Increment 3, slice 3): a new `/shipping` admin screen — zones (list/create/edit-LWW/delete-forbid-if- methods) drilling into a zone's methods (list/create/edit-LWW/delete-forbid- if-rates) drilling into a method's currency-keyed rates (list/create/edit- -with-CAS/delete). Built entirely on the existing list/detail scaffold and -`AdminRulesClient` (both landed in prior slices) — no domain or service -change. +with-CAS/delete). Built entirely on the existing list/detail scaffold and the +admin rules client (both landed in prior slices) — no domain change. This is the FIRST production screen to actually reach drill depth 3 — the scaffold's own synthetic geo fixture proved the N-level nav core worked @@ -18,22 +17,22 @@ rates) encode the full target path into the open form's option value fired from two different levels. The rates level exercises the scaffold's auto filter-path-carry at depth 2 for the first time: unlike tax rates (their own `id`, a per-zone list read), a shipping rate's identity is -`(methodId, currency)` and the service exposes only a single-currency -lookup — the level is a currency-KEYED filter (default `"USD"`, 0-or-1 rows), -not a true multi-row list. +`(methodId, currency)` and the rules surface exposes only a +single-currency lookup — the level is a currency-KEYED filter (default +`"USD"`, 0-or-1 rows), not a true multi-row list. Amounts are TEXT inputs parsed to integer minor units by exact integer string math (never a float), same discipline as the Tax console's basis- point parser — but UNLIKE product pricing, ZERO is a valid amount (a $0 flat rate, or a free-shipping method's below-threshold fallback), matching the -service's own `nonnegative()` (not `positive()`) schema. +rate's own `nonnegative()` (not `positive()`) validation. Regions are presented honestly: `ShippingZone.regions` is opaque config the -pricing engine never reads (checkout/quote takes an explicit +pricing engine never reads (the checkout quote takes an explicit `shippingZoneId`, never an address-to-zone match), so the screen's copy does not claim regions drive automatic zone selection — the field is a plain comma-separated code list for the merchant's own reference. Both parent- delete conflicts (a zone with methods, a method with rates) render the -actual referential-guard reason, never a raw HTTP status. Deleting a rate +actual referential-guard reason, never a bare failure code. Deleting a rate carries danger copy noting in-flight carts recompute while existing orders' snapshotted shipping fee is untouched. diff --git a/.changeset/sku-rename-carries-stock.md b/.changeset/sku-rename-carries-stock.md index 5cbfe82f..c0732b18 100644 --- a/.changeset/sku-rename-carries-stock.md +++ b/.changeset/sku-rename-carries-stock.md @@ -32,7 +32,7 @@ never come apart: when the cart or order finished. Reservations are short-lived, so this is a "try again shortly". Both writers of the field behave identically — the admin **Pricing & inventory** edit and the -integrator `PUT /products/:id/commerce` — because the rule belongs to the field, not to one +integrator product-commerce upsert — because the rule belongs to the field, not to one caller. Writes that change nothing (a re-submitted identical SKU, a double-submitted save, an out-of-order CMS sync, a rejected edit) move no stock at all, so a double-click moves the units exactly once. @@ -51,17 +51,16 @@ exactly once. - Setting a product's **first** SKU is not a rename, and still adopts an existing stock record for that SKU, units and all — the long-standing behaviour that lets a product re-linked to a SKU it used to own recover its stock. Renames refuse; first assignment adopts. -- `initialOnHand` on the integrator PUT is create-only, as before, and a rename claims the new - SKU's record as part of the move — so a PUT that both renames and supplies `initialOnHand` lands - the carried count (or zero, if the old SKU had no record), never the supplied figure. Add stock - with **Restock** instead. -- `SkuStockConflictError` and `SkuHeldStockError` currently surface as generic failures at the - HTTP boundary; mapping them to structured responses and legible messages in the admin console is - a follow-up. +- `initialOnHand` on the integrator upsert is create-only, as before, and a rename claims the new + SKU's record as part of the move — so a save that both renames and supplies `initialOnHand` + lands the carried count (or zero, if the old SKU had no record), never the supplied figure. Add + stock with **Restock** instead. +- `SkuStockConflictError` and `SkuHeldStockError` currently surface as generic failures outside + the domain; giving them legible messages in the admin console is a follow-up. - **A follow-up with a real stake:** the live-reservation check a rename runs is an unindexed scan - of the reservations table, and it runs while the rename holds the lock on the old SKU's inventory - row — the same lock a reservation's oversell-critical decrement needs. On a store whose - reservations table has grown large, that scan is time during which checkouts of that SKU wait on - the rename. Renames are rare, so this is a latency spike rather than a steady-state cost, but the - fix is a partial index on the reservations SKU covering only the live states, and it needs a - migration of its own rather than riding along here. + of the reservations, and it runs while the rename holds the old SKU's inventory row — the same + row a reservation's oversell-critical decrement needs. On a store whose reservations have grown + large, that scan is time during which checkouts of that SKU wait on the rename. Renames are + rare, so this is a latency spike rather than a steady-state cost, but the fix is an index on the + reservations SKU covering only the live states, and that is a storage-layer change of its own + rather than something to ride along here. diff --git a/.changeset/sku-rename-refusal-is-legible.md b/.changeset/sku-rename-refusal-is-legible.md index 3145577e..da95212d 100644 --- a/.changeset/sku-rename-refusal-is-legible.md +++ b/.changeset/sku-rename-refusal-is-legible.md @@ -8,15 +8,15 @@ A refused SKU rename now reaches the operator as a sentence, beside the SKU fiel Renaming a SKU carries its stock across, and there are two states the domain refuses because it cannot carry them honestly: the new SKU already has a stock record of its own, or the old SKU still has live reservations against it. Both refusals were correct and atomic — nothing was -written on either side — and both arrived at the console as a generic failure with a 500 behind -it. On the one screen where the answer is "type a different SKU" or "wait a few minutes", the -operator was told only that something had gone wrong, and the reason survived nowhere but the -service log. +written on either side — and both arrived at the console as an unexplained +failure. On the one screen where the answer is "type a different SKU" or "wait a few minutes", +the operator was told only that something had gone wrong, and the reason survived nowhere but +the server log. - **Both writers answer with a structured conflict**, in the shape each already used for a SKU collision: a machine code — `SKU_STOCK_CONFLICT` or `SKU_HELD_STOCK` — plus the facts the answer needs, which are both SKUs, or the SKU and how many reservations still reference it. The admin - edit and the integrator `PUT /products/:id/commerce` behave identically, because the rule + edit and the integrator commerce upsert behave identically, because the rule belongs to the field rather than to one caller. Nothing else crosses the wire: no internal message, no error name, no stack, no hint of the tables the check ran against. - **One sentence per refusal, written once.** "That SKU already has stock of its own" names both @@ -43,7 +43,7 @@ service log. re-applied on top of them — keeping the draft would make both false, and would leave one more Save between the operator and silently overwriting a writer they never saw. -A count the service did not send is never rendered as `0`: zero reservations beside a refusal +A count that never came back is never rendered as `0`: zero reservations beside a refusal caused by reservations would be the one thing the sentence must not say, so the copy drops the figure and keeps the fact. diff --git a/.changeset/storefront-checkout.md b/.changeset/storefront-checkout.md index b6b3d7ad..fdaf36dd 100644 --- a/.changeset/storefront-checkout.md +++ b/.changeset/storefront-checkout.md @@ -5,33 +5,31 @@ Storefront checkout — the plugin routes that close the buyer journey (ADR-0012). Additive: no existing route, type or behaviour changes. -**Three new public routes**, registered alongside the cart block, all `public: true` and all -pure `ctx.http` proxies: +**Three new public routes**, registered alongside the cart block, all `public: true`: -- **`storefront/checkout/summary`** — ONE route composing three upstream calls, in order: - `GET /carts/:id` → `POST /catalog/commerce/batch` → `POST /checkout/quote`. The commerce +- **`storefront/checkout/summary`** — ONE route composing three reads, in order: the cart + read → one batched commerce lookup for its lines → the checkout quote. The commerce batch is one call regardless of line count (the N+1 guard). The **quote's** breakdown is authoritative for every total — it is what `createOrderFromCart` will charge. Returns the line items, the totals, a `hasUnpricedLines` flag, and the checkout idempotency key. -- **`storefront/checkout/place`** — exactly one call, `POST /checkout/orders`. Projects the - reply down to `{ orderId, state, alreadyPlaced, clientAction }`; `clientAction` passes - through unmodified. +- **`storefront/checkout/place`** — exactly one operation, the create-order-from-cart + command. Projects the result down to `{ orderId, state, alreadyPlaced, clientAction }`; + `clientAction` passes through unmodified. - **`storefront/order`** — the unauthenticated capability read (ADR-0010 §2) the confirmation page polls. -**`HttpCommerceClient` gains `quoteCheckout` / `createOrder` / `getPublicOrder`**, 1:1 -mirrors of the service's checkout endpoints, plus the wire types -(`QuoteBreakdownWire`, `CheckoutRequestWire`, `PublicOrderWire`, `ClientActionWire`, …). -Both POSTs thread `X-Service-Token` (they are non-GETs the write gate blocks); -`Idempotency-Key` is forwarded **verbatim** and never invented; `getPublicOrder` sends **no** -`X-Internal-Token`, so a guest-readable page can only receive `serializePublicOrder`'s -whitelist. Every typed failure — including the **502 `PAYMENT_INTENT_FAILED`** — is returned -as `{ ok: false, reason }`, never thrown. +**The commerce client gains `quoteCheckout` / `createOrder` / `getPublicOrder`**, 1:1 +mirrors of the checkout use-cases, plus the payload types (`QuoteBreakdownWire`, +`CheckoutRequestWire`, `PublicOrderWire`, `ClientActionWire`, …). The caller's +idempotency key is forwarded **verbatim** and never invented; `getPublicOrder` is the +unprivileged read, so a guest-readable page can only receive `serializePublicOrder`'s +whitelist. Every typed failure — including `PAYMENT_INTENT_FAILED` — is returned as +`{ ok: false, reason }`, never thrown. **Honest zeros (`checkout-view-model.ts`, new).** `computeQuote` substitutes a synthetic zero-shipping method when no `methodId` is passed and skips tax entirely when no `zoneId` -is passed, so a store with nothing configured gets `shippingCents: 0` / `taxCents: 0` on the -wire — indistinguishable at the number from genuine free shipping. An **uncomputed** +is passed, so a store with nothing configured gets `shippingCents: 0` / `taxCents: 0` — +indistinguishable at the number from genuine free shipping. An **uncomputed** component therefore renders `"Not calculated"` and never `"Free"` or `"$0.00"`; a component that genuinely *was* computed renders its money even at zero. Same rule applied to an order's own totals on the confirmation view. @@ -45,5 +43,5 @@ Stripe's native idempotency) the same PaymentIntent. A replay whose order has al treating it as one would strand a buyer whose order is already paid. **No capability or egress change.** Stripe.js runs in the buyer's **browser**, never through -`ctx.http`, so `allowedHosts` stays at exactly one host — asserted, along with Stripe's script +`ctx.http`, so checkout adds nothing to `allowedHosts` — asserted, along with Stripe's script host being absent from the whole of `src/`, by an extended `sandbox-clean-guard` suite. diff --git a/.changeset/stripe-live-payment-intent.md b/.changeset/stripe-live-payment-intent.md index 9fad08c8..bfe2cba1 100644 --- a/.changeset/stripe-live-payment-intent.md +++ b/.changeset/stripe-live-payment-intent.md @@ -31,7 +31,7 @@ existing suite, staging and e2e keep running unchanged. `STRIPE_UNSUPPORTED_CURRENCIES` deny-list (Stripe's documented zero- and three-decimal sets) is checked **before any network call**, throwing a terminal `PaymentIntentError` with provider code `unsupported_currency` — so checkout - answers 502 instead of overcharging. The offline path is not gated (it moves no + refuses instead of overcharging. The offline path is not gated (it moves no money). Lifting the restriction needs an exponent-aware money boundary. **`@otta-sh/domain`** @@ -49,21 +49,12 @@ existing suite, staging and e2e keep running unchanged. - The idempotent-replay short-circuit no longer calls `createIntent` when the replayed order has left `pending` (paid / failed / expired / cancelled): now that this is a live provider call, a gateway outage must not turn a replay of an - already-PAID order into a 502. Such a replay returns the order with an empty - handle — `intentId: ""`, `clientAction: { kind: "none" }` (no new intent was - minted and none is needed); the wire shape is unchanged. + already-PAID order into a payment failure. Such a replay returns the order with + an empty handle — `intentId: ""`, `clientAction: { kind: "none" }` (no new intent was + minted and none is needed); the returned shape is unchanged. - **Fix:** `createOrderFromCart`'s outer catch released the coupon redemption for *any* throw after redeem, including throws after the order row was inserted — the order kept its discounted total while the use was handed back. An `orderMinted` ownership handoff (symmetric with the existing `onFailure` plumbing) now releases only while no order row owns the redemption. `RESERVATION_LOST` keeps its eager release (recovery there is a new cart + new key) — a deliberate asymmetry. - -**`@otta-sh/service`** - -- New `stripe-wiring.ts` (`wireStripeGateway`), used by both the Node bin and the - Worker entry: `STRIPE_WEBHOOK_SECRET` without `STRIPE_SECRET_KEY` now means - checkout hands buyers unpayable offline client secrets, so boot logs a loud - `console.warn`. **Warn, never throw** — staging/e2e run without a secret key. -- `POST /checkout/orders` answers **502** `{ ok: false, reason: - "PAYMENT_INTENT_FAILED" }` when the gateway call fails. diff --git a/.changeset/tax-admin-drilldown.md b/.changeset/tax-admin-drilldown.md index 63bec274..45aab9fc 100644 --- a/.changeset/tax-admin-drilldown.md +++ b/.changeset/tax-admin-drilldown.md @@ -5,8 +5,8 @@ Tax admin drill-down UI (admin-UX Increment 3, slice 2): a new `/tax` admin screen — tax classes (registry list/create) drilling into a class's tax rates (list/create/edit-with-CAS/delete). Built entirely on the existing -list/detail scaffold and `AdminRulesClient` (both landed in prior slices) — -no domain or service change. +list/detail scaffold and the admin rules client (both landed in prior +slices) — no domain change. This is the FIRST production screen where both scaffold levels are LISTS (no leaf level): a class drills straight into its rates list, not a detail. Row @@ -24,8 +24,8 @@ existing orders' snapshotted totals are untouched. **Scope note**: renaming or deleting a tax CLASS is intentionally NOT offered. `deleteTaxClass`'s in-use guard exists in `@otta-sh/domain` -(contract-tested) but was never wired to a service HTTP route, and there is -no domain port method for renaming a class at all — both are real -domain/service work for a future slice, not something a UI-only slice should -add. Tax RATES are fully wired end-to-end already, so this screen ships their +(contract-tested) but was never wired through to the console, and there is +no domain port method for renaming a class at all — both are real domain +work for a future slice, not something a UI-only slice should add. Tax +RATES are fully wired end-to-end already, so this screen ships their complete create/list/update-with-CAS/delete-idempotent surface. diff --git a/.changeset/tax-class-verbs-closeout.md b/.changeset/tax-class-verbs-closeout.md index 33090def..553f2f7e 100644 --- a/.changeset/tax-class-verbs-closeout.md +++ b/.changeset/tax-class-verbs-closeout.md @@ -10,7 +10,7 @@ admin surface is done: 1. **Tax-class rename/delete wiring** (the core). `#72` found that `TaxRulesStore` had create/list/delete but no rename at all, and that `deleteTaxClass` (the cross-aggregate delete-in-use guard, contract-tested - since Increment 2 slice 5) had never been routed to HTTP — the tax admin + since Increment 2 slice 5) had never been wired to a caller — the tax admin screen shipped list+create only, with an honest "not available" note. - **Domain**: `TaxRulesStore.updateClass(id, {name})` — last-writer-wins, the same `updateZone`/`updateMethod` precedent (#71): a class carries no @@ -19,23 +19,19 @@ admin surface is done: is new too — `deleteTaxClass`'s two in-use refusals now carry an honest `count` (products via the existing `countByTaxClass`, rates via this new method, queried only on the refusal path) instead of a bare boolean. - - **Service**: `PUT /admin/tax/classes/:id` (rename) and - `DELETE /admin/tax/classes/:id` (wiring `deleteTaxClass`, 409 with - `{reason, count}` on an in-use refusal). - - **Client**: `AdminRulesClient.updateTaxClass`/`deleteTaxClass` (the - latter a dedicated result type carrying the count, unlike the generic - zone/method/coupon `RulesDeleteResult`). - **Plugin**: the tax classes level gets a rename form + delete button per - row (danger-confirm), rendering an in-use conflict as "N products/N - rates reference this class" — never a bare refusal. The screen's old + row (danger-confirm). A class delete answers with its own result type + carrying the count, unlike the generic zone/method/coupon + `RulesDeleteResult`, so the screen renders an in-use conflict as "N + products/N rates reference this class" — never a bare refusal. The old "renaming/deleting is not available yet" note is gone. -2. **Server-side blank-economics guard** (`#75` review finding). The +2. **Blank-economics guard below the form** (`#75` review finding). The "a fixed_amount coupon can't null `amountCents`; a percentage coupon can't - null `rateBps`" rule previously lived ONLY in the plugin's form parser — a - direct `PUT /admin/coupons/:id` caller could blank a live coupon's - discount. `type` isn't on the edit body (it's the coupon's immutable kind, - stored on the record), so the route now fetches the coupon first to learn - its type, then validates before writing: 400, nothing written, on a + null `rateBps`" rule previously lived ONLY in the plugin's form parser — any + other caller of the coupon update could blank a live coupon's + discount. `type` isn't on the edit input (it's the coupon's immutable kind, + stored on the record), so the update now reads the coupon first to learn + its type, then validates before writing: refused, nothing written, on a violation. 3. **Staging descriptor nav** (`#72`/`#73` finding). Tax, Shipping, and Coupons all shipped working admin screens in prior slices but were never diff --git a/.changeset/tax-shipping-label-ordering.md b/.changeset/tax-shipping-label-ordering.md index 2c828e6c..a6846022 100644 --- a/.changeset/tax-shipping-label-ordering.md +++ b/.changeset/tax-shipping-label-ordering.md @@ -5,7 +5,7 @@ Tax and Shipping: lead every row label with the number it exists to show, stop printing raw enums at operators, and order the tax-class controls common-path-first. Presentation only — no port, wire format or money handling -changes, and the service is untouched. Both screens stay Block Kit. +changes, and nothing below the screens is touched. Both screens stay Block Kit. **Tax rates lead with the rate.** `20.00% — European Union · eu-standard-vat · also shipping`, where the label used to open with the slug. Slugs vary in @@ -20,8 +20,8 @@ readable natural key, not an opaque uuid. **Shipping methods lead with the price, which was previously not on the screen at all** — not in the row, not inside the expanded row, only two levels down under the rates drill-in. `€12.00 — Express courier · eu-express · flat rate`. -The amount is not on `ShippingMethodWire` and the service exposes no -cross-method rates read, so the methods level now fetches one rate per method, +The amount is not on `ShippingMethodWire` and there is no cross-method rates +read to call, so the methods level now fetches one rate per method, in parallel, and the cost is bounded on purpose: - **Only on the L-9 accordion branch**, so the fan-out can never exceed 25. @@ -31,7 +31,7 @@ in parallel, and the cost is bounded on purpose: re-list each pay for it; that is affordable at this bound on a registry an operator configures once, and it is why the bound is 25 and not `limit`. (Recorded follow-up, deliberately not built here: these reads carry no - `AbortSignal` and no deadline, so a service that hangs rather than fails + `AbortSignal` and no deadline, so a read that hangs rather than fails holds the render open. Cancellation belongs with a timeout policy for every admin read, not with a label change.) - **Each lookup is secondary and independently contained.** A failure degrades @@ -41,9 +41,8 @@ in parallel, and the cost is bounded on purpose: - **Four price outcomes, none collapsed into another**: an amount, `No rate set`, `Price unavailable` (the read did not answer) and `Price not loaded` (no read was made — the table branch, or a rejected currency). The last - exists so that a future change, such as service-side paging on this registry, - cannot print `Price unavailable` and blame the service for a read nobody - made. + exists so that a future change, such as paging this registry, cannot print + `Price unavailable` and blame the read for a lookup nobody made. - **A missing rate reads `No rate set`, never `Free` and never a zero amount.** A `free_shipping` method with no rate row costs a buyer nothing to see here, but it is also not configured, and the two must not look alike. @@ -64,8 +63,8 @@ currency. **A currency that is not a currency code is rejected before any read.** The filter value is trimmed, upper-cased and shape-checked (`/^[A-Z]{3}$/`); a typo returns an error banner inside a 200, with the list still rendered and the -field still editable, instead of spending up to 25 requests that will all fail -and then painting the whole list `Price unavailable` — blaming the service for +field still editable, instead of spending up to 25 reads that will all fail +and then painting the whole list `Price unavailable` — blaming the reads for a fat-finger. The check is deliberately NOT applied to the rates level one level down, where a single read's failure is already visible and correctly attributed, and substituting a default would turn a typo into a wrong answer. @@ -73,8 +72,9 @@ attributed, and substituting a default would turn a typo into a wrong answer. **No operator-facing copy names a raw enum.** The methods context line reads `"Flat rate" always charges its rate; "Free shipping" charges nothing above its threshold.`, and the fallback table's `Type` badge reads `Flat rate` / -`Free shipping`. The wire values are untouched — `flat_rate` / `free_shipping` -still go over `ctx.http` and still come back; only the copy changed. +`Free shipping`. The stored values are untouched — `flat_rate` / +`free_shipping` are still what the select submits and what a method carries; +only the copy changed. **A tax class's controls are ordered by what an operator does most.** `View rates` first, then the rename form, then the delete, last and alone. Order is diff --git a/.changeset/title-single-writer.md b/.changeset/title-single-writer.md index ef1d8bbf..7b4c26ce 100644 --- a/.changeset/title-single-writer.md +++ b/.changeset/title-single-writer.md @@ -23,9 +23,9 @@ and rejected, and the reasoning is recorded in **Breaking API changes** (relevant if you integrate directly, not if you only use the console): - `UpdateProductCommerceFieldsInput` (`@otta-sh/domain`) no longer has a `title` field. -- `PATCH /admin/products/:id` no longer accepts `title`. Its body schema is now **strict**: an - unrecognised key is a `400` naming the field, rather than being silently dropped behind a - `200`. Anything still sending `title` on that route will now fail on **every** edit, which is - deliberate — a silently discarded rename is the failure this release removes. -- `PUT /products/:id/commerce` is **unchanged** and still accepts `title`. It is the CMS sync's - channel and the one sanctioned writer. +- The product-edit command behind the Pricing & inventory form no longer accepts `title`, and + its input is now **strict**: an unrecognised key is rejected by name rather than silently + dropped behind a success. Anything still sending `title` will now fail on **every** edit, + which is deliberate — a silently discarded rename is the failure this release removes. +- The CMS content sync's own write path is **unchanged** and still carries `title`. It is the + one sanctioned writer. diff --git a/.changeset/variants-rest-and-cart-sku-guard.md b/.changeset/variants-rest-and-cart-sku-guard.md index 34729f41..ca53020b 100644 --- a/.changeset/variants-rest-and-cart-sku-guard.md +++ b/.changeset/variants-rest-and-cart-sku-guard.md @@ -2,55 +2,53 @@ "@otta-sh/plugin": minor --- -Variants reach the integrator API as catalogue data, and the cart stops taking a caller's -word for what a SKU is. The two ship together because the second is what makes the first -safe to expose at all: a product that hands out more than one SKU makes the add -endpoint's missing check reachable. +Variants become manageable catalogue data, and the cart stops taking a caller's word for +what a SKU is. The two ship together because the second is what makes the first safe to +expose at all: a product that hands out more than one SKU makes the add path's missing +check reachable. -- **Four routes, one per writer.** `GET /products/:id/variants` reads a product's sizes; - `PUT /products/:id/variants/:variantKey` is the CMS sync's declare; `PATCH` is the - guarded admin edit; `POST …/deactivate` is the orphan transition. `PUT` and `PATCH` - are not two spellings of one upsert — they are the two writers ADR-0016 keeps apart, - and all three write bodies are `.strict()`, so a declare carrying `sku`/`price` and an - edit carrying `title` are each a 400 that names the field rather than a 200 with it - silently dropped. The variant key is a path segment because it is the identity: - immutable, half the primary key, and unreachable from any body. +- **Four operations, one per writer.** `listProductVariants` reads a product's sizes; + `upsertProductVariant` is the CMS sync's declare; `updateProductVariantFields` is the + guarded admin edit; `deactivateProductVariant` is the orphan transition. Declare and + edit are not two spellings of one upsert — they are the two writers ADR-0016 keeps + apart, and neither input type carries the other's fields, so a declare carrying + `sku`/`price` and an edit carrying `title` do not compile rather than being accepted + with the offending field silently dropped. The variant key is its own argument because + it is the identity: immutable, half the primary key, and unreachable from any payload. - **This manages catalogue data ahead of the storefront wiring.** A merchant can declare, - price, rename and discontinue sizes over HTTP. What it does not yet do is sell them: + price, rename and discontinue sizes. What it does not yet do is sell them: **no guarded, priceable line can carry a variant SKU.** A bare add — one naming no product — can still place the SKU string on a line and reserve its units, exactly as it could before this change; that line has no product reference, so it cannot be priced, quoted or ordered. The gate is deliberate rather than a missing feature — see the guard below. -- **Every documented refusal is a typed envelope, never a 500.** The three SKU refusals - answer the same `SKU_TAKEN` / `SKU_STOCK_CONFLICT` / `SKU_HELD_STOCK` 409s the product - upsert already answers, carrying the operands an operator has to act on. The - compare-and-set outcomes answer `VARIANT_NOT_FOUND` (404 — an edit is neither a create - nor a resurrection), `STALE_EDIT` (409, with the watermark to reload from) and - `CURRENCY_MISMATCH` (409, carrying the variant's OWN currency, which is null on the +- **Every documented refusal is a typed envelope, never a thrown error.** The three SKU + refusals answer the same `SKU_TAKEN` / `SKU_STOCK_CONFLICT` / `SKU_HELD_STOCK` the + product upsert already answers, carrying the operands an operator has to act on. The + compare-and-set outcomes answer `VARIANT_NOT_FOUND` (an edit is neither a create nor a + resurrection), `STALE_EDIT` (with the watermark to reload from) and + `CURRENCY_MISMATCH` (carrying the variant's OWN currency, which is null on the archetypal first pricing refused against the product's). A missing variant key is the - 400 its error's docblock has been asking for since it was written. + refusal its error's docblock has been asking for since it was written. - **Money is integer minor units plus a currency, and absent is absent.** A declared but unpriced size serializes `null` — never `0`, never a zero-amount object, never "Free". -- **The variants read answers two projections off one route**, the shape - `GET /orders/:orderId` already uses. Anonymously it carries LIVE rows only: a - discontinued size's name and its last price are the shape of a catalogue somebody - stopped selling, and the caller this read exists for — the storefront picker — must not - render them anyway. With `X-Internal-Token` it carries every row, orphans flagged, - which is what makes the deactivate transition observable over HTTP at all. A wrong - token does not unlock and does not say so; it simply gets the public view. Both - projections publish a coarse `inStock` rather than the exact on-hand count, for the - reason the commerce read omits unit cost. -- **The cart add endpoint now resolves its SKU instead of forwarding it.** An add that +- **The variants read answers two projections.** The storefront's carries LIVE rows + only: a discontinued size's name and its last price are the shape of a catalogue + somebody stopped selling, and the caller this read exists for — the storefront picker + — must not render them anyway. The operator's carries every row, orphans flagged, + which is what makes the deactivate transition observable at all. Both projections + publish a coarse `inStock` rather than the exact on-hand count, for the reason the + commerce read omits unit cost. +- **The cart add now resolves its SKU instead of taking it on trust.** An add that names a product must resolve that SKU to a live, priced sellable unit **of that product**. A SKU belonging to another product, to a soft-deleted product, or to a product with no commerce row at all is refused `SKU_MISMATCH`; a product nobody has priced is refused `PRODUCT_NOT_PRICED` at the Add button rather than at the quote. - Rejected, never reinterpreted: the service does not substitute the SKU it thinks the - caller meant. + Rejected, never reinterpreted: nothing substitutes the SKU it guesses the caller + meant. - **A variant's SKU is resolved and then refused, until checkout can price it.** - `createOrderFromCart` and `POST /checkout/quote` both read the snapshot price *and* + `createOrderFromCart` and the checkout quote both read the snapshot price *and* title from the `product_commerce` row named by `productId`, and neither can reach a variant. Letting a size into a cart would therefore sell it at the parent's price under the parent's name — immutably, since an order line's snapshot is never rewritten — and @@ -62,7 +60,7 @@ endpoint's missing check reachable. and it touches no inventory: it runs before the domain's add, so a refused add holds no stock and a same-key retry of it is refused identically rather than half-applied. In the other direction the parity is deliberately not claimed: an accepted add whose unit - is later orphaned, soft-deleted or unpriced answers 409 on a same-key retry instead of + is later orphaned, soft-deleted or unpriced is refused on a same-key retry instead of replaying the stored line, because the catalogue genuinely changed between the two requests. The original line and its hold are untouched. - **A bare add (no `productId`) is unchanged, deliberately.** Resolving a bare SKU means @@ -70,23 +68,24 @@ endpoint's missing check reachable. by-SKU lookup — every read on it is keyed by product. Such a line is also unorderable by construction, since both checkout paths reject a null `productId` before pricing anything. -- **`HttpCommerceClient` mirrors all of it**, with the variant refusals normalized onto - `reason` like every other typed failure it returns. Every operand is nullable and none +- **The plugin's `CommerceClient` mirrors all of it**, with the variant refusals + normalized onto `reason` like every other typed failure it returns. Every operand is + nullable and none has a default: a `liveHolds` of `0` would deny the holds that caused the refusal, and an empty `currentUpdatedAt` would re-submit as a guaranteed second stale edit. Its cart methods gain `PRODUCT_NOT_PRICED` alongside `SKU_MISMATCH`. **Named obligation — the console's variants read is the operator projection, not the -public one.** A Variants tab must send `X-Internal-Token` and render the orphaned state +public one.** A Variants tab must ask for that projection and render the orphaned state distinctly: a tombstone can hold stock and sit on live order lines, and a screen built on the anonymous projection would show a merchant a catalogue with the discontinued sizes silently missing — which is how units get stranded. The exact on-hand count that tab needs is not on either projection here and is owed to the same gated surface. **Named follow-up — a by-SKU resolver on `ProductCommerceStore`.** It is what a bare add -needs to resolve rather than be waved through, and there is a second, sharper motivation -already in the tree: the Postgres cart store's add upserts on `(cart_id, sku)` and its -`doUpdateSet` writes `product_id` from the incoming request, so a bare re-add of a SKU -already on the cart **degrades that line's `product_id` to null** — silently converting a -priced, orderable line into one checkout refuses. Guarding that properly needs the same -lookup. The store is deliberately unchanged here; the defect is tracked as issue #235. +needs to resolve rather than be waved through, and there is a second, sharper motivation: +a cart store that keys its add on `(cart_id, sku)` and rewrites `product_id` from the +incoming request lets a bare re-add of a SKU already on the cart **degrade that line's +`product_id` to null** — silently converting a priced, orderable line into one checkout +refuses. Guarding that properly needs the same lookup. The store is deliberately +unchanged here; the defect is tracked as issue #235. diff --git a/packages/domain/src/testing/order-notes-store-contract.ts b/packages/domain/src/testing/order-notes-store-contract.ts index dd1323ca..e6cbc65a 100644 --- a/packages/domain/src/testing/order-notes-store-contract.ts +++ b/packages/domain/src/testing/order-notes-store-contract.ts @@ -20,8 +20,14 @@ export interface OrderNotesStoreContractOptions { * replay. Append-only — no edit/delete surface exists in this slice. Runs against * the fake first, then each DB dialect. Money-free (a note is a plain merchant * annotation), so there is no concurrency/no-oversell case HERE. - * `@otta-sh/store-postgres` is gone; no pg-backed concurrent-replay race for - * this contract has been re-created in `store-emdash` yet. + * + * The concurrent-replay race that once-only guard needs is adapter-local and + * Postgres-required (a fake or SQLite serializes writes and cannot race), so it + * lives with the adapter rather than in this shared spec: `@otta-sh/store-emdash`'s + * `test/misc-contract.dialects.test.ts` carries "concurrent appends with one + * idempotency_key insert exactly once (no duplicates)" as a `runIf(ctx.canRace)` + * case in the same `describeEachDialect` block that runs this contract. It replaces + * the case the deleted `@otta-sh/store-postgres` suite of the same name held. */ export function orderNotesStoreContract( makeHarness: () => Promise, diff --git a/packages/domain/src/testing/order-timeline-contract.ts b/packages/domain/src/testing/order-timeline-contract.ts index 94940d85..bf4bc5fb 100644 --- a/packages/domain/src/testing/order-timeline-contract.ts +++ b/packages/domain/src/testing/order-timeline-contract.ts @@ -91,9 +91,15 @@ function addNote( * (created / notes / fulfillment / cancellation / reconciliation resolution) into * one chronological view; and a historical order (no events) still yields a * useful partial timeline. Runs against the fake first, then each SQL dialect. - * `@otta-sh/store-postgres` is gone; its Postgres-required - * exactly-one-event-under-race cases (a fake/SQLite can't race) have not been - * re-created against `store-emdash`'s `EmdashOrderStore` yet. + * + * The exactly-one-event-UNDER-CONTENTION case is Postgres-required (a fake or + * SQLite serializes writes and cannot race), so it is adapter-local rather than + * part of this shared spec: `@otta-sh/store-emdash`'s + * `test/order-timeline-contract.dialects.test.ts` carries "concurrent state flips + * write exactly one audit event (no double audit under a race)" as a + * `runIf(ctx.canRace)` case in the same `describeEachDialect` block that runs this + * contract against `EmdashOrderStore`. It replaces the case the deleted + * `@otta-sh/store-postgres` suite of the same name held. */ export function orderTimelineContract( makeHarness: () => Promise, diff --git a/packages/plugin/src/admin/in-process-admin-products-client.ts b/packages/plugin/src/admin/in-process-admin-products-client.ts index 951fdcc0..5d5b74eb 100644 --- a/packages/plugin/src/admin/in-process-admin-products-client.ts +++ b/packages/plugin/src/admin/in-process-admin-products-client.ts @@ -104,7 +104,11 @@ const DEFAULT_LIMIT = 25; /** The stock-movement quantity ceiling (`stockMovementBody`: a positive integer * no greater than this). Far above the shopper-facing cart cap on purpose: this - * is the merchant's own surface. */ + * is the merchant's own surface. + * + * UNASSERTED: nothing yet drives a quantity past this ceiling. Tracked in issue + * #289 together with three sibling bounds in these admin clients that are + * likewise implemented but unpinned. */ const MAX_STOCK_MOVEMENT_QTY = 1_000_000_000; export class InProcessAdminProductsClient implements AdminProductsSurface { diff --git a/packages/plugin/test/admin-route-dispatch.sandbox.test.ts b/packages/plugin/test/admin-route-dispatch.sandbox.test.ts index e21f9030..f8835ee3 100644 --- a/packages/plugin/test/admin-route-dispatch.sandbox.test.ts +++ b/packages/plugin/test/admin-route-dispatch.sandbox.test.ts @@ -117,6 +117,27 @@ describe("admin route dispatch (workerd sandbox)", () => { expect(keys).not.toContain("admin/settings"); }); + // Manifest-level, not behavioral: em-dash's host — not this sandboxed plugin + // — is what enforces `public` by routing an anonymous request only through + // its own public dispatcher (see the route registration's comment in + // plugin.ts); invoking the sandbox directly (as every other test in this + // file does via `sandbox.invokeRoute`) bypasses that host-side gate + // entirely, so it cannot prove auth either way. The manifest flag IS the + // contract the host reads, and it previously had zero coverage anywhere in + // the repo: `service/test/admin-read-gate.test.ts` and + // `service/test/auth.test.ts` pinned the (now-deleted) service's own gate, + // not this one. + test("the admin route is registered non-public — em-dash must NOT treat it as anonymous/public dispatch", () => { + const adminRoute = plugin.routes?.admin; + expect(adminRoute).toBeDefined(); + // `RouteEntry` is `RouteHandler | { handler; public? }` — a bare-function + // entry carries no `public` flag at all, which is itself not the + // non-public admin shape this asserts. + if (typeof adminRoute === "function") + throw new Error("admin route registered as a bare handler, with no `public` flag"); + expect(adminRoute?.public).toBe(false); + }); + test("page_load /reports renders the Reports blocks over real in-process order/inventory data", async () => { const { storage } = await storageBridge(); await seedReportingFixtures(storage); diff --git a/packages/plugin/test/cart-routes.sandbox.test.ts b/packages/plugin/test/cart-routes.sandbox.test.ts index 5af2fb8d..bfd2addb 100644 --- a/packages/plugin/test/cart-routes.sandbox.test.ts +++ b/packages/plugin/test/cart-routes.sandbox.test.ts @@ -392,6 +392,16 @@ describe("storefront cart routes (workerd sandbox)", () => { // `toBeNull()` alone would also pass on an absent key. expect(cart).toHaveProperty("orderId"); expect(cart.orderId).toBeNull(); + // AND NO PRICE ON A LINE, the other half of the same pass-through: a cart + // line snapshots none, and the live price is read from the commerce row at + // display and at checkout. ABSENT rather than null — a nulled key would + // still tell a caller the field is there and invite a probe. `serializeLine` + // is a typed whitelist, so this is belt-and-braces over the type; it stands + // where the deleted service suite's cart-line price guard stood. + for (const line of cart.lines) { + expect(line).not.toHaveProperty("price"); + expect(line).not.toHaveProperty("unitPriceCents"); + } }); test("cart/read maps an unknown cart to the typed CART_NOT_FOUND reason (not a thrown error)", async () => { diff --git a/packages/plugin/test/commerce-client-contract.in-process.test.ts b/packages/plugin/test/commerce-client-contract.in-process.test.ts index b4f73d1d..9a1186e0 100644 --- a/packages/plugin/test/commerce-client-contract.in-process.test.ts +++ b/packages/plugin/test/commerce-client-contract.in-process.test.ts @@ -8,11 +8,13 @@ * bound to a rejecting stub precisely so a method that reached for egress would * fail the suite rather than quietly work. * - * WHY THE SAME CASES, UNCHANGED. The contract is the equivalence proof: the - * cases were lifted out of the HTTP client's own suites so both transports can - * execute them, and the value of that evaporates the moment a tier narrows, - * skips or reorders one. A case that fails here is a composition or an adapter - * defect, never a case to soften. + * WHY THE SAME CASES, UNCHANGED. The contract was the equivalence proof: the + * cases were lifted out of the HTTP client's own suites so both transports could + * execute them, and the value of that would have evaporated the moment a tier + * narrowed, skipped or reordered one. INC-D3b deleted the HTTP tier and this is + * the only one left, but the cases stay exactly as they were, because they are + * the PORT's spec rather than this composition's: a case that fails here is a + * composition or an adapter defect, never a case to soften. * * WHAT IS REAL AND WHAT IS NOT. The document store is real — real databases, * never mocks, because no fake can lose a compare-and-set race — and it is built @@ -27,16 +29,19 @@ * on the host in any form. * * Rows ARE cleared per case here, which is what makes `reset()` a real reset in - * this tier rather than the documented no-op the HTTP tier implements. + * this tier rather than the documented no-op the HTTP tier implemented. * - * THIS TIER DECLARES THE CLOCK HOOK AND NOT THE PAYMENTS ONE, and the other tier - * declares the reverse. Neither is a tier excusing itself: the two gaps are real, - * they are opposite, and each is pinned by a case that names its own gate — so a - * test report says which tier skipped what and why. The clock is offerable HERE + * THIS TIER DECLARES THE CLOCK HOOK AND NOT THE PAYMENTS ONE; the HTTP tier + * declared the reverse, and the two gaps were real and opposite rather than a + * tier excusing itself. Each is still pinned by a case that names its own gate, + * so a test report says what skipped and why. The clock is offerable HERE * because this backend is rebuilt per case, so winding it forward costs nothing * `reset()` cannot put back. The gateways are not offerable here YET, because the - * payment adapters have not moved in-process; when they do, the payments hook - * appears and the shared checkout case starts running with no edit to any case. + * payment adapters have not moved in-process; with the HTTP tier gone the gated + * checkout and refund-ceiling cases therefore skip everywhere, and their + * invariants are held at the DOMAIN layer meanwhile (see the note on + * `CommerceClientTier.payments`). When the adapters land, the payments hook + * appears here and those cases start running with no edit to any case. */ import { email as toEmail } from "@otta-sh/domain"; import { FixedClock } from "@otta-sh/domain/testing"; @@ -62,9 +67,9 @@ import { /** * The in-process tier. `arrange` programs state through the client's own writes - * and through the domain PORTS, exactly as the HTTP tier does — so the two tiers - * seed identically and a difference in a case's outcome can only come from the - * transport under test. + * and through the domain PORTS, exactly as the HTTP tier did — the two tiers + * seeded identically, so a difference in a case's outcome could only have come + * from the transport under test. */ function inProcessTier(): CommerceClientTier { let harness: InProcessCommerceHarness | undefined; @@ -101,7 +106,7 @@ function inProcessTier(): CommerceClientTier { await open?.close(); }, async reset() { - // A REAL reset, unlike the HTTP tier's documented no-op: it empties the + // A REAL reset, unlike the HTTP tier's documented no-op was: it empties the // rows and keeps the schema, which is the only form of reset that keeps the // revision trigger the guarded writes depend on. await harness?.reset(); @@ -116,11 +121,11 @@ function inProcessTier(): CommerceClientTier { * would let its slice pass against nothing, answering "no revenue" where the * honest answer would have been "not wired yet". * - * NO TOKENS ARE THREADED, unlike the HTTP tier, and that is the design rather - * than a gap: `X-Internal-Token` / `X-Service-Token` authenticate a caller TO - * THE SERVICE, and there is no service here. EmDash's own admin auth and CSRF - * gate the console routes (ADR-0014 D3), so the gated tier's auth-rejection - * cases are transport cases and stay in the HTTP tier's own file. + * NO TOKENS ARE THREADED, unlike the HTTP tier, and that was the design rather + * than a gap: `X-Internal-Token` / `X-Service-Token` authenticated a caller TO + * THE SERVICE, and there is no service any more. EmDash's own admin auth and + * CSRF gate the console routes (ADR-0014 D3); the auth-rejection cases were + * transport cases and went with the HTTP tier's own file. */ async makeAdminClients(): Promise { const ctx = harnessOrThrow().ctx; @@ -169,8 +174,8 @@ function inProcessTier(): CommerceClientTier { * `requestLoginLink` for one reason — this transport dispatches no mail yet, * and the emitted token is part of no reply, so there is no message to * capture and this is the only way to hold a token a shopper would have - * received. The other tier, which does dispatch, captures the mail instead. - * The redemption is the client's own on both, which is the half the cases + * received. The HTTP tier, which did dispatch, captured the mail instead. + * The redemption was the client's own on both, which is the half the cases * are actually about. */ async session(email) { @@ -217,7 +222,7 @@ describe("commerceClientContract over InProcessCommerceClient", () => { /** The admin slice gets its OWN tier instance — its own database and its own * `reset()` — so the console cases and the storefront cases cannot seed over - * each other, exactly as the HTTP file stands a second service for its admin + * each other, exactly as the HTTP file stood a second service for its admin * slice. */ const admin = inProcessTier(); @@ -232,13 +237,14 @@ describe("commerceClientContract over the in-process admin clients", () => { /** * WHAT STAYS IN THIS FILE, AND WHY EACH ONE CANNOT BE SHARED. * - * Most of what this file used to assert alone now lives in the shared contract and - * runs on both transports: the watermark, variant-key, title, zero-price and - * batch-cap refusals, and every identity case. Each moved because the OTHER - * transport can be held to it too — a bound proven on one implementation is not - * evidence about the port. + * Most of what this file used to assert alone now lives in the shared contract: + * the watermark, variant-key, title, zero-price and batch-cap refusals, and every + * identity case. Each moved because the OTHER transport could be held to it too — + * a bound proven on one implementation is not evidence about the port — and each + * stays there now that transport is gone, because the contract is the port's spec + * and not one tier's file. * - * What is left below is what genuinely does not survive the move, with the reason + * What is left below is what genuinely did not survive the move, with the reason * recorded per block rather than left to be rediscovered. None of it is a case that * was merely inconvenient to share. */ @@ -262,17 +268,17 @@ async function expectRefusal(call: Promise, field: string): Promise { let harness: InProcessCommerceHarness; @@ -290,10 +296,10 @@ describe("in-process commerce refuses malformed shopper input before any store c }); // THE EMPTY VARIANT KEY, here rather than in the shared contract. The shared - // case asserts a whitespace key on all three writers, because an EMPTY one makes - // the other transport build a path with an empty segment and miss its route - // altogether — so a shared empty-key case would assert a route miss on that tier - // and the bound on this one. The bound itself still deserves an assertion, and + // case asserts a whitespace key on all three writers, because an EMPTY one made + // the HTTP transport build a path with an empty segment and miss its route + // altogether — so a shared empty-key case would have asserted a route miss on + // that tier and the bound on this one. The bound deserves an assertion, and // this is the tier that checks it before any call, so it is asserted here. test("an empty variant key is refused by the bound, not by a missing route", async () => { await expectRefusal( @@ -324,10 +330,10 @@ describe("in-process commerce refuses malformed shopper input before any store c /** * THE TWO GAPS, PINNED. * - * Both are deliberate, both are invisible unless a test says so, and NEITHER can be - * a shared case — because in each the other transport does the very thing this one - * does not, so there is no single outcome for a shared case to assert. They are the - * two places the transports genuinely differ today, recorded here rather than only + * Both are deliberate, both are invisible unless a test says so, and NEITHER could + * be a shared case — because in each the HTTP transport did the very thing this one + * does not, so there was no single outcome for a shared case to assert. They were + * the two places the transports genuinely differed, recorded here rather than only * in prose so the difference has a test standing over it. Each fails the day the * missing piece lands, which is exactly when someone should come back and delete it. */ @@ -343,10 +349,11 @@ describe("in-process commerce: what is deliberately not wired yet", () => { await harness.close(); }); - // THE HEADLINE DIFFERENCE between the tiers, seen from this side: the other one - // composes a gateway and checks out successfully, which is why the shared - // checkout-replay case runs there and skips here. What this case adds — and the - // shared one cannot — is that the refusal damages nothing. + // THE HEADLINE DIFFERENCE between the tiers, seen from this side: the HTTP one + // composed a gateway and checked out successfully, which is why the shared + // checkout-replay case ran there and skips here — and, now that it is gone, + // skips everywhere. What this case adds — and the shared one cannot — is that + // the refusal damages nothing. test("checkout has NO payment gateway: a real cart with a held line survives the refusal intact", async () => { // A genuine cart, priced, with stock held for its line — so the refusal is // asserted against the state it must not damage rather than against nothing. @@ -386,9 +393,9 @@ describe("in-process commerce: what is deliberately not wired yet", () => { }); }); - // NOT SHAREABLE for the mirror-image reason: the other transport DOES dispatch the - // login mail — the shared identity cases mint their sessions by capturing it — so - // "no mail left the process" is true here and false there, by design on both. + // NOT SHAREABLE for the mirror-image reason: the HTTP transport DID dispatch the + // login mail — the shared identity cases minted their sessions by capturing it — + // so "no mail left the process" was true here and false there, by design on both. test("a login link records ONE challenge and dispatches NO mail", async () => { const challenges = harness.ctx.storage?.["login_challenges"]; if (challenges === undefined) @@ -411,18 +418,18 @@ describe("in-process commerce: what is deliberately not wired yet", () => { * * ADR-0019 §6 sets the FLOOR every dialect must meet — an id PREFIX, a folded * buyer-ref PREFIX, or an EXACT folded line sku — and says plainly that a dialect - * may answer MORE. Postgres does: it plans the buyer-ref half as an unanchored - * `like '%q%'`, so a fragment from the MIDDLE of an address finds the order there. + * may answer MORE. Postgres did: it planned the buyer-ref half as an unanchored + * `like '%q%'`, so a fragment from the MIDDLE of an address found the order there. * The document store behind this tier indexes a folded prefix key and cannot, and * that is a ratified divergence (2026-09-13) rather than a defect: a prefix is the - * floor both tiers meet, and the superset is sanctioned where the dialect offers - * it for free. + * floor every dialect meets, and the superset is sanctioned where the dialect + * offers it for free. * - * IT CANNOT BE A SHARED CASE, for the same reason none of the others can: the two - * tiers produce OPPOSITE answers to the identical call, so a shared case would - * have to assert one of them loosely enough to accept the other. The shared slice - * therefore asserts the floor and NEVER a negative, and each tier pins its own - * half here — this file the miss, the HTTP file the hit. Should the document store + * IT COULD NOT BE A SHARED CASE, for the same reason none of the others could: the + * two tiers produced OPPOSITE answers to the identical call, so a shared case would + * have had to assert one of them loosely enough to accept the other. The shared + * slice therefore asserts the floor and NEVER a negative, and each tier pinned its + * own half — this file the miss, the HTTP file the hit. Should the document store * ever gain substring search, this case fails and is deleted, which is exactly the * moment someone should be told. */ @@ -454,7 +461,7 @@ describe("in-process admin orders: search is PREFIX-only, by dialect (ADR-0019 ]); // The superset, absent: "guerite@" is a genuine fragment of the very same - // buyer ref, and the HTTP tier's Postgres dialect finds it. Here it does not, + // buyer ref, and the HTTP tier's Postgres dialect found it. Here it does not, // and the count agrees with the page rather than describing a set the rows do // not. const midString = await orders.listOrders({ search: "guerite@" }); diff --git a/packages/plugin/test/contracts/commerce-client-contract.ts b/packages/plugin/test/contracts/commerce-client-contract.ts index b1ce98d3..8c065154 100644 --- a/packages/plugin/test/contracts/commerce-client-contract.ts +++ b/packages/plugin/test/contracts/commerce-client-contract.ts @@ -302,7 +302,13 @@ export interface CommerceClientTier { * subject is an elapsed deadline skip, saying so in their own names. */ readonly clock?: CommerceClientTierClock; /** OPTIONAL: see {@link CommerceClientTierPayments}. Absent ⇒ the cases whose - * subject is a minted order skip, saying so in their own names. */ + * subject is a minted order skip, saying so in their own names. NO TIER + * DECLARES IT since the HTTP tier was deleted, so those three cases (the + * checkout replay, the lapsed-hold checkout, the refund ceiling) now skip + * everywhere: a composition-layer gap, not an unguarded invariant — each is + * covered at the domain layer, in `orders/create-order-from-cart.test.ts` and + * `refund-order-contract.ts`. They start running again the day the payment + * adapters move in-process, with no edit to any case. */ readonly payments?: CommerceClientTierPayments; arrange: CommerceClientTierArrange; } @@ -1013,11 +1019,11 @@ export function storefrontCommerceClientContract(tier: CommerceClientTier): void // TWO CASES ARE GATED, in opposite directions, and each names its reason // in its own title so a test report says why rather than a comment: // - the elapsed-deadline case needs `tier.clock`, which a shared, - // long-lived backend cannot offer; + // long-lived backend could not offer; // - the minted-order case needs `tier.payments`, which the transport // that has not yet received the payment adapters cannot offer. // Neither is a weakened case. Each runs in full where it can run at all, - // and starts running on the other tier the day that tier grows the hook. + // and starts running the day the surviving tier grows the hook it lacks. // ── identity: the session is the only credential ─────────────────── // @@ -1871,10 +1877,11 @@ export function adminOrdersProductsClientContract(tier: CommerceClientTier): voi * that has no orders surface fails by name here rather than running the * twelve methods' cases against a stub that would agree with anything. */ let orders: OrdersClientSurface; - /** THE STOREFRONT CLIENT, for the two states the admin surface can read but - * cannot produce: a soft-deleted row (`softDeleteProductCommerce`) and a sku - * under a live cart hold (`addCartLine`). Both are admin-facing outcomes - * reached only through a shopper-facing write, and both tiers have it. */ + /** THE STOREFRONT CLIENT, for the states one surface can reach and the other + * cannot: a soft-deleted row (`softDeleteProductCommerce`) and a sku under a + * live cart hold (`addCartLine`) are admin-facing outcomes reached only + * through a shopper-facing write; and `getPublicOrder` is the guest read of + * an order only the console can have fulfilled or cancelled. */ let storefront: CommerceClient; const makeAdminClients = assertAdminClients(tier); @@ -2272,6 +2279,105 @@ export function adminOrdersProductsClientContract(tier: CommerceClientTier): voi ).toMatchObject({ ok: false, status: 400 }); }); + // ── the guest's read of an order the console has acted on ───────── + // + // THE STAFF SIDE OF THE PUBLIC WHITELIST. The storefront slice pins the + // TOP-LEVEL redaction on a pending order (`getPublicOrder` omits + // `buyerRef`/`customerId`/`shippingAddress`); what it cannot reach is the + // two sub-objects only an admin write can create. Both are TRIMMED, not + // passed through: a guest reading their own order may see where the parcel + // is and why it was cancelled, never who in the shop touched it, when they + // did, or what free text they typed. The fields are ABSENT rather than + // nulled, so a caller cannot tell "redacted" from "never there". These two + // cases stand where the deleted service suite's public-order redaction test + // stood; the type guards them, and this proves the composition honours it. + + test("a guest's read of a SHIPPED order trims fulfillment to carrier and tracking, never the staff witness", async () => { + await tier.arrange.order({ orderId: "adm-o-pubful", buyerRef: "pubful@example.test" }); + // Fulfillment IS the `processing → shipped` flip, so the order has to be + // walked there first — a pending one is NOT_FULFILLABLE. + for (const [to, key] of [ + ["paid", "adm-o-pubful-t1"], + ["processing", "adm-o-pubful-t2"], + ] as const) { + expect(await orders.transitionOrder("adm-o-pubful", to, { idempotencyKey: key })).toEqual({ + ok: true, + transitioned: true, + }); + } + expect( + await orders.recordFulfillment( + "adm-o-pubful", + { + carrier: "UPS", + trackingNumber: "1Z-ADM-O-PUBFUL", + trackingUrl: "https://tracking.example.test/1Z-ADM-O-PUBFUL", + recordedBy: "ops@example.test", + }, + { idempotencyKey: "adm-o-pubful-f1" }, + ), + ).toMatchObject({ ok: true }); + + const read = await storefront.getPublicOrder("adm-o-pubful"); + expect(read.ok).toBe(true); + if (!read.ok) throw new Error("unreachable"); + const fulfillment = read.order.fulfillment; + expect(fulfillment).toMatchObject({ + carrier: "UPS", + trackingNumber: "1Z-ADM-O-PUBFUL", + trackingUrl: "https://tracking.example.test/1Z-ADM-O-PUBFUL", + }); + expect(typeof fulfillment?.shippedAt).toBe("string"); + for (const field of ["recordedBy", "recordedAt"]) { + expect(fulfillment, `${field} must not reach a guest`).not.toHaveProperty(field); + } + // And the top-level whitelist still holds on an order that has moved. + for (const field of [ + "buyerRef", + "customerId", + "shippingAddress", + "reconciliationFlag", + "reconciliationResolution", + ]) { + expect(read.order, `${field} must not reach a guest`).not.toHaveProperty(field); + } + // The console's own read is the UNTRIMMED one — the trim is the public + // projection's, not a field the write failed to record. + expect((await orders.getOrder("adm-o-pubful"))?.order.fulfillment).toMatchObject({ + recordedBy: "ops@example.test", + }); + }); + + test("a guest's read of a CANCELLED order keeps the reason and drops the detail and the canceller", async () => { + await tier.arrange.order({ orderId: "adm-o-pubcan", buyerRef: "pubcan@example.test" }); + expect( + await orders.cancelOrder( + "adm-o-pubcan", + { + reason: "customer_request", + detail: "buyer called to cancel", + cancelledBy: "ops@example.test", + }, + { idempotencyKey: "adm-o-pubcan-1" }, + ), + ).toEqual({ ok: true, cancelled: true }); + + const read = await storefront.getPublicOrder("adm-o-pubcan"); + expect(read.ok).toBe(true); + if (!read.ok) throw new Error("unreachable"); + const cancellation = read.order.cancellation; + expect(cancellation).toMatchObject({ reason: "customer_request" }); + expect(typeof cancellation?.cancelledAt).toBe("string"); + for (const field of ["detail", "cancelledBy"]) { + expect(cancellation, `${field} must not reach a guest`).not.toHaveProperty(field); + } + // Recorded in full on the console side, so the absence above is the trim. + expect((await orders.getOrder("adm-o-pubcan"))?.order.cancellation).toMatchObject({ + detail: "buyer called to cancel", + cancelledBy: "ops@example.test", + }); + }); + // ── getCustomerContext ──────────────────────────────────────────── test("the customer-context panel reads a GUEST order honestly: no account, no book, no sessions", async () => { diff --git a/packages/store-emdash/test/misc-contract.dialects.test.ts b/packages/store-emdash/test/misc-contract.dialects.test.ts index 67ee0629..7eab31d6 100644 --- a/packages/store-emdash/test/misc-contract.dialects.test.ts +++ b/packages/store-emdash/test/misc-contract.dialects.test.ts @@ -9,15 +9,18 @@ * `grant_idempotency_key`, a settings mutation ledger without the transaction that * bracketed it, and note-once without a UNIQUE `idempotency_key`. */ +import { idempotencyKey, orderId } from "@otta-sh/domain"; import { entitlementStoreContract, orderNotesStoreContract, settingsStoreContract, } from "@otta-sh/domain/testing"; +import { expect, test } from "vitest"; import { describeEachDialect } from "./describe-each-dialect.js"; import { MISC_LAYOUT } from "./misc-collections.js"; import { makeEntitlementHarness, + makeMiscHarness, makeOrderNotesHarness, makeSettingsHarness, } from "./misc-harness.js"; @@ -39,4 +42,44 @@ describeEachDialect("EmdashOrderNotesStore", (ctx) => { orderNotesStoreContract(async () => makeOrderNotesHarness(bound.storage), { dialect: ctx.dialect, }); + + // Idempotency under concurrency (Postgres-required, like the no-oversell race), + // carried over from the deleted `@otta-sh/store-postgres` suite of the same name: + // N concurrent appends carrying the SAME idempotency key must leave EXACTLY ONE + // note. The SQL's guard was an `idempotency_key` UNIQUE plus `ON CONFLICT DO + // NOTHING`; here the key IS the document id, so the once-only is the storage + // table's primary key and `append` is one create-if-absent — the loser's + // compare-and-set is refused, it retries, reads the committed note back and + // returns it with `appended: false`. `better-sqlite3` serializes writes in one + // process, so this is a real race only on Postgres. + test.runIf(ctx.canRace)( + "concurrent appends with one idempotency_key insert exactly once (no duplicates)", + async () => { + const h = makeMiscHarness(bound.storage); + const key = idempotencyKey("race-key"); + const N = 8; + const results = await Promise.all( + Array.from({ length: N }, () => + h.orderNotesStore.append({ + orderId: orderId("ord-race"), + author: "concurrent", + body: "exactly one", + idempotencyKey: key, + }), + ), + ); + // Exactly one caller performed the insert; the rest observed the replay. + expect(results.filter((r) => r.appended)).toHaveLength(1); + // All callers agree on the one stored note id. + const ids = new Set(results.map((r) => r.note.id)); + expect(ids.size).toBe(1); + // And the collection holds a single note for the order — through the port, + // and as documents, so a second note under a different id would be caught. + const notes = await h.orderNotesStore.listForOrder(orderId("ord-race")); + expect(notes).toHaveLength(1); + expect(notes[0]?.body).toBe("exactly one"); + expect(await h.notes.count()).toBe(1); + }, + 120_000, + ); }); diff --git a/packages/store-emdash/test/order-timeline-contract.dialects.test.ts b/packages/store-emdash/test/order-timeline-contract.dialects.test.ts index a4b8e0df..46899a03 100644 --- a/packages/store-emdash/test/order-timeline-contract.dialects.test.ts +++ b/packages/store-emdash/test/order-timeline-contract.dialects.test.ts @@ -7,16 +7,89 @@ * from `InMemoryOrderNotesStore` (the notes adapter is INC-B8's), and the fulfillment, * cancellation and reconciliation artifacts from the fields the guarded flips wrote. * `countingIds` makes the same-instant `(at, id)` tie-break append order. + * + * Plus the Postgres-only exactly-one-audit-event race the deleted + * `@otta-sh/store-postgres` suite of the same name carried, re-pointed at + * `EmdashOrderStore`. It is the audit half of the transition invariant, and it is + * Postgres-required for the same reason every other race here is: better-sqlite3 + * serializes writes in one process, so it can verify the write's SHAPE and never + * the contention. */ +import { + cents, + currency, + idempotencyKey, + orderId, + productId, + reservationId, + sku, + type CreateOrderInput, +} from "@otta-sh/domain"; import { orderTimelineContract } from "@otta-sh/domain/testing"; +import { expect, test } from "vitest"; import { describeEachDialect } from "./describe-each-dialect.js"; import { ORDER_LAYOUT } from "./order-collections.js"; import { makeOrderHarness, orderTimelineHarness } from "./order-harness.js"; +const USD = currency("USD"); + +/** One pending, physically-reserved order — the SQL suite's own seed, unchanged. */ +function pendingInput(id: string, key: string): CreateOrderInput { + return { + orderId: orderId(id), + cartId: "cart-1", + currency: USD, + idempotencyKey: idempotencyKey(key), + holdExpiresAt: "2026-07-10T00:15:00.000Z", + buyerRef: "buyer@example.com", + paymentMethod: "stripe", + lines: [ + { + productId: productId("p1"), + sku: sku("SKU-1"), + title: "Widget", + unitPrice: cents(500), + currency: USD, + quantity: 1, + fulfillmentKind: "physical", + reservationId: reservationId("res-1"), + }, + ], + totals: { subtotal: cents(500), total: cents(500), currency: USD }, + }; +} + describeEachDialect("EmdashOrderStore timeline", (ctx) => { const bound = ctx.useStorage(ORDER_LAYOUT); orderTimelineContract( async () => orderTimelineHarness(makeOrderHarness(bound.storage, { countingIds: true })), { dialect: ctx.dialect }, ); + + // Concurrency (Postgres-required, like the no-oversell race): N concurrent + // markPaid on the SAME pending order flip it EXACTLY ONCE — the guarded + // `state === fromState` check plus the pinned compare-and-set lets one caller + // win. The state-change audit is appended INSIDE that one guarded write (`#flipped` + // composes the new state and the event together), so EXACTLY ONE `state_change` + // event is written — a replay or a lost race is a 0-row flip and records none. + // This is the audit analogue of the outbox's first-wins `(orderId, toState)`. + test.runIf(ctx.canRace)( + "concurrent state flips write exactly one audit event (no double audit under a race)", + async () => { + const h = makeOrderHarness(bound.storage, { countingIds: true }); + const id = orderId("ord-audit-race"); + await h.store.createFromCart(pendingInput("ord-audit-race", "key-audit-race")); + + const N = 12; + const results = await Promise.all(Array.from({ length: N }, () => h.store.markPaid(id))); + // Exactly one caller won the guarded flip; the rest are benign 0-row misses. + expect(results.filter((won) => won)).toHaveLength(1); + + const events = await h.store.listEventsForOrder(id); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ fromState: "pending", toState: "paid" }); + expect((await h.store.getById(id))?.state).toBe("paid"); + }, + 120_000, + ); }); diff --git a/sites/staging/src/lib/cart-view.ts b/sites/staging/src/lib/cart-view.ts index 4ca988e6..845415ba 100644 --- a/sites/staging/src/lib/cart-view.ts +++ b/sites/staging/src/lib/cart-view.ts @@ -115,12 +115,12 @@ export function isCartTerminal(state: string | undefined): boolean { * The companion to `isCartTerminal`'s deliberate tolerance: the page renders an * unrecognised state as a live cart, and logs that it did. Without this, a * third state would arrive as a permanent, silent mis-render. The other half of - * that worry — a `serializeCart` that quietly stopped emitting the field — - * was meant to be pinned at the producer instead (#136), but the test that did - * that pinning lived in the now-deleted `@otta-sh/service` - * (`carts.http.contract.test.ts`). No equivalent presence guard for `state` is - * confirmed to exist against `InProcessCommerceClient`'s `serializeCart` today, - * so this log is this field's only backstop again. + * that worry — a `serializeCart` that quietly stopped emitting the field — is + * pinned at the producer by the type itself: `InProcessCommerceClient`'s + * `serializeCart` is annotated `: CartWire`, whose `state` is a required field, + * so dropping it fails to compile. Read-back assertions on `state` in the + * plugin's sandbox and client-contract suites corroborate that at runtime. This + * log guards the half the type cannot: a third state VALUE. */ export function isKnownCartState(state: string | undefined): boolean { return state === "active" || state === "checked_out"; diff --git a/sites/staging/test/cart-page.test.ts b/sites/staging/test/cart-page.test.ts index fb0ed86c..b716e1d8 100644 --- a/sites/staging/test/cart-page.test.ts +++ b/sites/staging/test/cart-page.test.ts @@ -564,14 +564,15 @@ describe("a checked-out cart is rendered as terminal, and never as a paid one", test("the wire type really does carry the state this page now reads", () => { // HONEST SCOPE: this pins the `CartWire` TypeScript DECLARATION, not what - // the plugin emits. `serializeCart` (`in-process-commerce-client.ts`) - // dropping the field would compile perfectly and arrive here as - // `undefined` — which the narrow fence above then renders as a live cart. - // #136 is closed by the `orderId` presence guard in - // `packages/plugin/test/cart-routes.sandbox.test.ts` (see the test below); - // no equivalent presence guard for `state` specifically is confirmed to - // exist there today. The `console.warn` pinned below is the runtime - // backstop either way. + // the plugin emits — but for this field the declaration IS the guard at + // the producer. `serializeCart` (`in-process-commerce-client.ts`) is + // annotated `: CartWire`, and `CartWire.state` is required, not optional, + // so a `serializeCart` that stopped emitting the field fails to compile + // rather than arriving here as `undefined`. Runtime coverage corroborates + // it: `packages/plugin/test/storefront-checkout.sandbox.test.ts` and the + // shared client contract both assert a read-back cart's `state`. What the + // type cannot catch is a THIRD state value — that is what the + // `console.warn` pinned below is the backstop for. const cart: CartWire = { cartId: "cart_1", state: "checked_out", From fd80ab7c999d29d83acb70e762e1c959aee94916 Mon Sep 17 00:00:00 2001 From: Vedanshu Date: Sun, 20 Sep 2026 13:56:44 +0000 Subject: [PATCH 5/5] [Test] Make the two new concurrency cases actually race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both cases were mutation-tested and neither died. Breaking the loser path in `EmdashOrderNotesStore.append` (return `appended: true` unconditionally) and in `EmdashOrderStore`'s guarded flip (report a won flip on a refused compare-and-set) left both suites GREEN. The CAS logic is correct; the tests were not reaching it. **Why.** `Promise.all` over N store calls is not a race here. `pg.Pool` opens connections lazily, so the first caller takes the one warm connection and completes its whole read-modify-write while its peers are still finishing a TCP connect. Instrumented on the note-append shape: all 8 callers entered within 3 ms, the winner's pre-read returned at +15 ms and its create-if-absent committed at +28 ms, and the other 7 pre-reads returned at +47 ms or later — every one of them finding the committed note. So all 7 took the replay branch and no two callers ever held the same revision. The `markPaid` case degenerates the same way, one step earlier: the peers read `state: "paid"` and refuse at the `doc.state !== fromState` guard without issuing a compare-and-set at all. **The fix** is a barrier, not more callers. `barrierCall` joins the existing fault-injection decorators: it holds the first N matching writes until every one has arrived, then releases the crowd into the real repository at once. Arrival at the barrier is itself the proof of contention — a caller only gets there after its own read decided to write — so the two cases now assert `barrier.arrived()` alongside their outcome. The barrier is one-shot, so the retry each loser performs passes straight through and the crowd cannot deadlock. Both mutations were re-applied against the barriered tests: 3/3 runs RED, with 8 of 8 appends and 12 of 12 flips claiming a win. Restored, 3/3 runs GREEN. Two changesets from the same review round: `admin-wire-completeness` restates the operator-facing warning that died with the REST service — the three admin lists still issue their count concurrently with the page read, so each request peaks at two host connections, now on the in-process path. `entitlements-check-auth` gets back the concrete false→true behaviour change that justifies its `minor` on `@otta-sh/domain`. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CQbJYWWm8tf8owshm7XRp8 --- .changeset/admin-wire-completeness.md | 6 ++ .changeset/entitlements-check-auth.md | 4 +- .../test/helpers/fault-injection.ts | 77 +++++++++++++++++++ .../test/misc-contract.dialects.test.ts | 23 +++++- .../order-timeline-contract.dialects.test.ts | 28 ++++++- 5 files changed, 134 insertions(+), 4 deletions(-) diff --git a/.changeset/admin-wire-completeness.md b/.changeset/admin-wire-completeness.md index ec7f4d9f..b14864a2 100644 --- a/.changeset/admin-wire-completeness.md +++ b/.changeset/admin-wire-completeness.md @@ -52,3 +52,9 @@ packages. no total is available and for a screen that narrowed its own fetched page (the products list's "Low stock only"), and a `total` that understates the rendered rows, or is not a non-negative safe integer, falls back rather than lies. + **Note for operators** (the warning the retired REST service carried, restated + for the surviving in-process path — it is still true): the three admin lists + (orders, products, coupons) now issue the count CONCURRENTLY with the page + read, so each of those requests holds TWO host database connections at its peak + rather than one. The queries are short, but a deployment that has tuned its host + connection pool down should account for it. diff --git a/.changeset/entitlements-check-auth.md b/.changeset/entitlements-check-auth.md index ba1a769b..deefe361 100644 --- a/.changeset/entitlements-check-auth.md +++ b/.changeset/entitlements-check-auth.md @@ -7,7 +7,9 @@ Authenticate the entitlement check — close the unauthenticated email existence - `@otta-sh/domain`: **contract tightening.** `EntitlementStore.check` now requires CASE-INSENSITIVE `buyerRef` matching (email semantics), enforced by the shared contract suite - that every downstream adapter must pass — hence a minor. + that every downstream adapter must pass. It is a **matching-semantics change**, not a + clarification: a `check` call that previously returned `false` for a `buyerRef` differing from + the stored one only in case now returns `true` — hence a minor rather than a patch. - `@otta-sh/plugin`: **WIRE BREAK.** Checking an entitlement by buyer email is no longer an anonymous probe: the `entitlements/download` route input drops `buyerRef` in favor of `sessionToken`, and the commerce client's `checkEntitlement` takes an optional `sessionToken` diff --git a/packages/store-emdash/test/helpers/fault-injection.ts b/packages/store-emdash/test/helpers/fault-injection.ts index a04fa318..bc94eabb 100644 --- a/packages/store-emdash/test/helpers/fault-injection.ts +++ b/packages/store-emdash/test/helpers/fault-injection.ts @@ -288,6 +288,83 @@ export function parkRead( }; } +/** A crowd held at one write, and the handles to observe the barrier. */ +export interface BarrieredCollection { + readonly collection: StorageCollection; + /** Resolves once `count` callers have arrived and been released together. */ + readonly opened: Promise; + /** How many callers have arrived at the barrier so far. */ + arrived(): number; +} + +/** + * Hold the first `count` matching writes until ALL of them have arrived, then let + * the whole crowd go at once. Every call is then performed for real. + * + * **This is what makes a `Promise.all` of N store calls an actual race.** Without + * it, N concurrent callers are not concurrent where it matters: `pg.Pool` opens + * its connections lazily, so the first caller gets the one warm connection and + * completes its read AND its write while its peers are still finishing a TCP + * connect. Measured on the note-append shape, the winner's create-if-absent + * committed ~20 ms before any peer's pre-read returned — so every peer read the + * committed document and took the replay branch, and no two callers ever reached + * `compareAndSet` on the same revision. A suite like that passes on an + * implementation whose loser path is broken, because the loser path is never + * entered. Rejecting the call graph's OWN scheduling and pinning the collision + * here is the only way the assertion means what it says. + * + * The barrier is one-shot: once open it stays open, so the retry each loser is + * about to perform passes straight through and the crowd cannot deadlock. A + * caller that never reaches the write (a guard refused it earlier) means the + * barrier never fills and the case times out — which is the honest failure, since + * such a run would not have been a race either. Assert `arrived()` to pin it. + */ +export function barrierCall( + raw: StorageCollection, + match: CallMatcher, + count: number, +): BarrieredCollection { + let waiting = 0; + let open = false; + let openGate: (() => void) | undefined; + const opened = new Promise((resolve) => { + openGate = resolve; + }); + + const hold = async (call: StorageCall): Promise => { + if (open || !match(call)) return; + waiting++; + if (waiting >= count) { + open = true; + openGate?.(); + } + await opened; + }; + + return { + collection: delegatingCollection(raw, { + async compareAndSet(id, expectedRevision, data) { + await hold({ method: "compareAndSet", id, expectedRevision }); + return raw.compareAndSet(id, expectedRevision, data); + }, + async put(id, data) { + await hold({ method: "put", id }); + return raw.put(id, data); + }, + async compareAndDelete(id, revision) { + await hold({ method: "compareAndDelete", id }); + return raw.compareAndDelete(id, revision); + }, + async updateIf(id, args) { + await hold({ method: "updateIf", id }); + return raw.updateIf(id, args); + }, + }), + opened, + arrived: () => waiting, + }; +} + /** Where the throw goes relative to the real call. */ export type FailMode = /** Perform the real call, THEN throw: "the process died after this write". */ diff --git a/packages/store-emdash/test/misc-contract.dialects.test.ts b/packages/store-emdash/test/misc-contract.dialects.test.ts index 7eab31d6..a3e7787e 100644 --- a/packages/store-emdash/test/misc-contract.dialects.test.ts +++ b/packages/store-emdash/test/misc-contract.dialects.test.ts @@ -16,7 +16,9 @@ import { settingsStoreContract, } from "@otta-sh/domain/testing"; import { expect, test } from "vitest"; +import { collectionOf, ORDER_NOTES_COLLECTION, type OrderNoteDoc } from "../src/index.js"; import { describeEachDialect } from "./describe-each-dialect.js"; +import { barrierCall, isClaimWrite, onId, withCollection } from "./helpers/fault-injection.js"; import { MISC_LAYOUT } from "./misc-collections.js"; import { makeEntitlementHarness, @@ -52,12 +54,29 @@ describeEachDialect("EmdashOrderNotesStore", (ctx) => { // compare-and-set is refused, it retries, reads the committed note back and // returns it with `appended: false`. `better-sqlite3` serializes writes in one // process, so this is a real race only on Postgres. + // + // The COLLISION is pinned by a barrier rather than left to `Promise.all`, and + // that is load-bearing: `pg.Pool` opens connections lazily, so the first caller + // gets the warm one and its create-if-absent commits ~20 ms before any peer's + // pre-read even returns. Every peer then finds the committed note and takes the + // replay branch, so nothing ever reaches the loser path this case exists to + // prove — the version of this test without the barrier stayed GREEN with the + // loser path deleted from the store. `barrierCall` holds all N create-if-absent + // writes until every one has arrived (which means every one read no note), then + // releases them into the real repository at once. See `helpers/fault-injection.ts`. test.runIf(ctx.canRace)( "concurrent appends with one idempotency_key insert exactly once (no duplicates)", async () => { - const h = makeMiscHarness(bound.storage); const key = idempotencyKey("race-key"); const N = 8; + const barrier = barrierCall( + collectionOf(bound.storage, ORDER_NOTES_COLLECTION), + onId(key, isClaimWrite), + N, + ); + const h = makeMiscHarness(bound.storage, { + storageForStore: withCollection(bound.storage, ORDER_NOTES_COLLECTION, barrier.collection), + }); const results = await Promise.all( Array.from({ length: N }, () => h.orderNotesStore.append({ @@ -68,6 +87,8 @@ describeEachDialect("EmdashOrderNotesStore", (ctx) => { }), ), ); + // All N really did contend: each one read no note and then tried to create it. + expect(barrier.arrived()).toBe(N); // Exactly one caller performed the insert; the rest observed the replay. expect(results.filter((r) => r.appended)).toHaveLength(1); // All callers agree on the one stored note id. diff --git a/packages/store-emdash/test/order-timeline-contract.dialects.test.ts b/packages/store-emdash/test/order-timeline-contract.dialects.test.ts index 46899a03..1013c4b9 100644 --- a/packages/store-emdash/test/order-timeline-contract.dialects.test.ts +++ b/packages/store-emdash/test/order-timeline-contract.dialects.test.ts @@ -27,7 +27,9 @@ import { } from "@otta-sh/domain"; import { orderTimelineContract } from "@otta-sh/domain/testing"; import { expect, test } from "vitest"; +import { collectionOf, ORDERS_COLLECTION, type OrderDoc } from "../src/index.js"; import { describeEachDialect } from "./describe-each-dialect.js"; +import { barrierCall, isUpdateWrite, onId, withCollection } from "./helpers/fault-injection.js"; import { ORDER_LAYOUT } from "./order-collections.js"; import { makeOrderHarness, orderTimelineHarness } from "./order-harness.js"; @@ -73,15 +75,37 @@ describeEachDialect("EmdashOrderStore timeline", (ctx) => { // composes the new state and the event together), so EXACTLY ONE `state_change` // event is written — a replay or a lost race is a 0-row flip and records none. // This is the audit analogue of the outbox's first-wins `(orderId, toState)`. + // + // The COLLISION is pinned by a barrier rather than left to `Promise.all`. Lazy + // `pg.Pool` connection setup lets the first caller finish its whole + // read-modify-write before its peers' pinning reads return; every peer then sees + // `state === "paid"`, refuses at the `doc.state !== fromState` guard, and never + // reaches a compare-and-set at all — so the version of this test without the + // barrier stayed GREEN with the store's losing compare-and-set made to report a + // win. `barrierCall` holds all N read-modify-write flips on the order document + // until every one has arrived (which means every one pinned the SAME `pending` + // revision), then releases them into the real repository at once, where exactly + // one revision check can succeed. `createFromCart`'s own write is a + // create-if-absent, so `isUpdateWrite` leaves the seed alone. test.runIf(ctx.canRace)( "concurrent state flips write exactly one audit event (no double audit under a race)", async () => { - const h = makeOrderHarness(bound.storage, { countingIds: true }); const id = orderId("ord-audit-race"); + const N = 12; + const barrier = barrierCall( + collectionOf(bound.storage, ORDERS_COLLECTION), + onId(id, isUpdateWrite), + N, + ); + const h = makeOrderHarness(bound.storage, { + countingIds: true, + storageForOrders: withCollection(bound.storage, ORDERS_COLLECTION, barrier.collection), + }); await h.store.createFromCart(pendingInput("ord-audit-race", "key-audit-race")); - const N = 12; const results = await Promise.all(Array.from({ length: N }, () => h.store.markPaid(id))); + // All N really did contend: each pinned the pending revision and tried to flip it. + expect(barrier.arrived()).toBe(N); // Exactly one caller won the guarded flip; the rest are benign 0-row misses. expect(results.filter((won) => won)).toHaveLength(1);