@aotter/mantle 0.1.0-alpha.9 → 0.1.2-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/README.md +38 -38
  2. package/dist/cli/generate.d.ts +10 -1
  3. package/dist/cli/generate.d.ts.map +1 -1
  4. package/dist/cli/generate.js +49 -4
  5. package/dist/cli/generate.js.map +1 -1
  6. package/dist/cli/main.js +0 -8
  7. package/dist/cli/main.js.map +1 -1
  8. package/dist/codegen/emitMantleModule.js +8 -0
  9. package/dist/codegen/emitMantleModule.js.map +1 -1
  10. package/docs/adapter-guide.md +23 -26
  11. package/docs/adr/0008-structured-diagnostic-shape.md +7 -2
  12. package/docs/adr/0010-locale-and-translates.md +14 -16
  13. package/docs/adr/0011-adapter-port-spec.md +2 -1
  14. package/docs/adr/0014-auth-better-auth-and-multi-tenant-mcp.md +100 -0
  15. package/docs/adr/0018-core-starters-repository-boundary.md +3 -0
  16. package/docs/adr/0019-sealed-manifest-runtime-pipeline.md +38 -2
  17. package/docs/adr/0020-builtin-handler-contracts-and-matched-upsert.md +199 -0
  18. package/docs/adr/0021-retire-starter-scaffolding.md +30 -0
  19. package/docs/adr/0022-caller-observed-version-occ.md +212 -0
  20. package/docs/adr/0023-port-failure-contract.md +69 -0
  21. package/docs/adr/0024-manifest-native-schema-tables.md +260 -0
  22. package/docs/adr/README.md +19 -3
  23. package/docs/adr/adr-lite-803-request-diagnostics.md +38 -0
  24. package/docs/adr/adr-lite-808-route-readiness.md +47 -0
  25. package/docs/adr/adr-lite-809-bounded-public-content.md +71 -0
  26. package/docs/adr/adr-lite-812-native-parity.md +150 -0
  27. package/docs/adr/adr-lite-823-home-statistics.md +64 -0
  28. package/docs/adr/adr-lite-842-mcp-authoring.md +30 -0
  29. package/docs/adr/adr-lite-861-admin-webmcp.md +42 -0
  30. package/docs/adr/adr-lite-909-admin-ui-kit.md +37 -0
  31. package/docs/api-mcp-authorization.md +3 -664
  32. package/docs/auth-hosting-model.md +4 -4
  33. package/docs/cloudflare-low-level-composition.md +3 -104
  34. package/docs/deferred-lifecycle-queues.md +2 -243
  35. package/docs/design-atoms.md +16 -796
  36. package/docs/direct-authoring.md +5 -0
  37. package/docs/examples/minimal-worker/README.md +37 -0
  38. package/docs/examples/minimal-worker/manifests/site.yaml +25 -0
  39. package/docs/examples/minimal-worker/package.json +26 -0
  40. package/docs/examples/minimal-worker/smoke.mjs +23 -0
  41. package/docs/examples/minimal-worker/src/index.ts +4 -0
  42. package/docs/examples/minimal-worker/tsconfig.json +17 -0
  43. package/docs/examples/minimal-worker/wrangler.jsonc +22 -0
  44. package/docs/handbook/cloudflare/authentication.md +167 -0
  45. package/docs/handbook/cloudflare/bindings.md +236 -0
  46. package/docs/handbook/cloudflare/conventional-worker.md +162 -0
  47. package/docs/handbook/cloudflare/deferred-hooks-queues.md +171 -0
  48. package/docs/handbook/cloudflare/deploy-and-operate.md +105 -0
  49. package/docs/handbook/cloudflare/low-level-composition.md +169 -0
  50. package/docs/handbook/cloudflare/media-r2.md +216 -0
  51. package/docs/handbook/cloudflare/public-web.md +144 -0
  52. package/docs/handbook/concepts/authorization.md +116 -0
  53. package/docs/handbook/concepts/four-atoms.md +173 -0
  54. package/docs/handbook/concepts/lifecycle-and-locales.md +142 -0
  55. package/docs/handbook/concepts/mcp-and-agents.md +109 -0
  56. package/docs/handbook/concepts/procedures-and-triggers.md +221 -0
  57. package/docs/handbook/concepts/runtime-and-adapters.md +177 -0
  58. package/docs/handbook/concepts/views.md +191 -0
  59. package/docs/handbook/examples/commerce-transaction.md +813 -0
  60. package/docs/handbook/examples/guarded-api.md +427 -0
  61. package/docs/handbook/examples/intake-form.md +319 -0
  62. package/docs/handbook/examples/legal-documents.md +218 -0
  63. package/docs/handbook/examples/procurement-approvals.md +240 -0
  64. package/docs/handbook/examples/publication.md +240 -0
  65. package/docs/handbook/examples/reservation.md +220 -0
  66. package/docs/handbook/navigation.json +63 -0
  67. package/docs/handbook/reference/authorization.md +295 -0
  68. package/docs/handbook/reference/diagnostics.md +183 -0
  69. package/docs/handbook/reference/manifest.md +132 -0
  70. package/docs/handbook/reference/procedure.md +263 -0
  71. package/docs/handbook/reference/schema.md +250 -0
  72. package/docs/handbook/reference/site-config.md +233 -0
  73. package/docs/handbook/reference/surface.md +207 -0
  74. package/docs/handbook/reference/trigger.md +193 -0
  75. package/docs/handbook/reference/view.md +256 -0
  76. package/docs/handbook/start/project-and-cli.md +122 -0
  77. package/docs/handbook/start/quickstart-worker.md +193 -0
  78. package/docs/labels.md +3 -1
  79. package/docs/media-uploads.md +3 -184
  80. package/docs/migration-0.1.2.md +60 -3
  81. package/docs/performance-harness.md +127 -10
  82. package/docs/release-process.md +85 -231
  83. package/docs/schema-indexes.md +3 -180
  84. package/docs/sealed-pipeline-ownership.md +6 -5
  85. package/docs/spec-only-host-adoption.md +158 -0
  86. package/docs/transaction-patterns.md +5 -0
  87. package/package.json +21 -25
  88. package/skills/README.md +4 -4
  89. package/skills/develop/SKILL.md +23 -28
  90. package/skills/install/SKILL.md +55 -147
  91. package/skills/provision/SKILL.md +4 -3
  92. package/skills/theme/SKILL.md +6 -6
  93. package/skills/update/SKILL.md +32 -68
  94. package/dist/cli/create.d.ts +0 -2
  95. package/dist/cli/create.d.ts.map +0 -1
  96. package/dist/cli/create.js +0 -243
  97. package/dist/cli/create.js.map +0 -1
  98. package/dist/cli/update.d.ts +0 -2
  99. package/dist/cli/update.d.ts.map +0 -1
  100. package/dist/cli/update.js +0 -413
  101. package/dist/cli/update.js.map +0 -1
  102. package/dist/provision/renderProvisionBundle.d.ts +0 -70
  103. package/dist/provision/renderProvisionBundle.d.ts.map +0 -1
  104. package/dist/provision/renderProvisionBundle.js +0 -367
  105. package/dist/provision/renderProvisionBundle.js.map +0 -1
  106. package/dist/provision.d.ts +0 -2
  107. package/dist/provision.d.ts.map +0 -1
  108. package/dist/provision.js +0 -2
  109. package/dist/provision.js.map +0 -1
