@tokenoftrust/storefront-runner 6.5.0 → 6.5.1
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/apps/storefront/migrations-apps/0026_fulfillment_assignment.sql +44 -0
- package/apps/storefront/src/lib/fulfillment/admin/commerceDesk.ts +21 -1
- package/apps/storefront/src/lib/fulfillment/assignmentStore.ts +284 -0
- package/apps/storefront/src/lib/fulfillment/commerceProjection.ts +2 -0
- package/apps/storefront/src/lib/fulfillment/commerceRecovery.ts +18 -1
- package/apps/storefront/src/lib/fulfillment/commerceRuntime.ts +28 -0
- package/apps/storefront/src/lib/fulfillment/fulfillmentService.ts +429 -70
- package/apps/storefront/src/lib/integrations/apps/registryService.ts +92 -65
- package/apps/storefront/src/lib/integrations/webhooks/commercePayloads.ts +101 -0
- package/apps/storefront/src/lib/integrations/webhooks/deliveryEngine.ts +17 -34
- package/apps/storefront/src/lib/integrations/webhooks/deliveryStore.ts +130 -0
- package/apps/storefront/src/lib/integrations/webhooks/dispatcher.ts +1 -1
- package/apps/storefront/src/lib/integrations/webhooks/getDispatcher.ts +8 -3
- package/apps/storefront/src/lib/integrations/webhooks/orderFulfilledClaim.ts +75 -0
- package/apps/storefront/src/lib/orders/data/orderForwardReceiver.ts +65 -4
- package/package.json +1 -1
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `order.fulfilled` is claimed once per order across its two producers: the fulfillment handoff
|
|
3
|
+
* and the order-forward receiver. The claim is keyed (tenant, stage, source, source order id,
|
|
4
|
+
* topic); each producer writes its claim and its gated event rows in one batch, and only the
|
|
5
|
+
* producer whose claim carries its event id writes the event.
|
|
6
|
+
*/
|
|
7
|
+
import type { CommerceStage } from "@tot/public-runtime";
|
|
8
|
+
import type { CommerceEventStatement } from "./deliveryStore.js";
|
|
9
|
+
|
|
10
|
+
export interface OrderFulfilledClaimKey {
|
|
11
|
+
tenantId: string;
|
|
12
|
+
stage: CommerceStage;
|
|
13
|
+
source: string;
|
|
14
|
+
sourceOrderId: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The forward contract's `env` mapped to a commerce stage; any other value claims and emits nothing. */
|
|
18
|
+
export function forwardStage(env: string): CommerceStage | null {
|
|
19
|
+
if (env === "production") return "publication";
|
|
20
|
+
if (env === "sandbox") return "preview";
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The handoff's claim: written only when no claim exists and `gate` holds (the handoff's CAS committed). */
|
|
25
|
+
export function handoffClaimStatement(
|
|
26
|
+
key: OrderFulfilledClaimKey,
|
|
27
|
+
eventId: string,
|
|
28
|
+
now: string,
|
|
29
|
+
gate: CommerceEventStatement,
|
|
30
|
+
): CommerceEventStatement {
|
|
31
|
+
return {
|
|
32
|
+
sql: `INSERT INTO commerce_event_claims
|
|
33
|
+
(tenant_id, stage, source, source_order_id, topic, event_id, claimed_by, claimed_at)
|
|
34
|
+
SELECT ?, ?, ?, ?, 'order.fulfilled', ?, 'handoff', ?
|
|
35
|
+
WHERE NOT EXISTS (
|
|
36
|
+
SELECT 1 FROM commerce_event_claims
|
|
37
|
+
WHERE tenant_id = ? AND stage = ? AND source = ? AND source_order_id = ? AND topic = 'order.fulfilled'
|
|
38
|
+
) AND (${gate.sql})`,
|
|
39
|
+
params: [
|
|
40
|
+
key.tenantId,
|
|
41
|
+
key.stage,
|
|
42
|
+
key.source,
|
|
43
|
+
key.sourceOrderId,
|
|
44
|
+
eventId,
|
|
45
|
+
now,
|
|
46
|
+
key.tenantId,
|
|
47
|
+
key.stage,
|
|
48
|
+
key.source,
|
|
49
|
+
key.sourceOrderId,
|
|
50
|
+
...gate.params,
|
|
51
|
+
],
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The forward receiver's claim. */
|
|
56
|
+
export function forwardClaimStatement(key: OrderFulfilledClaimKey, eventId: string, now: string): CommerceEventStatement {
|
|
57
|
+
return {
|
|
58
|
+
sql: `INSERT OR IGNORE INTO commerce_event_claims
|
|
59
|
+
(tenant_id, stage, source, source_order_id, topic, event_id, claimed_by, claimed_at)
|
|
60
|
+
VALUES (?, ?, ?, ?, 'order.fulfilled', ?, 'forward', ?)`,
|
|
61
|
+
params: [key.tenantId, key.stage, key.source, key.sourceOrderId, eventId, now],
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Event gate: the claim for `key` carries `eventId`. */
|
|
66
|
+
export function claimedByEventGate(key: OrderFulfilledClaimKey, eventId: string): CommerceEventStatement {
|
|
67
|
+
return {
|
|
68
|
+
sql: `EXISTS (
|
|
69
|
+
SELECT 1 FROM commerce_event_claims
|
|
70
|
+
WHERE tenant_id = ? AND stage = ? AND source = ? AND source_order_id = ? AND topic = 'order.fulfilled'
|
|
71
|
+
AND event_id = ?
|
|
72
|
+
)`,
|
|
73
|
+
params: [key.tenantId, key.stage, key.source, key.sourceOrderId, eventId],
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -21,6 +21,11 @@
|
|
|
21
21
|
* corresponding topic — e.g. -> `cancelled`, which V1 has no topic for)
|
|
22
22
|
* -> no emit; the row still updates, silently.
|
|
23
23
|
*
|
|
24
|
+
* `order.fulfilled` is claimed once per order (`orderFulfilledClaim.ts`) under the key the signed
|
|
25
|
+
* intake and the fulfillment handoff use: (tenant, stage from `env`, `foxycart`, external order id).
|
|
26
|
+
* The claim, the event and its deliveries commit in one batch; a forward whose order is already
|
|
27
|
+
* claimed writes nothing, and an `env` other than production or sandbox emits no `order.fulfilled`.
|
|
28
|
+
*
|
|
24
29
|
* Customer identity: `rawCustomerId` is optional (tot-foxycart's own
|
|
25
30
|
* order-forward payload doesn't always carry one — mirrors `FoxyOrder`'s
|
|
26
31
|
* own optional `customerEmail`). This receiver never derives, stores, or
|
|
@@ -36,7 +41,8 @@ import { SourceAuthorityStore } from "./sourceAuthorityStore.js";
|
|
|
36
41
|
import { SourceAuthorityError, parseSourceWire, exactKeys, digestJson, type BeginAck, type ResultAck, type AuthorityCode, isRecord, isSourceId } from "./sourceAuthorityContract.js";
|
|
37
42
|
import { OrdersStore, ScrappyOrderLedger, type OrderLineItem, type OrderStatus, type Order } from "./ordersStore.js";
|
|
38
43
|
import { emitWebhookEvent } from "../../integrations/webhooks/emit.js";
|
|
39
|
-
import type
|
|
44
|
+
import { commerceEventStatements, commerceTargetsQuery, type WebhookTopic } from "../../integrations/webhooks/deliveryStore.js";
|
|
45
|
+
import { claimedByEventGate, forwardClaimStatement, forwardStage, type OrderFulfilledClaimKey } from "../../integrations/webhooks/orderFulfilledClaim.js";
|
|
40
46
|
import type { Queryable } from "../../catalog/d1/catalog.js";
|
|
41
47
|
import {
|
|
42
48
|
createLocalJWKSet,
|
|
@@ -125,7 +131,11 @@ function topicsFor(previousStatus: OrderStatus | null, status: OrderStatus): Web
|
|
|
125
131
|
* verified the request's HMAC signature — this function never re-checks
|
|
126
132
|
* authenticity and must never be reached from an unverified request.
|
|
127
133
|
*/
|
|
128
|
-
export async function receiveForwardedOrder(
|
|
134
|
+
export async function receiveForwardedOrder(
|
|
135
|
+
db: Queryable,
|
|
136
|
+
input: OrderForwardInput,
|
|
137
|
+
options: { notify?: (tenantId: string, deliveryIds: string[]) => void | Promise<void> } = {},
|
|
138
|
+
): Promise<OrderForwardResult> {
|
|
129
139
|
const store = new OrdersStore(db);
|
|
130
140
|
// input.rawCustomerId is intentionally not read here — see the module doc's
|
|
131
141
|
// "Customer identity" note for why no hash is derived from it.
|
|
@@ -142,14 +152,65 @@ export async function receiveForwardedOrder(db: Queryable, input: OrderForwardIn
|
|
|
142
152
|
createdAt: input.createdAt ?? new Date().toISOString(),
|
|
143
153
|
});
|
|
144
154
|
|
|
145
|
-
const emitted =
|
|
146
|
-
for (const topic of
|
|
155
|
+
const emitted: WebhookTopic[] = [];
|
|
156
|
+
for (const topic of topicsFor(previousStatus, input.status)) {
|
|
157
|
+
if (topic === "order.fulfilled") {
|
|
158
|
+
if (await emitClaimedOrderFulfilled(db, input, order, options.notify)) emitted.push(topic);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
147
161
|
await emitWebhookEvent(topic, input.tenantId, toOrderData(order), {});
|
|
162
|
+
emitted.push(topic);
|
|
148
163
|
}
|
|
149
164
|
|
|
150
165
|
return { order, previousStatus, emitted };
|
|
151
166
|
}
|
|
152
167
|
|
|
168
|
+
/** Claims `order.fulfilled` for this order and, only under that claim, writes its event and deliveries. */
|
|
169
|
+
async function emitClaimedOrderFulfilled(
|
|
170
|
+
db: Queryable,
|
|
171
|
+
input: OrderForwardInput,
|
|
172
|
+
order: Order,
|
|
173
|
+
notify: ((tenantId: string, deliveryIds: string[]) => void | Promise<void>) | undefined,
|
|
174
|
+
): Promise<boolean> {
|
|
175
|
+
const stage = forwardStage(input.env);
|
|
176
|
+
if (!stage) return false;
|
|
177
|
+
if (!db.batch) throw new Error("Atomic D1 batch support is required.");
|
|
178
|
+
const key: OrderFulfilledClaimKey = {
|
|
179
|
+
tenantId: input.tenantId,
|
|
180
|
+
stage,
|
|
181
|
+
source: "foxycart",
|
|
182
|
+
sourceOrderId: input.externalOrderId,
|
|
183
|
+
};
|
|
184
|
+
const eventId = crypto.randomUUID();
|
|
185
|
+
const now = new Date().toISOString();
|
|
186
|
+
const targets = commerceTargetsQuery(input.tenantId, "order.fulfilled");
|
|
187
|
+
const installs = await db.all<{ install_id: string }>(targets.sql, ...targets.params);
|
|
188
|
+
const deliveries = installs.map((row) => ({ installId: row.install_id, deliveryId: crypto.randomUUID() }));
|
|
189
|
+
await db.batch([
|
|
190
|
+
forwardClaimStatement(key, eventId, now),
|
|
191
|
+
...commerceEventStatements({
|
|
192
|
+
eventId,
|
|
193
|
+
tenantId: input.tenantId,
|
|
194
|
+
topic: "order.fulfilled",
|
|
195
|
+
payload: toOrderData(order),
|
|
196
|
+
gate: claimedByEventGate(key, eventId),
|
|
197
|
+
targets: deliveries,
|
|
198
|
+
now,
|
|
199
|
+
}),
|
|
200
|
+
]);
|
|
201
|
+
const written = await db.first<{ ok: number }>(`SELECT 1 AS ok FROM webhook_events WHERE id = ?`, eventId);
|
|
202
|
+
if (!written) return false;
|
|
203
|
+
const committed = await db.all<{ delivery_id: string }>(`SELECT delivery_id FROM webhook_deliveries WHERE event_id = ?`, eventId);
|
|
204
|
+
if (notify && committed.length > 0) {
|
|
205
|
+
try {
|
|
206
|
+
await notify(input.tenantId, committed.map((row) => row.delivery_id));
|
|
207
|
+
} catch {
|
|
208
|
+
// The committed deliveries stay pending; the tenant commerce alarm redelivers them.
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
213
|
+
|
|
153
214
|
export interface CommerceCallerConfig {
|
|
154
215
|
expectedStage: CommerceStage;
|
|
155
216
|
jwks: JSONWebKeySet;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "6.5.
|
|
3
|
+
"version": "6.5.1",
|
|
4
4
|
"license": "SEE LICENSE IN LICENSE",
|
|
5
5
|
"description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
|
|
6
6
|
"packageManager": "pnpm@11.9.0",
|