@aotter/mantle 0.1.2-alpha.6 → 0.1.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.
- package/README.md +15 -10
- package/dist/cli/generate.d.ts +9 -0
- package/dist/cli/generate.d.ts.map +1 -1
- package/dist/cli/generate.js +40 -1
- package/dist/cli/generate.js.map +1 -1
- package/dist/cli/main.d.ts +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +37 -9
- package/dist/cli/main.js.map +1 -1
- package/docs/adapter-guide.md +6 -1
- package/docs/adr/adr-lite-845-frontend-client.md +38 -0
- package/docs/agent-prompts.md +91 -0
- package/docs/api-mcp-authorization.md +1 -1
- package/docs/auth-hosting-model.md +1 -1
- package/docs/examples/README.md +22 -0
- package/docs/examples/builtin-commerce.md +269 -0
- package/docs/examples/builtin-intake.md +143 -0
- package/docs/examples/builtin-legal-documents.md +189 -0
- package/docs/examples/builtin-procurement.md +241 -0
- package/docs/examples/builtin-publication.md +241 -0
- package/docs/examples/builtin-reservation.md +149 -0
- package/docs/examples/cf-primitives-commerce-inventory.md +809 -0
- package/docs/examples/cf-primitives-guarded-api.md +429 -0
- package/docs/examples/cf-primitives-intake-hooks.md +319 -0
- package/docs/examples/host-chatgpt-sites/.openai/hosting.json +1 -0
- package/docs/examples/host-chatgpt-sites/README.md +53 -0
- package/docs/examples/host-chatgpt-sites/drizzle/0000_sites_users.sql +8 -0
- package/docs/examples/host-chatgpt-sites/drizzle/0001_mantle.sql +294 -0
- package/docs/examples/host-chatgpt-sites/drizzle/0002_article_cover.sql +5 -0
- package/docs/examples/host-chatgpt-sites/drizzle/meta/_journal.json +1 -0
- package/docs/examples/host-chatgpt-sites/manifests/site.yaml +27 -0
- package/docs/examples/host-chatgpt-sites/package.json +1 -0
- package/docs/examples/host-chatgpt-sites/public/site.css +1 -0
- package/docs/examples/host-chatgpt-sites/scripts/build.mjs +12 -0
- package/docs/examples/host-chatgpt-sites/scripts/check.mjs +99 -0
- package/docs/examples/host-chatgpt-sites/scripts/migration.mjs +9 -0
- package/docs/examples/host-chatgpt-sites/src/chatgpt-auth.ts +63 -0
- package/docs/examples/host-chatgpt-sites/src/index.ts +46 -0
- package/docs/examples/host-chatgpt-sites/src/mcp.ts +49 -0
- package/docs/examples/host-chatgpt-sites/src/media.ts +109 -0
- package/docs/examples/host-chatgpt-sites/src/r2-lab.ts +38 -0
- package/docs/examples/host-chatgpt-sites/src/storage-fingerprint.json +1 -0
- package/docs/examples/host-chatgpt-sites/src/web.ts +35 -0
- package/docs/examples/host-chatgpt-sites/tsconfig.json +1 -0
- package/docs/examples/host-chatgpt-sites/wrangler.jsonc +10 -0
- package/docs/examples/host-local-admin-otp/.dev.vars.example +3 -0
- package/docs/examples/host-local-admin-otp/README.md +70 -0
- package/docs/examples/host-local-admin-otp/ensure-dev-vars.mjs +5 -0
- package/docs/examples/host-local-admin-otp/package.json +29 -0
- package/docs/examples/host-local-admin-otp/public/.gitkeep +1 -0
- package/docs/examples/host-local-admin-otp/smoke.mjs +141 -0
- package/docs/examples/host-local-admin-otp/src/index.ts +54 -0
- package/docs/examples/host-local-admin-otp/wrangler.jsonc +23 -0
- package/docs/examples/{minimal-worker → host-minimal-worker}/README.md +9 -5
- package/docs/examples/host-minimal-worker/manifests/site.yaml +25 -0
- package/docs/examples/{minimal-worker → host-minimal-worker}/package.json +3 -3
- package/docs/examples/host-minimal-worker/tsconfig.json +17 -0
- package/docs/handbook/cloudflare/authentication.md +17 -2
- package/docs/handbook/cloudflare/bindings.md +9 -7
- package/docs/handbook/cloudflare/chatgpt-sites.md +29 -0
- package/docs/handbook/cloudflare/conventional-worker.md +3 -3
- package/docs/handbook/cloudflare/deferred-hooks-queues.md +0 -1
- package/docs/handbook/cloudflare/deploy-and-operate.md +14 -20
- package/docs/handbook/cloudflare/media-r2.md +2 -2
- package/docs/handbook/cloudflare/public-web.md +1 -1
- package/docs/handbook/cloudflare/site-chrome.md +75 -0
- package/docs/handbook/concepts/authorization.md +2 -2
- package/docs/handbook/concepts/four-atoms.md +2 -2
- package/docs/handbook/concepts/lifecycle-and-locales.md +1 -1
- package/docs/handbook/concepts/mcp-and-agents.md +1 -1
- package/docs/handbook/concepts/procedures-and-triggers.md +1 -1
- package/docs/handbook/concepts/runtime-and-adapters.md +2 -2
- package/docs/handbook/concepts/views.md +2 -2
- package/docs/handbook/examples/commerce-transaction.md +4 -806
- package/docs/handbook/examples/commerce.md +11 -0
- package/docs/handbook/examples/guarded-api.md +3 -420
- package/docs/handbook/examples/hub.md +10 -0
- package/docs/handbook/examples/intake-form.md +6 -313
- package/docs/handbook/examples/intake-hooks.md +11 -0
- package/docs/handbook/examples/legal-documents.md +3 -211
- package/docs/handbook/examples/procurement-approvals.md +3 -233
- package/docs/handbook/examples/publication.md +3 -233
- package/docs/handbook/examples/reservation.md +3 -213
- package/docs/handbook/navigation.json +17 -2
- package/docs/handbook/reference/authorization.md +1 -1
- package/docs/handbook/reference/procedure.md +2 -2
- package/docs/handbook/reference/schema.md +3 -3
- package/docs/handbook/reference/site-config.md +5 -16
- package/docs/handbook/reference/surface.md +3 -7
- package/docs/handbook/sites/equipment-checkout.md +231 -0
- package/docs/handbook/sites/host-reference.md +113 -0
- package/docs/handbook/sites/index.md +111 -0
- package/docs/handbook/start/project-and-cli.md +22 -14
- package/docs/handbook/start/quickstart-admin.md +239 -0
- package/docs/handbook/start/quickstart-worker.md +22 -23
- package/docs/migration-0.1.2.md +26 -0
- package/docs/release-process.md +92 -7
- package/docs/sealed-pipeline-ownership.md +2 -2
- package/docs/spec-only-host-adoption.md +3 -4
- package/docs/transaction-patterns.md +2 -2
- package/package.json +15 -15
- package/skills/README.md +18 -2
- package/skills/develop/SKILL.md +32 -23
- package/skills/install/SKILL.md +50 -11
- package/skills/provision/SKILL.md +21 -5
- /package/docs/examples/{minimal-worker → host-local-admin-otp}/manifests/site.yaml +0 -0
- /package/docs/examples/{minimal-worker → host-local-admin-otp}/tsconfig.json +0 -0
- /package/docs/examples/{minimal-worker → host-minimal-worker}/smoke.mjs +0 -0
- /package/docs/examples/{minimal-worker → host-minimal-worker}/src/index.ts +0 -0
- /package/docs/examples/{minimal-worker → host-minimal-worker}/wrangler.jsonc +0 -0
|
@@ -0,0 +1,809 @@
|
|
|
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
|
+
**Handler class:** cf-primitives · **Builder:** no · [Examples hub](./README.md). The Builder-safe catalog and orders Manifest (no Durable Object, Queue, payment provider, or `ref` handlers) is [Commerce catalog](./builtin-commerce.md).
|
|
7
|
+
|
|
8
|
+
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.
|
|
9
|
+
|
|
10
|
+
## Problem
|
|
11
|
+
|
|
12
|
+
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.
|
|
13
|
+
|
|
14
|
+
## Manifest
|
|
15
|
+
|
|
16
|
+
Trimmed to the essentials that stay valid together. Localized product copy is omitted; see [Publication](./builtin-publication.md) for the parent/child pattern.
|
|
17
|
+
|
|
18
|
+
```yaml
|
|
19
|
+
apiVersion: cms.mantle.aotter.net/v1
|
|
20
|
+
kind: Schema
|
|
21
|
+
metadata:
|
|
22
|
+
name: products
|
|
23
|
+
spec:
|
|
24
|
+
title: Products
|
|
25
|
+
lifecycle: publishing
|
|
26
|
+
uniqueIndexes:
|
|
27
|
+
- [slug]
|
|
28
|
+
schema:
|
|
29
|
+
type: object
|
|
30
|
+
additionalProperties: false
|
|
31
|
+
required: [slug, title, priceMinor, currency]
|
|
32
|
+
properties:
|
|
33
|
+
slug: { type: string, pattern: "^[a-z0-9-]+$" }
|
|
34
|
+
title: { type: string, minLength: 1, maxLength: 160 }
|
|
35
|
+
summary: { type: string, maxLength: 500 }
|
|
36
|
+
priceMinor: { type: integer, minimum: 0, x-mcp-hint: money-minor }
|
|
37
|
+
currency: { type: string, pattern: "^[A-Z]{3}$" }
|
|
38
|
+
---
|
|
39
|
+
apiVersion: cms.mantle.aotter.net/v1
|
|
40
|
+
kind: Schema
|
|
41
|
+
metadata:
|
|
42
|
+
name: orders
|
|
43
|
+
spec:
|
|
44
|
+
title: Orders
|
|
45
|
+
description: Guest orders created by checkout and maintained only by declared Procedures.
|
|
46
|
+
lifecycle: operational
|
|
47
|
+
uniqueIndexes:
|
|
48
|
+
- [orderToken]
|
|
49
|
+
- [orderNumber]
|
|
50
|
+
indexes:
|
|
51
|
+
- [orderStatus]
|
|
52
|
+
searchableFields: [orderNumber, customerName, customerEmail]
|
|
53
|
+
uiSchema:
|
|
54
|
+
list:
|
|
55
|
+
filterField: orderStatus
|
|
56
|
+
primaryField: orderNumber
|
|
57
|
+
columns: [orderStatus, customerEmail, totalMinor]
|
|
58
|
+
schema:
|
|
59
|
+
readOnly: true
|
|
60
|
+
type: object
|
|
61
|
+
required: [orderToken, orderNumber, orderStatus, currency, totalMinor, customerName, customerEmail, shippingAddress, items, expiresAt]
|
|
62
|
+
properties:
|
|
63
|
+
orderToken: { type: string, pattern: "^[0-9a-f-]{36}$" }
|
|
64
|
+
orderNumber: { type: string }
|
|
65
|
+
orderStatus: { type: string, enum: [pending_payment, paid, fulfilled, cancelled] }
|
|
66
|
+
currency: { type: string, pattern: "^[A-Z]{3}$" }
|
|
67
|
+
totalMinor: { type: integer, minimum: 0, x-mcp-hint: money-minor }
|
|
68
|
+
customerName: { type: string, minLength: 1, maxLength: 120 }
|
|
69
|
+
customerEmail: { type: string, format: email }
|
|
70
|
+
shippingAddress: { type: string, minLength: 1, maxLength: 500 }
|
|
71
|
+
items:
|
|
72
|
+
type: array
|
|
73
|
+
minItems: 1
|
|
74
|
+
maxItems: 20
|
|
75
|
+
items:
|
|
76
|
+
type: object
|
|
77
|
+
required: [productSlug, title, quantity, unitPriceMinor, lineTotalMinor]
|
|
78
|
+
properties:
|
|
79
|
+
productSlug: { type: string }
|
|
80
|
+
title: { type: string }
|
|
81
|
+
quantity: { type: integer, minimum: 1, maximum: 99 }
|
|
82
|
+
unitPriceMinor: { type: integer, minimum: 0 }
|
|
83
|
+
lineTotalMinor: { type: integer, minimum: 0 }
|
|
84
|
+
expiresAt: { type: number, x-mcp-hint: timestamp-ms }
|
|
85
|
+
paidAt: { type: number, x-mcp-hint: timestamp-ms }
|
|
86
|
+
fulfilledAt: { type: number, x-mcp-hint: timestamp-ms }
|
|
87
|
+
cancelledAt: { type: number, x-mcp-hint: timestamp-ms }
|
|
88
|
+
trackingNumber: { type: string }
|
|
89
|
+
cancelReason: { type: string }
|
|
90
|
+
---
|
|
91
|
+
apiVersion: cms.mantle.aotter.net/v1
|
|
92
|
+
kind: Schema
|
|
93
|
+
metadata:
|
|
94
|
+
name: inventory
|
|
95
|
+
spec:
|
|
96
|
+
title: Inventory
|
|
97
|
+
description: Query mirror of the InventoryCoordinator Durable Object, which is the stock authority.
|
|
98
|
+
lifecycle: operational
|
|
99
|
+
uniqueIndexes:
|
|
100
|
+
- [productSlug]
|
|
101
|
+
indexes:
|
|
102
|
+
- [available, productSlug]
|
|
103
|
+
uiSchema:
|
|
104
|
+
list:
|
|
105
|
+
primaryField: productSlug
|
|
106
|
+
columns: [available, reserved, revision]
|
|
107
|
+
nav:
|
|
108
|
+
standalone: true
|
|
109
|
+
parentField: productSlug
|
|
110
|
+
schema:
|
|
111
|
+
readOnly: true
|
|
112
|
+
type: object
|
|
113
|
+
required: [productSlug, available, reserved, revision]
|
|
114
|
+
properties:
|
|
115
|
+
productSlug: { type: string, x-mantle-ref: products }
|
|
116
|
+
available: { type: integer, minimum: 0 }
|
|
117
|
+
reserved: { type: integer, minimum: 0 }
|
|
118
|
+
revision: { type: integer, minimum: 0 }
|
|
119
|
+
---
|
|
120
|
+
apiVersion: cms.mantle.aotter.net/v1
|
|
121
|
+
kind: Schema
|
|
122
|
+
metadata:
|
|
123
|
+
name: inventory-movements
|
|
124
|
+
spec:
|
|
125
|
+
title: Inventory movements
|
|
126
|
+
description: Append-only audit trail for stock changes caused by checkout and staff operations.
|
|
127
|
+
lifecycle: operational
|
|
128
|
+
uniqueIndexes:
|
|
129
|
+
- [movementKey]
|
|
130
|
+
indexes:
|
|
131
|
+
- [kind, occurredAt]
|
|
132
|
+
- [productSlug, occurredAt]
|
|
133
|
+
- [orderToken]
|
|
134
|
+
uiSchema:
|
|
135
|
+
list:
|
|
136
|
+
filterField: kind
|
|
137
|
+
primaryField: productSlug
|
|
138
|
+
columns: [kind, availableDelta, reservedDelta, occurredAt]
|
|
139
|
+
schema:
|
|
140
|
+
readOnly: true
|
|
141
|
+
type: object
|
|
142
|
+
required: [movementKey, productSlug, kind, availableDelta, reservedDelta, occurredAt]
|
|
143
|
+
properties:
|
|
144
|
+
movementKey: { type: string }
|
|
145
|
+
productSlug: { type: string }
|
|
146
|
+
orderToken: { type: string }
|
|
147
|
+
kind: { type: string, enum: [adjust, reserve, sale, release, cancellation] }
|
|
148
|
+
availableDelta: { type: integer }
|
|
149
|
+
reservedDelta: { type: integer }
|
|
150
|
+
note: { type: string }
|
|
151
|
+
occurredAt: { type: number, x-mcp-hint: timestamp-ms }
|
|
152
|
+
---
|
|
153
|
+
apiVersion: cms.mantle.aotter.net/v1
|
|
154
|
+
kind: View
|
|
155
|
+
metadata:
|
|
156
|
+
name: public-products
|
|
157
|
+
spec:
|
|
158
|
+
surface: public
|
|
159
|
+
from: products
|
|
160
|
+
fields: [id, slug, title, summary, priceMinor, currency, updatedAt]
|
|
161
|
+
filter:
|
|
162
|
+
eq: { field: status, value: published }
|
|
163
|
+
orderBy:
|
|
164
|
+
- { field: slug, direction: asc }
|
|
165
|
+
limit: 100
|
|
166
|
+
---
|
|
167
|
+
apiVersion: cms.mantle.aotter.net/v1
|
|
168
|
+
kind: View
|
|
169
|
+
metadata:
|
|
170
|
+
name: picking-list
|
|
171
|
+
spec:
|
|
172
|
+
title: Picking list
|
|
173
|
+
surface: staff
|
|
174
|
+
uiSchema:
|
|
175
|
+
list:
|
|
176
|
+
columns: [orderNumber, customerName, shippingAddress, productSlug, productTitle, quantity]
|
|
177
|
+
searchFields: [orderNumber, customerName, shippingAddress, productSlug, productTitle]
|
|
178
|
+
sql: |
|
|
179
|
+
SELECT
|
|
180
|
+
o.orderNumber,
|
|
181
|
+
o.customerName,
|
|
182
|
+
o.shippingAddress,
|
|
183
|
+
json_extract(item.value, '$.productSlug') AS productSlug,
|
|
184
|
+
json_extract(item.value, '$.title') AS productTitle,
|
|
185
|
+
json_extract(item.value, '$.quantity') AS quantity
|
|
186
|
+
FROM orders AS o
|
|
187
|
+
JOIN json_each(o.items) AS item
|
|
188
|
+
WHERE o.orderStatus = 'paid'
|
|
189
|
+
ORDER BY o._mantle_created_at ASC, o.orderNumber ASC, item.key ASC
|
|
190
|
+
limit: 200
|
|
191
|
+
---
|
|
192
|
+
apiVersion: cms.mantle.aotter.net/v1
|
|
193
|
+
kind: Procedure
|
|
194
|
+
metadata:
|
|
195
|
+
name: place-order
|
|
196
|
+
spec:
|
|
197
|
+
input:
|
|
198
|
+
type: object
|
|
199
|
+
additionalProperties: false
|
|
200
|
+
required: [customerName, customerEmail, shippingAddress, items]
|
|
201
|
+
properties:
|
|
202
|
+
customerName: { type: string, minLength: 1, maxLength: 120 }
|
|
203
|
+
customerEmail: { type: string, format: email }
|
|
204
|
+
shippingAddress: { type: string, minLength: 1, maxLength: 500 }
|
|
205
|
+
items:
|
|
206
|
+
type: array
|
|
207
|
+
minItems: 1
|
|
208
|
+
maxItems: 20
|
|
209
|
+
items:
|
|
210
|
+
type: object
|
|
211
|
+
additionalProperties: false
|
|
212
|
+
required: [productSlug, quantity]
|
|
213
|
+
properties:
|
|
214
|
+
productSlug: { type: string, pattern: "^[a-z0-9-]+$" }
|
|
215
|
+
quantity: { type: integer, minimum: 1, maximum: 99 }
|
|
216
|
+
output:
|
|
217
|
+
type: object
|
|
218
|
+
required: [outcome, orderToken, orderNumber, expiresAt, totalMinor, currency]
|
|
219
|
+
properties:
|
|
220
|
+
outcome: { type: string, enum: [pending_payment] }
|
|
221
|
+
orderToken: { type: string }
|
|
222
|
+
orderNumber: { type: string }
|
|
223
|
+
expiresAt: { type: number }
|
|
224
|
+
totalMinor: { type: integer }
|
|
225
|
+
currency: { type: string }
|
|
226
|
+
handler: { kind: ref, ref: placeOrder }
|
|
227
|
+
---
|
|
228
|
+
apiVersion: cms.mantle.aotter.net/v1
|
|
229
|
+
kind: Procedure
|
|
230
|
+
metadata:
|
|
231
|
+
name: pay-order
|
|
232
|
+
spec:
|
|
233
|
+
input:
|
|
234
|
+
type: object
|
|
235
|
+
additionalProperties: false
|
|
236
|
+
required: [orderToken]
|
|
237
|
+
properties:
|
|
238
|
+
orderToken: { type: string, pattern: "^[0-9a-f-]{36}$" }
|
|
239
|
+
output:
|
|
240
|
+
type: object
|
|
241
|
+
required: [outcome, orderToken]
|
|
242
|
+
properties:
|
|
243
|
+
outcome: { type: string, enum: [paid, already_paid, expired, closed, missing] }
|
|
244
|
+
orderToken: { type: string }
|
|
245
|
+
handler: { kind: ref, ref: payOrder }
|
|
246
|
+
---
|
|
247
|
+
apiVersion: cms.mantle.aotter.net/v1
|
|
248
|
+
kind: Procedure
|
|
249
|
+
metadata:
|
|
250
|
+
name: cancel-guest-order
|
|
251
|
+
spec:
|
|
252
|
+
input:
|
|
253
|
+
type: object
|
|
254
|
+
additionalProperties: false
|
|
255
|
+
required: [orderToken]
|
|
256
|
+
properties:
|
|
257
|
+
orderToken: { type: string, pattern: "^[0-9a-f-]{36}$" }
|
|
258
|
+
output:
|
|
259
|
+
type: object
|
|
260
|
+
required: [outcome, orderToken]
|
|
261
|
+
properties:
|
|
262
|
+
outcome: { type: string, enum: [cancelled, already_cancelled, closed, missing] }
|
|
263
|
+
orderToken: { type: string }
|
|
264
|
+
handler: { kind: ref, ref: cancelGuestOrder }
|
|
265
|
+
---
|
|
266
|
+
apiVersion: cms.mantle.aotter.net/v1
|
|
267
|
+
kind: Procedure
|
|
268
|
+
metadata:
|
|
269
|
+
name: adjust-inventory
|
|
270
|
+
spec:
|
|
271
|
+
title: Adjust inventory
|
|
272
|
+
requires:
|
|
273
|
+
auth:
|
|
274
|
+
all:
|
|
275
|
+
- { "ctx.staff": [owner] }
|
|
276
|
+
input:
|
|
277
|
+
type: object
|
|
278
|
+
additionalProperties: false
|
|
279
|
+
required: [operationId, productSlug, delta, reason]
|
|
280
|
+
properties:
|
|
281
|
+
operationId: { type: string, format: uuid, x-mcp-hint: idempotency-key }
|
|
282
|
+
productSlug: { type: string, pattern: "^[a-z0-9-]+$", x-mantle-ref: products }
|
|
283
|
+
delta: { type: integer, minimum: -100000, maximum: 100000 }
|
|
284
|
+
reason: { type: string, minLength: 1, maxLength: 500 }
|
|
285
|
+
uiSchema:
|
|
286
|
+
fields:
|
|
287
|
+
reason: { widget: textarea }
|
|
288
|
+
output:
|
|
289
|
+
type: object
|
|
290
|
+
required: [productSlug, available, reserved, revision]
|
|
291
|
+
properties:
|
|
292
|
+
productSlug: { type: string }
|
|
293
|
+
available: { type: integer }
|
|
294
|
+
reserved: { type: integer }
|
|
295
|
+
revision: { type: integer }
|
|
296
|
+
handler: { kind: ref, ref: adjustInventory }
|
|
297
|
+
---
|
|
298
|
+
apiVersion: cms.mantle.aotter.net/v1
|
|
299
|
+
kind: Procedure
|
|
300
|
+
metadata:
|
|
301
|
+
name: fulfill-order
|
|
302
|
+
spec:
|
|
303
|
+
title: Fulfill order
|
|
304
|
+
requires:
|
|
305
|
+
auth:
|
|
306
|
+
all:
|
|
307
|
+
- { "ctx.staff": [owner] }
|
|
308
|
+
input:
|
|
309
|
+
type: object
|
|
310
|
+
additionalProperties: false
|
|
311
|
+
required: [orderToken]
|
|
312
|
+
properties:
|
|
313
|
+
orderToken: { type: string, pattern: "^[0-9a-f-]{36}$", x-mantle-ref: orders }
|
|
314
|
+
trackingNumber: { type: string, maxLength: 120 }
|
|
315
|
+
output:
|
|
316
|
+
type: object
|
|
317
|
+
required: [outcome, orderToken]
|
|
318
|
+
properties:
|
|
319
|
+
outcome: { type: string, enum: [fulfilled, already_fulfilled, closed, missing] }
|
|
320
|
+
orderToken: { type: string }
|
|
321
|
+
handler: { kind: ref, ref: fulfillOrder }
|
|
322
|
+
---
|
|
323
|
+
apiVersion: cms.mantle.aotter.net/v1
|
|
324
|
+
kind: Procedure
|
|
325
|
+
metadata:
|
|
326
|
+
name: expire-order
|
|
327
|
+
spec:
|
|
328
|
+
input:
|
|
329
|
+
type: object
|
|
330
|
+
additionalProperties: false
|
|
331
|
+
required: [orderToken, now]
|
|
332
|
+
properties:
|
|
333
|
+
orderToken: { type: string, pattern: "^[0-9a-f-]{36}$" }
|
|
334
|
+
now: { type: number }
|
|
335
|
+
output:
|
|
336
|
+
type: object
|
|
337
|
+
required: [outcome, orderToken]
|
|
338
|
+
properties:
|
|
339
|
+
outcome: { type: string, enum: [expired, too_early, closed, missing] }
|
|
340
|
+
orderToken: { type: string }
|
|
341
|
+
handler: { kind: ref, ref: expireOrder }
|
|
342
|
+
---
|
|
343
|
+
apiVersion: cms.mantle.aotter.net/v1
|
|
344
|
+
kind: Procedure
|
|
345
|
+
metadata:
|
|
346
|
+
name: sweep-expired-orders
|
|
347
|
+
spec:
|
|
348
|
+
input:
|
|
349
|
+
type: object
|
|
350
|
+
additionalProperties: false
|
|
351
|
+
required: [now]
|
|
352
|
+
properties:
|
|
353
|
+
now: { type: number }
|
|
354
|
+
output:
|
|
355
|
+
type: object
|
|
356
|
+
required: [checked, expired]
|
|
357
|
+
properties:
|
|
358
|
+
checked: { type: integer }
|
|
359
|
+
expired: { type: integer }
|
|
360
|
+
handler: { kind: ref, ref: sweepExpiredOrders }
|
|
361
|
+
---
|
|
362
|
+
apiVersion: cms.mantle.aotter.net/v1
|
|
363
|
+
kind: Trigger
|
|
364
|
+
metadata:
|
|
365
|
+
name: place-order-http
|
|
366
|
+
spec:
|
|
367
|
+
source: { kind: http, method: POST, path: /api/commerce/orders }
|
|
368
|
+
target: { procedure: place-order }
|
|
369
|
+
---
|
|
370
|
+
# Demo only. Remove before connecting a real provider; see "Where the payment provider plugs in".
|
|
371
|
+
apiVersion: cms.mantle.aotter.net/v1
|
|
372
|
+
kind: Trigger
|
|
373
|
+
metadata:
|
|
374
|
+
name: pay-order-http
|
|
375
|
+
spec:
|
|
376
|
+
source: { kind: http, method: POST, path: /api/commerce/orders/pay }
|
|
377
|
+
target: { procedure: pay-order }
|
|
378
|
+
---
|
|
379
|
+
apiVersion: cms.mantle.aotter.net/v1
|
|
380
|
+
kind: Trigger
|
|
381
|
+
metadata:
|
|
382
|
+
name: cancel-guest-order-http
|
|
383
|
+
spec:
|
|
384
|
+
source: { kind: http, method: POST, path: /api/commerce/orders/cancel }
|
|
385
|
+
target: { procedure: cancel-guest-order }
|
|
386
|
+
---
|
|
387
|
+
apiVersion: cms.mantle.aotter.net/v1
|
|
388
|
+
kind: Trigger
|
|
389
|
+
metadata:
|
|
390
|
+
name: adjust-inventory-mcp
|
|
391
|
+
spec:
|
|
392
|
+
source: { kind: mcp, surface: staff }
|
|
393
|
+
target: { procedure: adjust-inventory }
|
|
394
|
+
---
|
|
395
|
+
apiVersion: cms.mantle.aotter.net/v1
|
|
396
|
+
kind: Trigger
|
|
397
|
+
metadata:
|
|
398
|
+
name: fulfill-order-mcp
|
|
399
|
+
spec:
|
|
400
|
+
source: { kind: mcp, surface: staff }
|
|
401
|
+
target: { procedure: fulfill-order }
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
Points worth noticing:
|
|
405
|
+
|
|
406
|
+
- `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.
|
|
407
|
+
- `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.
|
|
408
|
+
- `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.
|
|
409
|
+
- `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.
|
|
410
|
+
- `picking-list` is a raw `sql` staff View using `json_each` to unnest order lines. `sql` Views run on SQLite storage only.
|
|
411
|
+
|
|
412
|
+
See [Reads: Views, REST and MCP](../handbook/concepts/views.md) and the [Schema reference](../handbook/reference/schema.md).
|
|
413
|
+
|
|
414
|
+
## Worker and handlers
|
|
415
|
+
|
|
416
|
+
### Bindings and Worker entry
|
|
417
|
+
|
|
418
|
+
```ts
|
|
419
|
+
// src/env.ts
|
|
420
|
+
import type { MantleCloudflareEnv } from "@aotter/mantle/cloudflare";
|
|
421
|
+
import type { InventoryCoordinator } from "./commerce/InventoryCoordinator.js";
|
|
422
|
+
|
|
423
|
+
export type ExpiryMessage = { readonly type: "expire-order"; readonly orderToken: string };
|
|
424
|
+
|
|
425
|
+
export interface Env extends MantleCloudflareEnv {
|
|
426
|
+
readonly INVENTORY_COORDINATOR: DurableObjectNamespace<InventoryCoordinator>;
|
|
427
|
+
readonly ORDER_EXPIRY_QUEUE: Queue<ExpiryMessage>;
|
|
428
|
+
readonly PAYMENT_WEBHOOK_SECRET?: string;
|
|
429
|
+
}
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
```toml
|
|
433
|
+
# wrangler.toml (additions)
|
|
434
|
+
[[durable_objects.bindings]]
|
|
435
|
+
name = "INVENTORY_COORDINATOR"
|
|
436
|
+
class_name = "InventoryCoordinator"
|
|
437
|
+
|
|
438
|
+
[[migrations]]
|
|
439
|
+
tag = "inventory-v1"
|
|
440
|
+
new_sqlite_classes = ["InventoryCoordinator"]
|
|
441
|
+
|
|
442
|
+
[[queues.producers]]
|
|
443
|
+
binding = "ORDER_EXPIRY_QUEUE"
|
|
444
|
+
queue = "shop-order-expiry"
|
|
445
|
+
|
|
446
|
+
[[queues.consumers]]
|
|
447
|
+
queue = "shop-order-expiry"
|
|
448
|
+
max_concurrency = 1
|
|
449
|
+
max_batch_size = 10
|
|
450
|
+
max_retries = 5
|
|
451
|
+
retry_delay = 30
|
|
452
|
+
dead_letter_queue = "shop-order-expiry-dlq"
|
|
453
|
+
|
|
454
|
+
[triggers]
|
|
455
|
+
crons = ["*/5 * * * *"]
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
```ts
|
|
459
|
+
// src/index.ts
|
|
460
|
+
import { createMantleWorker } from "@aotter/mantle/cloudflare";
|
|
461
|
+
import { bindMantle, plan } from "../.mantle/generated/mantle.js";
|
|
462
|
+
import { buildCommerceHandlers } from "./commerce/handlers.js";
|
|
463
|
+
import { verifyProviderEvent } from "./commerce/provider.js";
|
|
464
|
+
import type { Env, ExpiryMessage } from "./env.js";
|
|
465
|
+
|
|
466
|
+
export { InventoryCoordinator } from "./commerce/InventoryCoordinator.js";
|
|
467
|
+
|
|
468
|
+
const worker = createMantleWorker<Env>({
|
|
469
|
+
plan,
|
|
470
|
+
extend: ({ getRuntime, env }) => ({
|
|
471
|
+
handlers: buildCommerceHandlers(getRuntime),
|
|
472
|
+
mount({ app }) {
|
|
473
|
+
// Provider callback: raw body + signature, outside the JSON HTTP Trigger path.
|
|
474
|
+
app.post("/payments/callback", async (c) => {
|
|
475
|
+
const raw = await c.req.text();
|
|
476
|
+
const event = await verifyProviderEvent(raw, c.req.header("x-provider-signature"), env.PAYMENT_WEBHOOK_SECRET);
|
|
477
|
+
if (!event) return c.text("invalid signature", 400);
|
|
478
|
+
if (event.type !== "payment.succeeded") return c.text("ignored", 200);
|
|
479
|
+
const api = bindMantle(await getRuntime());
|
|
480
|
+
const result = await api.procedures.payOrder(
|
|
481
|
+
{ orderToken: event.orderToken },
|
|
482
|
+
{ user: null, staff: null, env, waitUntil: (p) => c.executionCtx.waitUntil(p) },
|
|
483
|
+
);
|
|
484
|
+
if (!result.ok) return c.text(result.diagnostic.code, result.diagnostic.code === "INTERNAL_ERROR" ? 500 : 200);
|
|
485
|
+
return c.text("OK", 200); // paid, already_paid, expired, closed, missing are all terminal for the provider
|
|
486
|
+
});
|
|
487
|
+
},
|
|
488
|
+
}),
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
function internalContext(env: Env, ctx: ExecutionContext) {
|
|
492
|
+
return {
|
|
493
|
+
user: null,
|
|
494
|
+
staff: null,
|
|
495
|
+
env,
|
|
496
|
+
waitUntil: (promise: Promise<unknown>) => ctx.waitUntil(promise),
|
|
497
|
+
} as const;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
export default {
|
|
501
|
+
fetch: worker.fetch,
|
|
502
|
+
|
|
503
|
+
async queue(batch: MessageBatch<ExpiryMessage>, env: Env, ctx: ExecutionContext): Promise<void> {
|
|
504
|
+
let api: ReturnType<typeof bindMantle>;
|
|
505
|
+
try {
|
|
506
|
+
api = bindMantle(await worker.getRuntime(env));
|
|
507
|
+
} catch (error) {
|
|
508
|
+
console.error("[order-expiry] runtime unavailable", error);
|
|
509
|
+
batch.retryAll();
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
for (const message of batch.messages) {
|
|
513
|
+
const body = message.body;
|
|
514
|
+
if (body?.type !== "expire-order" || typeof body.orderToken !== "string") {
|
|
515
|
+
console.error("[order-expiry] discarded malformed message", message.id);
|
|
516
|
+
message.ack();
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
try {
|
|
520
|
+
const result = await api.procedures.expireOrder({ orderToken: body.orderToken, now: Date.now() }, internalContext(env, ctx));
|
|
521
|
+
if (!result.ok) {
|
|
522
|
+
if (result.diagnostic.code === "INTERNAL_ERROR" || result.diagnostic.code === "CONFLICT") message.retry();
|
|
523
|
+
else message.ack();
|
|
524
|
+
} else if (result.data.outcome === "too_early") {
|
|
525
|
+
message.retry({ delaySeconds: 60 });
|
|
526
|
+
} else {
|
|
527
|
+
message.ack();
|
|
528
|
+
}
|
|
529
|
+
} catch (error) {
|
|
530
|
+
console.error("[order-expiry] transient failure", message.id, error);
|
|
531
|
+
message.retry();
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
},
|
|
535
|
+
|
|
536
|
+
async scheduled(_controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
|
|
537
|
+
const api = bindMantle(await worker.getRuntime(env));
|
|
538
|
+
const result = await api.procedures.sweepExpiredOrders({ now: Date.now() }, internalContext(env, ctx));
|
|
539
|
+
if (!result.ok) throw new Error(`expiry sweep failed: ${result.diagnostic.code}`);
|
|
540
|
+
},
|
|
541
|
+
} satisfies ExportedHandler<Env, ExpiryMessage>;
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
`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 }`.
|
|
545
|
+
|
|
546
|
+
`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.
|
|
547
|
+
|
|
548
|
+
### Inventory authority in a Durable Object
|
|
549
|
+
|
|
550
|
+
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.
|
|
551
|
+
|
|
552
|
+
```ts
|
|
553
|
+
// src/commerce/InventoryCoordinator.ts (abridged)
|
|
554
|
+
import { DurableObject } from "cloudflare:workers";
|
|
555
|
+
|
|
556
|
+
export type StockItem = { readonly productSlug: string; readonly quantity: number };
|
|
557
|
+
export type StockSnapshot = { readonly productSlug: string; readonly available: number; readonly reserved: number; readonly revision: number };
|
|
558
|
+
type OrderState = { readonly items: readonly StockItem[]; readonly expiresAt: number; readonly status: "pending_payment" | "paid" | "fulfilled" | "cancelled" | "expired" };
|
|
559
|
+
|
|
560
|
+
export class InventoryCoordinator extends DurableObject {
|
|
561
|
+
reserve(orderId: string, items: readonly StockItem[], expiresAt: number) {
|
|
562
|
+
return this.ctx.storage.transaction(async (txn) => {
|
|
563
|
+
const existing = await txn.get<OrderState>(`order:${orderId}`);
|
|
564
|
+
if (existing) return existing.status === "pending_payment" ? { outcome: "already_reserved" as const } : { outcome: "closed" as const };
|
|
565
|
+
// read every SKU; reject if any lacks stock; otherwise available -= q, reserved += q, revision += 1
|
|
566
|
+
// then txn.put(`order:${orderId}`, { items, expiresAt, status: "pending_payment" })
|
|
567
|
+
return { outcome: "reserved" as const, snapshots: [] as StockSnapshot[] };
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
pay(orderId: string, now: number) {
|
|
572
|
+
return this.ctx.storage.transaction(async (txn) => {
|
|
573
|
+
const order = await txn.get<OrderState>(`order:${orderId}`);
|
|
574
|
+
if (!order) return { outcome: "missing" as const };
|
|
575
|
+
if (order.status === "paid") return { outcome: "already_paid" as const, items: order.items };
|
|
576
|
+
if (order.status !== "pending_payment") return { outcome: "closed" as const };
|
|
577
|
+
if (order.expiresAt <= now) return release(txn, orderId, order, "expired"); // late payment: release, do not sell
|
|
578
|
+
// reserved -= q for each item; status -> paid
|
|
579
|
+
return { outcome: "paid" as const, items: order.items };
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
expire(orderId: string, now: number) { /* pending_payment && expiresAt <= now -> release(...,"expired"); else too_early / closed / missing */ }
|
|
584
|
+
cancel(orderId: string) { /* pending_payment -> release; paid -> available += q; fulfilled|expired -> closed */ }
|
|
585
|
+
fulfill(orderId: string) { /* paid -> fulfilled; repeat -> already_fulfilled */ }
|
|
586
|
+
|
|
587
|
+
adjust(operationId: string, productSlug: string, delta: number, reason: string) {
|
|
588
|
+
return this.ctx.storage.transaction(async (txn) => {
|
|
589
|
+
const applied = await txn.get<{ productSlug: string; delta: number; reason: string; snapshot: StockSnapshot }>(`adjustment:${operationId}`);
|
|
590
|
+
if (applied) {
|
|
591
|
+
return applied.productSlug === productSlug && applied.delta === delta && applied.reason === reason
|
|
592
|
+
? { outcome: "already_adjusted" as const, snapshot: applied.snapshot }
|
|
593
|
+
: { outcome: "idempotency_conflict" as const };
|
|
594
|
+
}
|
|
595
|
+
// available + delta must stay >= 0; write inventory:<slug> and adjustment:<operationId>
|
|
596
|
+
return { outcome: "adjusted" as const, snapshot: {} as StockSnapshot };
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
```
|
|
601
|
+
|
|
602
|
+
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.
|
|
603
|
+
|
|
604
|
+
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.
|
|
605
|
+
|
|
606
|
+
```ts
|
|
607
|
+
// src/commerce/handlers.ts (abridged)
|
|
608
|
+
import type { HandlerContext, MantleRuntime } from "@aotter/mantle/runtime";
|
|
609
|
+
import { DiagnosticError, runtimeDiagnostic } from "@aotter/mantle/spec";
|
|
610
|
+
import type { MantleHandlers } from "../../.mantle/generated/mantle.js";
|
|
611
|
+
import type { Env } from "../env.js";
|
|
612
|
+
|
|
613
|
+
const CHECKOUT_TTL_MS = 15 * 60 * 1000;
|
|
614
|
+
const inventory = (env: Env) => env.INVENTORY_COORDINATOR.getByName("site");
|
|
615
|
+
|
|
616
|
+
export function buildCommerceHandlers(getRuntime: () => Promise<MantleRuntime>): MantleHandlers<Env> {
|
|
617
|
+
return {
|
|
618
|
+
placeOrder: async (input, ctx) => {
|
|
619
|
+
const runtime = await getRuntime();
|
|
620
|
+
const orderToken = crypto.randomUUID();
|
|
621
|
+
// 1. re-price on the server from published products; reject unknown slugs or mixed currencies
|
|
622
|
+
const priced = await priceItems(runtime, input.items);
|
|
623
|
+
const now = Date.now();
|
|
624
|
+
const expiresAt = now + CHECKOUT_TTL_MS;
|
|
625
|
+
// 2. reserve in the DO first; insufficient stock -> CONFLICT
|
|
626
|
+
const reserved = await inventory(ctx.env).reserve(orderToken, priced.stockItems, expiresAt);
|
|
627
|
+
if (reserved.outcome !== "reserved") throw conflict("/items", reserved, "quantities currently in stock");
|
|
628
|
+
// 3. project into Mantle; on failure compensate in the DO and remove any partial row
|
|
629
|
+
let created: { id: string } | null = null;
|
|
630
|
+
try {
|
|
631
|
+
created = await runtime.createDraft.execute({
|
|
632
|
+
collection: "orders",
|
|
633
|
+
authorId: null,
|
|
634
|
+
ctx,
|
|
635
|
+
data: { orderToken, orderNumber: orderNumber(now, orderToken), orderStatus: "pending_payment", ...priced.orderFields(input), expiresAt },
|
|
636
|
+
});
|
|
637
|
+
await recordStockChange(runtime, reserved.snapshots, priced.stockItems, "reserve", orderToken, ctx);
|
|
638
|
+
} catch (error) {
|
|
639
|
+
await inventory(ctx.env).cancel(orderToken);
|
|
640
|
+
if (created) await runtime.deleteEntry.execute({ id: created.id, collection: "orders", ctx });
|
|
641
|
+
throw error;
|
|
642
|
+
}
|
|
643
|
+
// 4. schedule expiry; the cron sweep covers a lost message
|
|
644
|
+
const enqueue = ctx.env.ORDER_EXPIRY_QUEUE.send({ type: "expire-order", orderToken }, { contentType: "json", delaySeconds: CHECKOUT_TTL_MS / 1000 })
|
|
645
|
+
.catch((error) => console.error(`[commerce] could not enqueue expiry for ${orderToken}`, error));
|
|
646
|
+
if (ctx.waitUntil) ctx.waitUntil(enqueue); else await enqueue;
|
|
647
|
+
return { outcome: "pending_payment", orderToken, orderNumber: orderNumber(now, orderToken), expiresAt, totalMinor: priced.totalMinor, currency: priced.currency };
|
|
648
|
+
},
|
|
649
|
+
|
|
650
|
+
payOrder: async ({ orderToken }, ctx) => {
|
|
651
|
+
const runtime = await getRuntime();
|
|
652
|
+
const order = await runtime.entries.readByDataField({ collection: "orders", field: "orderToken", value: orderToken });
|
|
653
|
+
if (!order) return { outcome: "missing", orderToken };
|
|
654
|
+
const result = await inventory(ctx.env).pay(orderToken, Date.now());
|
|
655
|
+
if (result.outcome === "paid" || result.outcome === "already_paid") {
|
|
656
|
+
await recordStockChange(runtime, result.snapshots ?? [], result.items ?? [], "sale", orderToken, ctx);
|
|
657
|
+
await updateOrder(runtime, order, { orderStatus: "paid", paidAt: order.data.paidAt ?? Date.now() }, ctx); // keep the first paidAt
|
|
658
|
+
} else if (result.outcome === "expired") {
|
|
659
|
+
await expirePersistedOrder(runtime, order, result, ctx);
|
|
660
|
+
}
|
|
661
|
+
return { outcome: result.outcome === "paid" || result.outcome === "already_paid" || result.outcome === "expired" ? result.outcome : "closed", orderToken };
|
|
662
|
+
},
|
|
663
|
+
|
|
664
|
+
expireOrder: async ({ orderToken, now }, ctx) => {
|
|
665
|
+
const runtime = await getRuntime();
|
|
666
|
+
const result = await inventory(ctx.env).expire(orderToken, now);
|
|
667
|
+
const order = await runtime.entries.readByDataField({ collection: "orders", field: "orderToken", value: orderToken });
|
|
668
|
+
if (result.outcome === "expired" && order) await expirePersistedOrder(runtime, order, result, ctx);
|
|
669
|
+
return { outcome: result.outcome === "expired" || result.outcome === "too_early" || result.outcome === "missing" ? result.outcome : "closed", orderToken };
|
|
670
|
+
},
|
|
671
|
+
|
|
672
|
+
sweepExpiredOrders: async ({ now }, ctx) => {
|
|
673
|
+
const runtime = await getRuntime();
|
|
674
|
+
const pending = await runtime.listEntries.execute({ collection: "orders", filter: { field: "orderStatus", value: "pending_payment" }, limit: 100 });
|
|
675
|
+
let expired = 0;
|
|
676
|
+
for (const order of pending) {
|
|
677
|
+
if ((order.data.expiresAt as number) > now) continue;
|
|
678
|
+
const result = await inventory(ctx.env).expire(order.data.orderToken as string, now);
|
|
679
|
+
if (result.outcome === "expired") { await expirePersistedOrder(runtime, order, result, ctx); expired += 1; }
|
|
680
|
+
}
|
|
681
|
+
return { checked: pending.length, expired };
|
|
682
|
+
},
|
|
683
|
+
|
|
684
|
+
adjustInventory: async ({ operationId, productSlug, delta, reason }, ctx) => {
|
|
685
|
+
const runtime = await getRuntime();
|
|
686
|
+
const result = await inventory(ctx.env).adjust(operationId, productSlug, delta, reason);
|
|
687
|
+
if (result.outcome === "idempotency_conflict") throw conflict("/operationId", operationId, "an idempotency key used with the same adjustment input");
|
|
688
|
+
if (result.outcome === "insufficient_stock") throw conflict("/delta", delta, "an adjustment that keeps available stock non-negative");
|
|
689
|
+
await persistSnapshots(runtime, [result.snapshot], ctx);
|
|
690
|
+
await ensureMovement(runtime, `adjust:${operationId}`, { productSlug, kind: "adjust", availableDelta: delta, reservedDelta: 0, note: reason }, ctx);
|
|
691
|
+
return result.snapshot;
|
|
692
|
+
},
|
|
693
|
+
|
|
694
|
+
fulfillOrder: async ({ orderToken, trackingNumber }, ctx) => { /* DO fulfill -> updateOrder({ orderStatus: "fulfilled", fulfilledAt, trackingNumber }) */ },
|
|
695
|
+
cancelGuestOrder: async ({ orderToken }, ctx) => { /* only pending_payment; DO cancel -> release movements + updateOrder({ orderStatus: "cancelled", cancelledAt, cancelReason }) */ },
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function conflict(path: string, value: unknown, expected: string): DiagnosticError {
|
|
700
|
+
return new DiagnosticError(runtimeDiagnostic({ code: "CONFLICT", severity: "error", path, value, expected }));
|
|
701
|
+
}
|
|
702
|
+
```
|
|
703
|
+
|
|
704
|
+
`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 sketched above; expand them in your Worker.
|
|
705
|
+
|
|
706
|
+
### Delayed expiry with Queues and a cron sweep
|
|
707
|
+
|
|
708
|
+
`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:
|
|
709
|
+
|
|
710
|
+
| Consumer observation | Action | Why |
|
|
711
|
+
|---|---|---|
|
|
712
|
+
| runtime boot fails | `batch.retryAll()` | nothing was processed |
|
|
713
|
+
| malformed body | `ack()` and log | retrying cannot fix it |
|
|
714
|
+
| Procedure `ok: false`, code `INTERNAL_ERROR` or `CONFLICT` | `retry()` | transient or racing with pay/cancel |
|
|
715
|
+
| Procedure `ok: false`, any other code | `ack()` and log | permanent (for example validation) |
|
|
716
|
+
| `outcome: too_early` | `retry({ delaySeconds: 60 })` | delivered before `expiresAt` |
|
|
717
|
+
| `outcome: expired`, `closed` or `missing` | `ack()` | terminal |
|
|
718
|
+
| handler threw | `retry()` | dispatch failure |
|
|
719
|
+
|
|
720
|
+
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](../handbook/cloudflare/deferred-hooks-queues.md).
|
|
721
|
+
|
|
722
|
+
### Where the payment provider plugs in
|
|
723
|
+
|
|
724
|
+
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.
|
|
725
|
+
|
|
726
|
+
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:
|
|
727
|
+
|
|
728
|
+
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.
|
|
729
|
+
2. Map the verified event to an `orderToken` (store it as the provider's client reference when creating the provider session).
|
|
730
|
+
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.
|
|
731
|
+
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.
|
|
732
|
+
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.
|
|
733
|
+
|
|
734
|
+
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`.
|
|
735
|
+
|
|
736
|
+
## Try it
|
|
737
|
+
|
|
738
|
+
Place an order:
|
|
739
|
+
|
|
740
|
+
```sh
|
|
741
|
+
curl -sS -X POST http://localhost:8787/api/commerce/orders \
|
|
742
|
+
-H 'content-type: application/json' \
|
|
743
|
+
-d '{"customerName":"Ada","customerEmail":"ada@example.test","shippingAddress":"1 Shell Lane","items":[{"productSlug":"notebook","quantity":2}]}'
|
|
744
|
+
```
|
|
745
|
+
|
|
746
|
+
```json
|
|
747
|
+
{
|
|
748
|
+
"ok": true,
|
|
749
|
+
"data": {
|
|
750
|
+
"outcome": "pending_payment",
|
|
751
|
+
"orderToken": "5b7d1a8e-4c2f-4f7e-9d1a-0b6c2e8f3a11",
|
|
752
|
+
"orderNumber": "MNT-20260910-5B7D1A8E",
|
|
753
|
+
"expiresAt": 1788880263492,
|
|
754
|
+
"totalMinor": 2400,
|
|
755
|
+
"currency": "TWD"
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
```
|
|
759
|
+
|
|
760
|
+
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.
|
|
761
|
+
|
|
762
|
+
Public catalog:
|
|
763
|
+
|
|
764
|
+
```sh
|
|
765
|
+
curl -sS 'http://localhost:8787/api/views/public-products?show=20'
|
|
766
|
+
# {"ok":true,"data":{"rows":[{"id":"...","slug":"notebook","title":"Notebook","priceMinor":1200,"currency":"TWD","updatedAt":...}],"page":1,"show":20,"hasMore":false}}
|
|
767
|
+
```
|
|
768
|
+
|
|
769
|
+
Staff, on `/mcp/staff`:
|
|
770
|
+
|
|
771
|
+
```json
|
|
772
|
+
{
|
|
773
|
+
"jsonrpc": "2.0", "id": 3, "method": "tools/call",
|
|
774
|
+
"params": {
|
|
775
|
+
"name": "adjust_inventory",
|
|
776
|
+
"arguments": { "operationId": "0d1e2f3a-4b5c-4d6e-8f90-a1b2c3d4e5f6", "productSlug": "notebook", "delta": 50, "reason": "Restock from supplier" }
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
```
|
|
780
|
+
|
|
781
|
+
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`.
|
|
782
|
+
|
|
783
|
+
| Surface | Tool | Origin |
|
|
784
|
+
|---|---|---|
|
|
785
|
+
| `/mcp` | `query_view_public_products` | public View |
|
|
786
|
+
| `/mcp/staff` | `query_view_picking_list` | staff `sql` View |
|
|
787
|
+
| `/mcp/staff` | `adjust_inventory`, `fulfill_order` | staff MCP Triggers |
|
|
788
|
+
| `/mcp/staff` | `create_draft_products`, `update_draft_products`, `request_publish`, ... | publishing Schema `products` |
|
|
789
|
+
|
|
790
|
+
`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.
|
|
791
|
+
|
|
792
|
+
## What this deliberately leaves out
|
|
793
|
+
|
|
794
|
+
- **A real provider SDK.** `verifyProviderEvent` is a placeholder for the chosen provider's signature check.
|
|
795
|
+
- **Tax, shipping, discounts.** `totalMinor` equals the sum of line totals.
|
|
796
|
+
- **Refunds.** No Procedure moves a `paid` order to refunded, and a staff cancel that returns stock still does not move money.
|
|
797
|
+
- **Multi-currency carts.** All lines must share one currency; mixed carts are rejected.
|
|
798
|
+
- **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.
|
|
799
|
+
- **Customer accounts.** Orders are guest orders keyed by token; see [Procurement approvals](./builtin-procurement.md) for `ctx.user`-owned rows.
|
|
800
|
+
|
|
801
|
+
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).
|
|
802
|
+
|
|
803
|
+
## Source
|
|
804
|
+
|
|
805
|
+
- [`docs/transaction-patterns.md`](../transaction-patterns.md) — reserve/settle exactly once, delayed expiry, adoption checks
|
|
806
|
+
- [Schema](../handbook/reference/schema.md), [Procedure](../handbook/reference/procedure.md), and [View](../handbook/reference/view.md) references — `readOnly`, `x-mantle-ref`, `idempotency-key`, `sql` Views
|
|
807
|
+
- [`docs/deferred-lifecycle-queues.md`](../deferred-lifecycle-queues.md) — Queue contract and multiplexing
|
|
808
|
+
- [`packages/adapters/cloudflare/src/worker/createMantleWorker.ts`](../../packages/adapters/cloudflare/src/worker/createMantleWorker.ts) — `extend.mount`, `getRuntime`
|
|
809
|
+
- [`packages/mantle/src/codegen/emitMantleModule.ts`](../../packages/mantle/src/codegen/emitMantleModule.ts) — `bindMantle(...).procedures`
|