@@ -0,0 +1,813 @@
1
+ ---
2
+ description: A guest checkout with a Durable Object as stock authority, Queue-based order expiry, and a verified payment-provider callback.
3
+ ---
4
+ # Commerce: catalog, orders, inventory authority and asynchronous settlement
5
+
6
+ This example is the most involved pattern in the set. It publishes a catalog, accepts guest orders, reserves stock exactly once in an application-owned Durable Object, expires unpaid orders through a Queue with a cron sweep as recovery, and shows where a real payment provider plugs in. Read it if money and stock are involved.
7
+
8
+ ## Problem
9
+
10
+ Staff publish products with a price. A guest places an order for one or more products; the server re-prices every line, reserves stock, and returns an order token with a fifteen-minute payment deadline. Payment arrives asynchronously from a provider callback, never from the browser. If payment does not arrive in time the reservation is released. Staff adjust stock, fulfil paid orders and read a picking list, all through Admin or Staff MCP, and every stock change is auditable. Mantle stores the query mirrors; the Durable Object is the only writer of truth for stock.
11
+
12
+ ## Manifest
13
+
14
+ Trimmed to the essentials that stay valid together. Localized product copy is omitted; see [Publication](./publication.md) for the parent/child pattern.
15
+
16
+ ```yaml
17
+ apiVersion: cms.mantle.aotter.net/v1
18
+ kind: Schema
19
+ metadata:
20
+ name: products
21
+ spec:
22
+ title: Products
23
+ lifecycle: publishing
24
+ uniqueIndexes:
25
+ - [slug]
26
+ schema:
27
+ type: object
28
+ additionalProperties: false
29
+ required: [slug, title, priceMinor, currency]
30
+ properties:
31
+ slug: { type: string, pattern: "^[a-z0-9-]+$" }
32
+ title: { type: string, minLength: 1, maxLength: 160 }
33
+ summary: { type: string, maxLength: 500 }
34
+ priceMinor: { type: integer, minimum: 0, x-mcp-hint: money-minor }
35
+ currency: { type: string, pattern: "^[A-Z]{3}$" }
36
+ ---
37
+ apiVersion: cms.mantle.aotter.net/v1
38
+ kind: Schema
39
+ metadata:
40
+ name: orders
41
+ spec:
42
+ title: Orders
43
+ description: Guest orders created by checkout and maintained only by declared Procedures.
44
+ lifecycle: operational
45
+ uniqueIndexes:
46
+ - [orderToken]
47
+ - [orderNumber]
48
+ indexes:
49
+ - [orderStatus]
50
+ searchableFields: [orderNumber, customerName, customerEmail]
51
+ uiSchema:
52
+ list:
53
+ filterField: orderStatus
54
+ primaryField: orderNumber
55
+ columns: [orderStatus, customerEmail, totalMinor]
56
+ schema:
57
+ readOnly: true
58
+ type: object
59
+ required: [orderToken, orderNumber, orderStatus, currency, totalMinor, customerName, customerEmail, shippingAddress, items, expiresAt]
60
+ properties:
61
+ orderToken: { type: string, pattern: "^[0-9a-f-]{36}$" }
62
+ orderNumber: { type: string }
63
+ orderStatus: { type: string, enum: [pending_payment, paid, fulfilled, cancelled] }
64
+ currency: { type: string, pattern: "^[A-Z]{3}$" }
65
+ totalMinor: { type: integer, minimum: 0, x-mcp-hint: money-minor }
66
+ customerName: { type: string, minLength: 1, maxLength: 120 }
67
+ customerEmail: { type: string, format: email }
68
+ shippingAddress: { type: string, minLength: 1, maxLength: 500 }
69
+ items:
70
+ type: array
71
+ minItems: 1
72
+ maxItems: 20
73
+ items:
74
+ type: object
75
+ required: [productSlug, title, quantity, unitPriceMinor, lineTotalMinor]
76
+ properties:
77
+ productSlug: { type: string }
78
+ title: { type: string }
79
+ quantity: { type: integer, minimum: 1, maximum: 99 }
80
+ unitPriceMinor: { type: integer, minimum: 0 }
81
+ lineTotalMinor: { type: integer, minimum: 0 }
82
+ expiresAt: { type: number, x-mcp-hint: timestamp-ms }
83
+ paidAt: { type: number, x-mcp-hint: timestamp-ms }
84
+ fulfilledAt: { type: number, x-mcp-hint: timestamp-ms }
85
+ cancelledAt: { type: number, x-mcp-hint: timestamp-ms }
86
+ trackingNumber: { type: string }
87
+ cancelReason: { type: string }
88
+ ---
89
+ apiVersion: cms.mantle.aotter.net/v1
90
+ kind: Schema
91
+ metadata:
92
+ name: inventory
93
+ spec:
94
+ title: Inventory
95
+ description: Query mirror of the InventoryCoordinator Durable Object, which is the stock authority.
96
+ lifecycle: operational
97
+ uniqueIndexes:
98
+ - [productSlug]
99
+ indexes:
100
+ - [available, productSlug]
101
+ uiSchema:
102
+ list:
103
+ primaryField: productSlug
104
+ columns: [available, reserved, revision]
105
+ nav:
106
+ standalone: true
107
+ parentField: productSlug
108
+ schema:
109
+ readOnly: true
110
+ type: object
111
+ required: [productSlug, available, reserved, revision]
112
+ properties:
113
+ productSlug: { type: string, x-mantle-ref: products }
114
+ available: { type: integer, minimum: 0 }
115
+ reserved: { type: integer, minimum: 0 }
116
+ revision: { type: integer, minimum: 0 }
117
+ ---
118
+ apiVersion: cms.mantle.aotter.net/v1
119
+ kind: Schema
120
+ metadata:
121
+ name: inventory-movements
122
+ spec:
123
+ title: Inventory movements
124
+ description: Append-only audit trail for stock changes caused by checkout and staff operations.
125
+ lifecycle: operational
126
+ uniqueIndexes:
127
+ - [movementKey]
128
+ indexes:
129
+ - [kind, occurredAt]
130
+ - [productSlug, occurredAt]
131
+ - [orderToken]
132
+ uiSchema:
133
+ list:
134
+ filterField: kind
135
+ primaryField: productSlug
136
+ columns: [kind, availableDelta, reservedDelta, occurredAt]
137
+ schema:
138
+ readOnly: true
139
+ type: object
140
+ required: [movementKey, productSlug, kind, availableDelta, reservedDelta, occurredAt]
141
+ properties:
142
+ movementKey: { type: string }
143
+ productSlug: { type: string }
144
+ orderToken: { type: string }
145
+ kind: { type: string, enum: [adjust, reserve, sale, release, cancellation] }
146
+ availableDelta: { type: integer }
147
+ reservedDelta: { type: integer }
148
+ note: { type: string }
149
+ occurredAt: { type: number, x-mcp-hint: timestamp-ms }
150
+ ---
151
+ apiVersion: cms.mantle.aotter.net/v1
152
+ kind: View
153
+ metadata:
154
+ name: public-products
155
+ spec:
156
+ surface: public
157
+ from: products
158
+ fields: [id, slug, title, summary, priceMinor, currency, updatedAt]
159
+ filter:
160
+ eq: { field: status, value: published }
161
+ orderBy:
162
+ - { field: slug, direction: asc }
163
+ limit: 100
164
+ ---
165
+ apiVersion: cms.mantle.aotter.net/v1
166
+ kind: View
167
+ metadata:
168
+ name: picking-list
169
+ spec:
170
+ title: Picking list
171
+ surface: staff
172
+ uiSchema:
173
+ list:
174
+ columns: [orderNumber, customerName, shippingAddress, productSlug, productTitle, quantity]
175
+ searchFields: [orderNumber, customerName, shippingAddress, productSlug, productTitle]
176
+ sql: |
177
+ SELECT
178
+ o.orderNumber,
179
+ o.customerName,
180
+ o.shippingAddress,
181
+ json_extract(item.value, '$.productSlug') AS productSlug,
182
+ json_extract(item.value, '$.title') AS productTitle,
183
+ json_extract(item.value, '$.quantity') AS quantity
184
+ FROM orders AS o
185
+ JOIN json_each(o.items) AS item
186
+ WHERE o.orderStatus = 'paid'
187
+ ORDER BY o._mantle_created_at ASC, o.orderNumber ASC, item.key ASC
188
+ limit: 200
189
+ ---
190
+ apiVersion: cms.mantle.aotter.net/v1
191
+ kind: Procedure
192
+ metadata:
193
+ name: place-order
194
+ spec:
195
+ input:
196
+ type: object
197
+ additionalProperties: false
198
+ required: [customerName, customerEmail, shippingAddress, items]
199
+ properties:
200
+ customerName: { type: string, minLength: 1, maxLength: 120 }
201
+ customerEmail: { type: string, format: email }
202
+ shippingAddress: { type: string, minLength: 1, maxLength: 500 }
203
+ items:
204
+ type: array
205
+ minItems: 1
206
+ maxItems: 20
207
+ items:
208
+ type: object
209
+ additionalProperties: false
210
+ required: [productSlug, quantity]
211
+ properties:
212
+ productSlug: { type: string, pattern: "^[a-z0-9-]+$" }
213
+ quantity: { type: integer, minimum: 1, maximum: 99 }
214
+ output:
215
+ type: object
216
+ required: [outcome, orderToken, orderNumber, expiresAt, totalMinor, currency]
217
+ properties:
218
+ outcome: { type: string, enum: [pending_payment] }
219
+ orderToken: { type: string }
220
+ orderNumber: { type: string }
221
+ expiresAt: { type: number }
222
+ totalMinor: { type: integer }
223
+ currency: { type: string }
224
+ handler: { kind: ref, ref: placeOrder }
225
+ ---
226
+ apiVersion: cms.mantle.aotter.net/v1
227
+ kind: Procedure
228
+ metadata:
229
+ name: pay-order
230
+ spec:
231
+ input:
232
+ type: object
233
+ additionalProperties: false
234
+ required: [orderToken]
235
+ properties:
236
+ orderToken: { type: string, pattern: "^[0-9a-f-]{36}$" }
237
+ output:
238
+ type: object
239
+ required: [outcome, orderToken]
240
+ properties:
241
+ outcome: { type: string, enum: [paid, already_paid, expired, closed, missing] }
242
+ orderToken: { type: string }
243
+ handler: { kind: ref, ref: payOrder }
244
+ ---
245
+ apiVersion: cms.mantle.aotter.net/v1
246
+ kind: Procedure
247
+ metadata:
248
+ name: cancel-guest-order
249
+ spec:
250
+ input:
251
+ type: object
252
+ additionalProperties: false
253
+ required: [orderToken]
254
+ properties:
255
+ orderToken: { type: string, pattern: "^[0-9a-f-]{36}$" }
256
+ output:
257
+ type: object
258
+ required: [outcome, orderToken]
259
+ properties:
260
+ outcome: { type: string, enum: [cancelled, already_cancelled, closed, missing] }
261
+ orderToken: { type: string }
262
+ handler: { kind: ref, ref: cancelGuestOrder }
263
+ ---
264
+ apiVersion: cms.mantle.aotter.net/v1
265
+ kind: Procedure
266
+ metadata:
267
+ name: adjust-inventory
268
+ spec:
269
+ title: Adjust inventory
270
+ requires:
271
+ auth:
272
+ all:
273
+ - { "ctx.staff": [owner] }
274
+ input:
275
+ type: object
276
+ additionalProperties: false
277
+ required: [operationId, productSlug, delta, reason]
278
+ properties:
279
+ operationId: { type: string, format: uuid, x-mcp-hint: idempotency-key }
280
+ productSlug: { type: string, pattern: "^[a-z0-9-]+$", x-mantle-ref: products }
281
+ delta: { type: integer, minimum: -100000, maximum: 100000 }
282
+ reason: { type: string, minLength: 1, maxLength: 500 }
283
+ uiSchema:
284
+ fields:
285
+ reason: { widget: textarea }
286
+ output:
287
+ type: object
288
+ required: [productSlug, available, reserved, revision]
289
+ properties:
290
+ productSlug: { type: string }
291
+ available: { type: integer }
292
+ reserved: { type: integer }
293
+ revision: { type: integer }
294
+ handler: { kind: ref, ref: adjustInventory }
295
+ ---
296
+ apiVersion: cms.mantle.aotter.net/v1
297
+ kind: Procedure
298
+ metadata:
299
+ name: fulfill-order
300
+ spec:
301
+ title: Fulfill order
302
+ requires:
303
+ auth:
304
+ all:
305
+ - { "ctx.staff": [owner] }
306
+ input:
307
+ type: object
308
+ additionalProperties: false
309
+ required: [orderToken]
310
+ properties:
311
+ orderToken: { type: string, pattern: "^[0-9a-f-]{36}$", x-mantle-ref: orders }
312
+ trackingNumber: { type: string, maxLength: 120 }
313
+ output:
314
+ type: object
315
+ required: [outcome, orderToken]
316
+ properties:
317
+ outcome: { type: string, enum: [fulfilled, already_fulfilled, closed, missing] }
318
+ orderToken: { type: string }
319
+ handler: { kind: ref, ref: fulfillOrder }
320
+ ---
321
+ apiVersion: cms.mantle.aotter.net/v1
322
+ kind: Procedure
323
+ metadata:
324
+ name: expire-order
325
+ spec:
326
+ input:
327
+ type: object
328
+ additionalProperties: false
329
+ required: [orderToken, now]
330
+ properties:
331
+ orderToken: { type: string, pattern: "^[0-9a-f-]{36}$" }
332
+ now: { type: number }
333
+ output:
334
+ type: object
335
+ required: [outcome, orderToken]
336
+ properties:
337
+ outcome: { type: string, enum: [expired, too_early, closed, missing] }
338
+ orderToken: { type: string }
339
+ handler: { kind: ref, ref: expireOrder }
340
+ ---
341
+ apiVersion: cms.mantle.aotter.net/v1
342
+ kind: Procedure
343
+ metadata:
344
+ name: sweep-expired-orders
345
+ spec:
346
+ input:
347
+ type: object
348
+ additionalProperties: false
349
+ required: [now]
350
+ properties:
351
+ now: { type: number }
352
+ output:
353
+ type: object
354
+ required: [checked, expired]
355
+ properties:
356
+ checked: { type: integer }
357
+ expired: { type: integer }
358
+ handler: { kind: ref, ref: sweepExpiredOrders }
359
+ ---
360
+ apiVersion: cms.mantle.aotter.net/v1
361
+ kind: Trigger
362
+ metadata:
363
+ name: place-order-http
364
+ spec:
365
+ source: { kind: http, method: POST, path: /api/commerce/orders }
366
+ target: { procedure: place-order }
367
+ ---
368
+ # Demo only. Remove before connecting a real provider; see "Where the payment provider plugs in".
369
+ apiVersion: cms.mantle.aotter.net/v1
370
+ kind: Trigger
371
+ metadata:
372
+ name: pay-order-http
373
+ spec:
374
+ source: { kind: http, method: POST, path: /api/commerce/orders/pay }
375
+ target: { procedure: pay-order }
376
+ ---
377
+ apiVersion: cms.mantle.aotter.net/v1
378
+ kind: Trigger
379
+ metadata:
380
+ name: cancel-guest-order-http
381
+ spec:
382
+ source: { kind: http, method: POST, path: /api/commerce/orders/cancel }
383
+ target: { procedure: cancel-guest-order }
384
+ ---
385
+ apiVersion: cms.mantle.aotter.net/v1
386
+ kind: Trigger
387
+ metadata:
388
+ name: adjust-inventory-mcp
389
+ spec:
390
+ source: { kind: mcp, surface: staff }
391
+ target: { procedure: adjust-inventory }
392
+ ---
393
+ apiVersion: cms.mantle.aotter.net/v1
394
+ kind: Trigger
395
+ metadata:
396
+ name: fulfill-order-mcp
397
+ spec:
398
+ source: { kind: mcp, surface: staff }
399
+ target: { procedure: fulfill-order }
400
+ ```
401
+
402
+ Points worth noticing:
403
+
404
+ - `orders`, `inventory` and `inventory-movements` set root `schema.readOnly: true`. Admin and Staff MCP keep list and detail access and the declared row Procedures, but suppress and reject generic create, update, status and delete. Only handlers maintain these projections.
405
+ - `inventory.productSlug` references `products`, so Inventory stays available under each product. `uiSchema.nav.standalone: true` also adds a main Admin Nav entry with a product filter for staff who need a cross-product stock list.
406
+ - `expire-order` and `sweep-expired-orders` have **no Trigger**. They are internal Procedures: the Queue consumer and the cron handler invoke them through the generated binding. Nothing external can call them.
407
+ - `adjust-inventory.operationId` carries `x-mcp-hint: idempotency-key`; Admin generates a hidden UUID per form, and other callers must reuse theirs on retry. `productSlug` carries `x-mantle-ref: products`, so Admin offers "Adjust inventory" on each product row with the slug prefilled. `fulfill-order.orderToken` does the same on `orders` rows.
408
+ - `picking-list` is a raw `sql` staff View using `json_each` to unnest order lines. `sql` Views run on SQLite storage only.
409
+
410
+ See [Reads: Views, REST and MCP](../concepts/views.md) and the [Schema reference](../reference/schema.md).
411
+
412
+ ## Worker and handlers
413
+
414
+ ### Bindings and Worker entry
415
+
416
+ ```ts
417
+ // src/env.ts
418
+ import type { MantleCloudflareEnv } from "@aotter/mantle/cloudflare";
419
+ import type { InventoryCoordinator } from "./commerce/InventoryCoordinator.js";
420
+
421
+ export type ExpiryMessage = { readonly type: "expire-order"; readonly orderToken: string };
422
+
423
+ export interface Env extends MantleCloudflareEnv {
424
+ readonly INVENTORY_COORDINATOR: DurableObjectNamespace<InventoryCoordinator>;
425
+ readonly ORDER_EXPIRY_QUEUE: Queue<ExpiryMessage>;
426
+ readonly PAYMENT_WEBHOOK_SECRET?: string;
427
+ }
428
+ ```
429
+
430
+ ```toml
431
+ # wrangler.toml (additions)
432
+ [[durable_objects.bindings]]
433
+ name = "INVENTORY_COORDINATOR"
434
+ class_name = "InventoryCoordinator"
435
+
436
+ [[migrations]]
437
+ tag = "inventory-v1"
438
+ new_sqlite_classes = ["InventoryCoordinator"]
439
+
440
+ [[queues.producers]]
441
+ binding = "ORDER_EXPIRY_QUEUE"
442
+ queue = "shop-order-expiry"
443
+
444
+ [[queues.consumers]]
445
+ queue = "shop-order-expiry"
446
+ max_concurrency = 1
447
+ max_batch_size = 10
448
+ max_retries = 5
449
+ retry_delay = 30
450
+ dead_letter_queue = "shop-order-expiry-dlq"
451
+
452
+ [triggers]
453
+ crons = ["*/5 * * * *"]
454
+ ```
455
+
456
+ ```ts
457
+ // src/index.ts
458
+ import { createMantleWorker } from "@aotter/mantle/cloudflare";
459
+ import { bindMantle, plan } from "../.mantle/generated/mantle.js";
460
+ import { buildCommerceHandlers } from "./commerce/handlers.js";
461
+ import { verifyProviderEvent } from "./commerce/provider.js";
462
+ import type { Env, ExpiryMessage } from "./env.js";
463
+
464
+ export { InventoryCoordinator } from "./commerce/InventoryCoordinator.js";
465
+
466
+ const worker = createMantleWorker<Env>({
467
+ plan,
468
+ extend: ({ getRuntime, env }) => ({
469
+ handlers: buildCommerceHandlers(getRuntime),
470
+ mount({ app }) {
471
+ // Provider callback: raw body + signature, outside the JSON HTTP Trigger path.
472
+ app.post("/payments/callback", async (c) => {
473
+ const raw = await c.req.text();
474
+ const event = await verifyProviderEvent(raw, c.req.header("x-provider-signature"), env.PAYMENT_WEBHOOK_SECRET);
475
+ if (!event) return c.text("invalid signature", 400);
476
+ if (event.type !== "payment.succeeded") return c.text("ignored", 200);
477
+ const api = bindMantle(await getRuntime());
478
+ const result = await api.procedures.payOrder(
479
+ { orderToken: event.orderToken },
480
+ { user: null, staff: null, env, waitUntil: (p) => c.executionCtx.waitUntil(p) },
481
+ );
482
+ if (!result.ok) return c.text(result.diagnostic.code, result.diagnostic.code === "INTERNAL_ERROR" ? 500 : 200);
483
+ return c.text("OK", 200); // paid, already_paid, expired, closed, missing are all terminal for the provider
484
+ });
485
+ },
486
+ }),
487
+ });
488
+
489
+ function internalContext(env: Env, ctx: ExecutionContext) {
490
+ return {
491
+ user: null,
492
+ staff: null,
493
+ env,
494
+ waitUntil: (promise: Promise<unknown>) => ctx.waitUntil(promise),
495
+ } as const;
496
+ }
497
+
498
+ export default {
499
+ fetch: worker.fetch,
500
+
501
+ async queue(batch: MessageBatch<ExpiryMessage>, env: Env, ctx: ExecutionContext): Promise<void> {
502
+ let api: ReturnType<typeof bindMantle>;
503
+ try {
504
+ api = bindMantle(await worker.getRuntime(env));
505
+ } catch (error) {
506
+ console.error("[order-expiry] runtime unavailable", error);
507
+ batch.retryAll();
508
+ return;
509
+ }
510
+ for (const message of batch.messages) {
511
+ const body = message.body;
512
+ if (body?.type !== "expire-order" || typeof body.orderToken !== "string") {
513
+ console.error("[order-expiry] discarded malformed message", message.id);
514
+ message.ack();
515
+ continue;
516
+ }
517
+ try {
518
+ const result = await api.procedures.expireOrder({ orderToken: body.orderToken, now: Date.now() }, internalContext(env, ctx));
519
+ if (!result.ok) {
520
+ if (result.diagnostic.code === "INTERNAL_ERROR" || result.diagnostic.code === "CONFLICT") message.retry();
521
+ else message.ack();
522
+ } else if (result.data.outcome === "too_early") {
523
+ message.retry({ delaySeconds: 60 });
524
+ } else {
525
+ message.ack();
526
+ }
527
+ } catch (error) {
528
+ console.error("[order-expiry] transient failure", message.id, error);
529
+ message.retry();
530
+ }
531
+ }
532
+ },
533
+
534
+ async scheduled(_controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
535
+ const api = bindMantle(await worker.getRuntime(env));
536
+ const result = await api.procedures.sweepExpiredOrders({ now: Date.now() }, internalContext(env, ctx));
537
+ if (!result.ok) throw new Error(`expiry sweep failed: ${result.diagnostic.code}`);
538
+ },
539
+ } satisfies ExportedHandler<Env, ExpiryMessage>;
540
+ ```
541
+
542
+ `worker.getRuntime(env)` returns the same booted runtime `fetch` uses; `bindMantle(runtime).procedures.<lowerCamel>(input, ctx)` runs the full Procedure pipeline (auth predicates, input validation, handler, output validation) and returns `{ ok: true, data } | { ok: false, diagnostic }`.
543
+
544
+ `internalContext` is a **system caller**: `user: null, staff: null` and no `auth`. It satisfies no `requires.auth` predicate, so it can only invoke Procedures that declare none. That is intentional. `expire-order` and `sweep-expired-orders` have no `requires` and no Trigger, so the Queue and cron are their only callers. Never hand this context to a staff-guarded Procedure to "skip" authorization; declare an internal Procedure instead.
545
+
546
+ ### Inventory authority in a Durable Object
547
+
548
+ One `InventoryCoordinator` instance per shop (`env.INVENTORY_COORDINATOR.getByName("site")`) keeps every SKU's counts and every order's reservation in one storage namespace, so a multi-product cart reserves atomically. Every method runs in `this.ctx.storage.transaction`, checks the current state, and returns a prior outcome when repeated.
549
+
550
+ ```ts
551
+ // src/commerce/InventoryCoordinator.ts (abridged)
552
+ import { DurableObject } from "cloudflare:workers";
553
+
554
+ export type StockItem = { readonly productSlug: string; readonly quantity: number };
555
+ export type StockSnapshot = { readonly productSlug: string; readonly available: number; readonly reserved: number; readonly revision: number };
556
+ type OrderState = { readonly items: readonly StockItem[]; readonly expiresAt: number; readonly status: "pending_payment" | "paid" | "fulfilled" | "cancelled" | "expired" };
557
+
558
+ export class InventoryCoordinator extends DurableObject {
559
+ reserve(orderId: string, items: readonly StockItem[], expiresAt: number) {
560
+ return this.ctx.storage.transaction(async (txn) => {
561
+ const existing = await txn.get<OrderState>(`order:${orderId}`);
562
+ if (existing) return existing.status === "pending_payment" ? { outcome: "already_reserved" as const } : { outcome: "closed" as const };
563
+ // read every SKU; reject if any lacks stock; otherwise available -= q, reserved += q, revision += 1
564
+ // then txn.put(`order:${orderId}`, { items, expiresAt, status: "pending_payment" })
565
+ return { outcome: "reserved" as const, snapshots: [] as StockSnapshot[] };
566
+ });
567
+ }
568
+
569
+ pay(orderId: string, now: number) {
570
+ return this.ctx.storage.transaction(async (txn) => {
571
+ const order = await txn.get<OrderState>(`order:${orderId}`);
572
+ if (!order) return { outcome: "missing" as const };
573
+ if (order.status === "paid") return { outcome: "already_paid" as const, items: order.items };
574
+ if (order.status !== "pending_payment") return { outcome: "closed" as const };
575
+ if (order.expiresAt <= now) return release(txn, orderId, order, "expired"); // late payment: release, do not sell
576
+ // reserved -= q for each item; status -> paid
577
+ return { outcome: "paid" as const, items: order.items };
578
+ });
579
+ }
580
+
581
+ expire(orderId: string, now: number) { /* pending_payment && expiresAt <= now -> release(...,"expired"); else too_early / closed / missing */ }
582
+ cancel(orderId: string) { /* pending_payment -> release; paid -> available += q; fulfilled|expired -> closed */ }
583
+ fulfill(orderId: string) { /* paid -> fulfilled; repeat -> already_fulfilled */ }
584
+
585
+ adjust(operationId: string, productSlug: string, delta: number, reason: string) {
586
+ return this.ctx.storage.transaction(async (txn) => {
587
+ const applied = await txn.get<{ productSlug: string; delta: number; reason: string; snapshot: StockSnapshot }>(`adjustment:${operationId}`);
588
+ if (applied) {
589
+ return applied.productSlug === productSlug && applied.delta === delta && applied.reason === reason
590
+ ? { outcome: "already_adjusted" as const, snapshot: applied.snapshot }
591
+ : { outcome: "idempotency_conflict" as const };
592
+ }
593
+ // available + delta must stay >= 0; write inventory:<slug> and adjustment:<operationId>
594
+ return { outcome: "adjusted" as const, snapshot: {} as StockSnapshot };
595
+ });
596
+ }
597
+ }
598
+ ```
599
+
600
+ Storage keys are `inventory:<slug>`, `order:<orderToken>` and `adjustment:<operationId>`. An adjustment key is bound to product, delta and reason: reusing it with different input is `idempotency_conflict`, which the handler maps to `CONFLICT` (409), not a second stock change.
601
+
602
+ The Mantle `inventory` and `inventory-movements` rows are **query mirrors**. Handlers write them only through runtime write use cases (`runtime.createDraft`, `runtime.updateDraft`, `runtime.deleteEntry`, each of which is live on an operational Schema), never through Mantle's tables. The mirror is revision-guarded: a snapshot older than the stored `revision` is skipped, and a version race on `updateDraft` is retried. Movement rows use a deterministic `movementKey` (`reserve:<orderToken>:<slug>`, `sale:<orderToken>:<slug>`, `adjust:<operationId>`) and are inserted only if absent, so replaying any step cannot double-record.
603
+
604
+ ```ts
605
+ // src/commerce/handlers.ts (abridged)
606
+ import type { HandlerContext, MantleRuntime } from "@aotter/mantle/runtime";
607
+ import { DiagnosticError, runtimeDiagnostic } from "@aotter/mantle/spec";
608
+ import type { MantleHandlers } from "../../.mantle/generated/mantle.js";
609
+ import type { Env } from "../env.js";
610
+
611
+ const CHECKOUT_TTL_MS = 15 * 60 * 1000;
612
+ const inventory = (env: Env) => env.INVENTORY_COORDINATOR.getByName("site");
613
+
614
+ export function buildCommerceHandlers(getRuntime: () => Promise<MantleRuntime>): MantleHandlers<Env> {
615
+ return {
616
+ placeOrder: async (input, ctx) => {
617
+ const runtime = await getRuntime();
618
+ const orderToken = crypto.randomUUID();
619
+ // 1. re-price on the server from published products; reject unknown slugs or mixed currencies
620
+ const priced = await priceItems(runtime, input.items);
621
+ const now = Date.now();
622
+ const expiresAt = now + CHECKOUT_TTL_MS;
623
+ // 2. reserve in the DO first; insufficient stock -> CONFLICT
624
+ const reserved = await inventory(ctx.env).reserve(orderToken, priced.stockItems, expiresAt);
625
+ if (reserved.outcome !== "reserved") throw conflict("/items", reserved, "quantities currently in stock");
626
+ // 3. project into Mantle; on failure compensate in the DO and remove any partial row
627
+ let created: { id: string } | null = null;
628
+ try {
629
+ created = await runtime.createDraft.execute({
630
+ collection: "orders",
631
+ authorId: null,
632
+ ctx,
633
+ data: { orderToken, orderNumber: orderNumber(now, orderToken), orderStatus: "pending_payment", ...priced.orderFields(input), expiresAt },
634
+ });
635
+ await recordStockChange(runtime, reserved.snapshots, priced.stockItems, "reserve", orderToken, ctx);
636
+ } catch (error) {
637
+ await inventory(ctx.env).cancel(orderToken);
638
+ if (created) await runtime.deleteEntry.execute({ id: created.id, collection: "orders", ctx });
639
+ throw error;
640
+ }
641
+ // 4. schedule expiry; the cron sweep covers a lost message
642
+ const enqueue = ctx.env.ORDER_EXPIRY_QUEUE.send({ type: "expire-order", orderToken }, { contentType: "json", delaySeconds: CHECKOUT_TTL_MS / 1000 })
643
+ .catch((error) => console.error(`[commerce] could not enqueue expiry for ${orderToken}`, error));
644
+ if (ctx.waitUntil) ctx.waitUntil(enqueue); else await enqueue;
645
+ return { outcome: "pending_payment", orderToken, orderNumber: orderNumber(now, orderToken), expiresAt, totalMinor: priced.totalMinor, currency: priced.currency };
646
+ },
647
+
648
+ payOrder: async ({ orderToken }, ctx) => {
649
+ const runtime = await getRuntime();
650
+ const order = await runtime.entries.readByDataField({ collection: "orders", field: "orderToken", value: orderToken });
651
+ if (!order) return { outcome: "missing", orderToken };
652
+ const result = await inventory(ctx.env).pay(orderToken, Date.now());
653
+ if (result.outcome === "paid" || result.outcome === "already_paid") {
654
+ await recordStockChange(runtime, result.snapshots ?? [], result.items ?? [], "sale", orderToken, ctx);
655
+ await updateOrder(runtime, order, { orderStatus: "paid", paidAt: order.data.paidAt ?? Date.now() }, ctx); // keep the first paidAt
656
+ } else if (result.outcome === "expired") {
657
+ await expirePersistedOrder(runtime, order, result, ctx);
658
+ }
659
+ return { outcome: result.outcome === "paid" || result.outcome === "already_paid" || result.outcome === "expired" ? result.outcome : "closed", orderToken };
660
+ },
661
+
662
+ expireOrder: async ({ orderToken, now }, ctx) => {
663
+ const runtime = await getRuntime();
664
+ const result = await inventory(ctx.env).expire(orderToken, now);
665
+ const order = await runtime.entries.readByDataField({ collection: "orders", field: "orderToken", value: orderToken });
666
+ if (result.outcome === "expired" && order) await expirePersistedOrder(runtime, order, result, ctx);
667
+ return { outcome: result.outcome === "expired" || result.outcome === "too_early" || result.outcome === "missing" ? result.outcome : "closed", orderToken };
668
+ },
669
+
670
+ sweepExpiredOrders: async ({ now }, ctx) => {
671
+ const runtime = await getRuntime();
672
+ const pending = await runtime.listEntries.execute({ collection: "orders", filter: { field: "orderStatus", value: "pending_payment" }, limit: 100 });
673
+ let expired = 0;
674
+ for (const order of pending) {
675
+ if ((order.data.expiresAt as number) > now) continue;
676
+ const result = await inventory(ctx.env).expire(order.data.orderToken as string, now);
677
+ if (result.outcome === "expired") { await expirePersistedOrder(runtime, order, result, ctx); expired += 1; }
678
+ }
679
+ return { checked: pending.length, expired };
680
+ },
681
+
682
+ adjustInventory: async ({ operationId, productSlug, delta, reason }, ctx) => {
683
+ const runtime = await getRuntime();
684
+ const result = await inventory(ctx.env).adjust(operationId, productSlug, delta, reason);
685
+ if (result.outcome === "idempotency_conflict") throw conflict("/operationId", operationId, "an idempotency key used with the same adjustment input");
686
+ if (result.outcome === "insufficient_stock") throw conflict("/delta", delta, "an adjustment that keeps available stock non-negative");
687
+ await persistSnapshots(runtime, [result.snapshot], ctx);
688
+ await ensureMovement(runtime, `adjust:${operationId}`, { productSlug, kind: "adjust", availableDelta: delta, reservedDelta: 0, note: reason }, ctx);
689
+ return result.snapshot;
690
+ },
691
+
692
+ fulfillOrder: async ({ orderToken, trackingNumber }, ctx) => { /* DO fulfill -> updateOrder({ orderStatus: "fulfilled", fulfilledAt, trackingNumber }) */ },
693
+ cancelGuestOrder: async ({ orderToken }, ctx) => { /* only pending_payment; DO cancel -> release movements + updateOrder({ orderStatus: "cancelled", cancelledAt, cancelReason }) */ },
694
+ };
695
+ }
696
+
697
+ function conflict(path: string, value: unknown, expected: string): DiagnosticError {
698
+ return new DiagnosticError(runtimeDiagnostic({ code: "CONFLICT", severity: "error", path, value, expected }));
699
+ }
700
+ ```
701
+
702
+ `expirePersistedOrder` records `release` movements and deletes the pending order row from the mirror; the audit trail keeps the trace. `persistSnapshots`, `ensureMovement`, `updateOrder` and `recordStockChange` are the revision-guarded and insert-if-absent helpers described above; the retired implementation in Source shows them in full.
703
+
704
+ ### Delayed expiry with Queues and a cron sweep
705
+
706
+ `place-order` sends `{ type: "expire-order", orderToken }` with `delaySeconds` equal to the checkout TTL. The consumer validates the message before touching the runtime and decides per message:
707
+
708
+ | Consumer observation | Action | Why |
709
+ |---|---|---|
710
+ | runtime boot fails | `batch.retryAll()` | nothing was processed |
711
+ | malformed body | `ack()` and log | retrying cannot fix it |
712
+ | Procedure `ok: false`, code `INTERNAL_ERROR` or `CONFLICT` | `retry()` | transient or racing with pay/cancel |
713
+ | Procedure `ok: false`, any other code | `ack()` and log | permanent (for example validation) |
714
+ | `outcome: too_early` | `retry({ delaySeconds: 60 })` | delivered before `expiresAt` |
715
+ | `outcome: expired`, `closed` or `missing` | `ack()` | terminal |
716
+ | handler threw | `retry()` | dispatch failure |
717
+
718
+ Queue delivery is at-least-once and a delayed message can arrive early, late or twice; the DO makes `expire` idempotent so that is harmless. The `*/5 * * * *` cron runs `sweep-expired-orders`, which lists `pending_payment` rows and expires those past `expiresAt`, so correctness does not depend on any single delivery. Undeliverable messages land in the DLQ after `max_retries`. This queue is application-owned; Mantle's own deferred-hook queue is a separate opt-in described in [Deferred hooks with Queues](../cloudflare/deferred-hooks-queues.md).
719
+
720
+ ### Where the payment provider plugs in
721
+
722
+ The Manifest above ships `pay-order-http` so the flow can be exercised without a provider. **Remove that Trigger in production.** Knowing an order token must never count as proof of payment.
723
+
724
+ Provider callbacks do not fit the JSON HTTP Trigger path: Stripe-style webhooks sign the raw request body and reject re-serialized JSON; ECPay-style callbacks post form data and expect a specific acknowledgement body. Mount the callback as an application route in `extend.mount`, as `src/index.ts` above does, on a path outside Mantle's reserved prefixes. In that route:
725
+
726
+ 1. Read the raw body with `c.req.text()` and verify the provider signature with the secret from `Env`. Reject on failure and return before touching Mantle.
727
+ 2. Map the verified event to an `orderToken` (store it as the provider's client reference when creating the provider session).
728
+ 3. Invoke the internal `pay-order` Procedure through `bindMantle(await getRuntime()).procedures.payOrder(...)`. Do not call the handler function directly and do not write Mantle tables from the route; the Procedure pipeline and the DO transition stay the single path.
729
+ 4. Treat every callback as a retry. `pay` returns `already_paid` on repetition, `sale:<orderToken>:<slug>` movement keys are inserted only if absent, and `paidAt` keeps its first value. A duplicate callback therefore cannot deduct inventory twice.
730
+ 5. Answer the provider with whatever it requires for acknowledgement once the outcome is terminal (`paid`, `already_paid`, `expired`, `closed`, `missing`). Return 5xx only for `INTERNAL_ERROR` so the provider retries.
731
+
732
+ Provider return and success URLs are customer navigation only; only the verified server callback confirms payment. Before enabling a real provider, make concurrent pay and cancel callbacks converge: a late `paid` projection must not overwrite a cancellation that already restored stock. The DO already refuses to pay a non-pending order; keep the Mantle projection ordered by re-reading the row before each `updateDraft`.
733
+
734
+ ## Try it
735
+
736
+ Place an order:
737
+
738
+ ```sh
739
+ curl -sS -X POST http://localhost:8787/api/commerce/orders \
740
+ -H 'content-type: application/json' \
741
+ -d '{"customerName":"Ada","customerEmail":"ada@example.test","shippingAddress":"1 Shell Lane","items":[{"productSlug":"notebook","quantity":2}]}'
742
+ ```
743
+
744
+ ```json
745
+ {
746
+ "ok": true,
747
+ "data": {
748
+ "outcome": "pending_payment",
749
+ "orderToken": "5b7d1a8e-4c2f-4f7e-9d1a-0b6c2e8f3a11",
750
+ "orderNumber": "MNT-20260910-5B7D1A8E",
751
+ "expiresAt": 1788880263492,
752
+ "totalMinor": 2400,
753
+ "currency": "TWD"
754
+ }
755
+ }
756
+ ```
757
+
758
+ Insufficient stock is HTTP 409 `CONFLICT` at `path: "/items"`; an unknown slug is HTTP 400 `INPUT_VALIDATION_FAILED`. With the demo Trigger still present, `POST /api/commerce/orders/pay` with `{"orderToken":"..."}` returns `{ ok: true, data: { outcome: "paid", orderToken } }` the first time and `already_paid` after that.
759
+
760
+ Public catalog:
761
+
762
+ ```sh
763
+ curl -sS 'http://localhost:8787/api/views/public-products?show=20'
764
+ # {"ok":true,"data":{"rows":[{"id":"...","slug":"notebook","title":"Notebook","priceMinor":1200,"currency":"TWD","updatedAt":...}],"page":1,"show":20,"hasMore":false}}
765
+ ```
766
+
767
+ Staff, on `/mcp/staff`:
768
+
769
+ ```json
770
+ {
771
+ "jsonrpc": "2.0", "id": 3, "method": "tools/call",
772
+ "params": {
773
+ "name": "adjust_inventory",
774
+ "arguments": { "operationId": "0d1e2f3a-4b5c-4d6e-8f90-a1b2c3d4e5f6", "productSlug": "notebook", "delta": 50, "reason": "Restock from supplier" }
775
+ }
776
+ }
777
+ ```
778
+
779
+ The result is the new snapshot `{ productSlug, available, reserved, revision }`. Repeating the exact call returns the same snapshot; changing `delta` while reusing `operationId` is a JSON-RPC error with `error.data.code = "CONFLICT"`. A non-owner staff session is denied with `AUTH_DENIED`.
780
+
781
+ | Surface | Tool | Origin |
782
+ |---|---|---|
783
+ | `/mcp` | `query_view_public_products` | public View |
784
+ | `/mcp/staff` | `query_view_picking_list` | staff `sql` View |
785
+ | `/mcp/staff` | `adjust_inventory`, `fulfill_order` | staff MCP Triggers |
786
+ | `/mcp/staff` | `create_draft_products`, `update_draft_products`, `request_publish`, ... | publishing Schema `products` |
787
+
788
+ `orders`, `inventory` and `inventory-movements` expose no `create_record_*`/`update_record_*` tools because they are `readOnly`. `expire_order` and `sweep_expired_orders` are not tools anywhere.
789
+
790
+ ## What this deliberately leaves out
791
+
792
+ - **A real provider SDK.** `verifyProviderEvent` is a placeholder for the chosen provider's signature check.
793
+ - **Tax, shipping, discounts.** `totalMinor` equals the sum of line totals.
794
+ - **Refunds.** No Procedure moves a `paid` order to refunded; `cancel-order` for staff exists in the retired implementation and returns stock, but it does not move money.
795
+ - **Multi-currency carts.** All lines must share one currency; mixed carts are rejected.
796
+ - **Sharding the Durable Object.** One instance per shop is the right default. Split by SKU only after measured single-shop saturation, and expect a distributed reservation workflow when you do.
797
+ - **Customer accounts.** Orders are guest orders keyed by token; see [Procurement approvals](./procurement-approvals.md) for `ctx.user`-owned rows.
798
+
799
+ The DO protects local coordination. It is not a distributed transaction across the DO, D1 and the provider; each downstream step must be safe to retry and have a reconciliation path (the sweep, the audit rows, provider retries).
800
+
801
+ ## Source
802
+
803
+ - [`docs/transaction-patterns.md`](../../../docs/transaction-patterns.md) — reserve/settle exactly once, delayed expiry, adoption checks
804
+ - [Schema](../reference/schema.md), [Procedure](../reference/procedure.md), and [View](../reference/view.md) references — `readOnly`, `x-mantle-ref`, `idempotency-key`, `sql` Views
805
+ - [`docs/deferred-lifecycle-queues.md`](../../../docs/deferred-lifecycle-queues.md) — Queue contract and multiplexing
806
+ - [`packages/adapters/cloudflare/src/worker/createMantleWorker.ts`](../../../packages/adapters/cloudflare/src/worker/createMantleWorker.ts) — `extend.mount`, `getRuntime`
807
+ - [`packages/mantle/src/codegen/emitMantleModule.ts`](../../../packages/mantle/src/codegen/emitMantleModule.ts) — `bindMantle(...).procedures`
808
+ - [`overlays/transaction/manifests/site.yaml`](https://github.com/aotter/mantle-starters/blob/a66ec0ea3aaefc09a0229b7d8ca35af630f2b55d/overlays/transaction/manifests/site.yaml) — retired full Manifest
809
+ - [`overlays/transaction/src/index.ts`](https://github.com/aotter/mantle-starters/blob/a66ec0ea3aaefc09a0229b7d8ca35af630f2b55d/overlays/transaction/src/index.ts) — queue and scheduled entrypoints
810
+ - [`overlays/transaction/src/commerce/handlers.ts`](https://github.com/aotter/mantle-starters/blob/a66ec0ea3aaefc09a0229b7d8ca35af630f2b55d/overlays/transaction/src/commerce/handlers.ts) — full handler implementation
811
+ - [`overlays/transaction/src/commerce/InventoryCoordinator.ts`](https://github.com/aotter/mantle-starters/blob/a66ec0ea3aaefc09a0229b7d8ca35af630f2b55d/overlays/transaction/src/commerce/InventoryCoordinator.ts) — Durable Object
812
+ - [`overlays/transaction/wrangler.append.toml`](https://github.com/aotter/mantle-starters/blob/a66ec0ea3aaefc09a0229b7d8ca35af630f2b55d/overlays/transaction/wrangler.append.toml)
813
+ - [`overlays/transaction/handoff.md`](https://github.com/aotter/mantle-starters/blob/a66ec0ea3aaefc09a0229b7d8ca35af630f2b55d/overlays/transaction/handoff.md) — "Replacing the demo payment"