@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.
@@ -0,0 +1,44 @@
1
+ -- Processor config: one row per (tenant, stage) naming the install that processes released orders.
2
+ -- A cleared config keeps the row with state `cleared`; rows are never deleted.
3
+ CREATE TABLE `fulfillment_processor_config` (
4
+ `tenant_id` text NOT NULL,
5
+ `stage` text NOT NULL CHECK (`stage` IN ('preview', 'publication')),
6
+ `install_id` text NOT NULL,
7
+ `state` text NOT NULL CHECK (`state` IN ('active', 'cleared')),
8
+ `updated_by` text NOT NULL,
9
+ `updated_at` text NOT NULL,
10
+ PRIMARY KEY (`tenant_id`, `stage`)
11
+ );
12
+ --> statement-breakpoint
13
+ -- Assignments move by state and keep history; none is deleted.
14
+ CREATE TABLE `fulfillment_assignments` (
15
+ `assignment_id` text PRIMARY KEY NOT NULL,
16
+ `tenant_id` text NOT NULL,
17
+ `stage` text NOT NULL CHECK (`stage` IN ('preview', 'publication')),
18
+ `order_id` text NOT NULL,
19
+ `install_id` text NOT NULL,
20
+ `state` text NOT NULL CHECK (`state` IN ('active', 'held', 'cancelled', 'released')),
21
+ `event_version` integer NOT NULL CHECK (`event_version` >= 0),
22
+ `assigned_at` text NOT NULL,
23
+ `updated_at` text NOT NULL
24
+ );
25
+ --> statement-breakpoint
26
+ CREATE UNIQUE INDEX `fulfillment_assignments_open_order`
27
+ ON `fulfillment_assignments` (`tenant_id`, `stage`, `order_id`)
28
+ WHERE `state` IN ('active', 'held');
29
+ --> statement-breakpoint
30
+ CREATE INDEX `fulfillment_assignments_install_state`
31
+ ON `fulfillment_assignments` (`tenant_id`, `stage`, `install_id`, `state`, `assigned_at`);
32
+ --> statement-breakpoint
33
+ -- One claim per business event across every emitter that can report it.
34
+ CREATE TABLE `commerce_event_claims` (
35
+ `tenant_id` text NOT NULL,
36
+ `stage` text NOT NULL CHECK (`stage` IN ('preview', 'publication')),
37
+ `source` text NOT NULL,
38
+ `source_order_id` text NOT NULL,
39
+ `topic` text NOT NULL CHECK (`topic` IN ('order.fulfilled')),
40
+ `event_id` text NOT NULL,
41
+ `claimed_by` text NOT NULL CHECK (`claimed_by` IN ('handoff', 'forward')),
42
+ `claimed_at` text NOT NULL,
43
+ PRIMARY KEY (`tenant_id`, `stage`, `source`, `source_order_id`, `topic`)
44
+ );
@@ -5,6 +5,7 @@ export function mountCommerceDesk(root: HTMLElement): void {
5
5
  const status = root.querySelector<HTMLElement>("[data-desk-status]")!;
6
6
  const rows = root.querySelector<HTMLElement>("[data-desk-orders]")!;
7
7
  const next = root.querySelector<HTMLButtonElement>("[data-desk-next]")!;
8
+ const processor = root.querySelector<HTMLElement>("[data-desk-processor]");
8
9
  const api = parseCommerceDeskApiRoot(root.dataset.commerceApi, root.dataset.targetTenant);
9
10
  if (!api) {
10
11
  rows.replaceChildren();
@@ -38,6 +39,10 @@ export function mountCommerceDesk(root: HTMLElement): void {
38
39
  text("p", `${order.payment} · ${order.fulfillment} · ${order.batch?.phase ?? "unclaimed"}`, card);
39
40
  text("p", order.lines.map((line) => `${line.sku} × ${line.quantity}`).join(", ") || "No verified line items", card);
40
41
  text("p", order.holds.length ? `Held: ${order.holds.join(", ")}` : "Ready", card);
42
+ if (order.assignment) {
43
+ const owner = text("p", `Assigned to processor ${order.assignment.installId} · ${order.assignment.state}`, card);
44
+ owner.dataset.assignmentState = order.assignment.state;
45
+ }
41
46
  const actions = order.batch
42
47
  ? [
43
48
  ["pick", "Confirm pick"],
@@ -91,6 +96,17 @@ export function mountCommerceDesk(root: HTMLElement): void {
91
96
  };
92
97
  }
93
98
  }
99
+ async function loadProcessor(): Promise<void> {
100
+ if (!processor) return;
101
+ try {
102
+ const value = (await request("/processor")) as { processor: { installId: string; notifiable: boolean } | null };
103
+ processor.textContent = value.processor
104
+ ? `Processor: ${value.processor.installId}${value.processor.notifiable ? "" : " (not receiving assignments)"}`
105
+ : "Processor: none. Released orders use the label path.";
106
+ } catch {
107
+ processor.textContent = "Processor configuration is visible to the store owner only.";
108
+ }
109
+ }
94
110
  async function load(after: string | null): Promise<void> {
95
111
  status.textContent = "Loading orders…";
96
112
  try {
@@ -115,7 +131,11 @@ export function mountCommerceDesk(root: HTMLElement): void {
115
131
  status.textContent = "Orders are unavailable. Refresh and try again.";
116
132
  }
117
133
  }
118
- root.querySelector<HTMLButtonElement>("[data-desk-refresh]")!.onclick = () => void load(null);
134
+ root.querySelector<HTMLButtonElement>("[data-desk-refresh]")!.onclick = () => {
135
+ void loadProcessor();
136
+ void load(null);
137
+ };
119
138
  next.onclick = () => void load(cursor);
139
+ void loadProcessor();
120
140
  void load(null);
121
141
  }
@@ -0,0 +1,284 @@
1
+ /**
2
+ * Processor configuration and fulfillment assignments.
3
+ *
4
+ * A (tenant, stage) names at most one processor install. Operator release writes the order's
5
+ * assignment to that install; hold, cancel and refund move it by state; switching or clearing the
6
+ * processor, and suspending, uninstalling or descoping its install, release it. Rows are never
7
+ * deleted: assignments keep their history and a config clears by state.
8
+ */
9
+ import type { CommerceStage } from "@tot/public-runtime";
10
+ import type { FulfillmentDatabase, SqlStatement } from "./store.js";
11
+
12
+ export const PROCESSOR_SCOPE = "fulfillment:process";
13
+
14
+ export type AssignmentState = "active" | "held" | "cancelled" | "released";
15
+
16
+ export interface ProcessorConfig {
17
+ installId: string;
18
+ /** The install is active on this tenant, has an endpoint and holds `fulfillment:process`. */
19
+ notifiable: boolean;
20
+ }
21
+
22
+ export interface OpenAssignment {
23
+ assignmentId: string;
24
+ installId: string;
25
+ state: "active" | "held";
26
+ eventVersion: number;
27
+ assignedAt: string;
28
+ }
29
+
30
+ /** SQL condition: install `?` is active on tenant `?`, has an endpoint and holds the processor scope. */
31
+ const NOTIFIABLE_INSTALL = `EXISTS (
32
+ SELECT 1 FROM app_installs AS pi
33
+ WHERE pi.install_id = ? AND pi.tenant_id = ? AND pi.status = 'active' AND pi.managed_kind IS NULL
34
+ AND COALESCE(pi.webhook_endpoint, '') != ''
35
+ AND EXISTS (SELECT 1 FROM json_each(pi.scopes) WHERE json_each.value = '${PROCESSOR_SCOPE}')
36
+ )`;
37
+
38
+ export async function readProcessor(
39
+ db: Pick<FulfillmentDatabase, "get">,
40
+ tenantId: string,
41
+ stage: CommerceStage,
42
+ ): Promise<ProcessorConfig | null> {
43
+ const config = await db.get<{ install_id: string }>(
44
+ `SELECT install_id FROM fulfillment_processor_config WHERE tenant_id = ? AND stage = ? AND state = 'active'`,
45
+ [tenantId, stage],
46
+ );
47
+ if (!config) return null;
48
+ const notifiable = await db.get<{ ok: number }>(`SELECT 1 AS ok WHERE ${NOTIFIABLE_INSTALL}`, [config.install_id, tenantId]);
49
+ return { installId: config.install_id, notifiable: notifiable?.ok === 1 };
50
+ }
51
+
52
+ /** True when `installId` could be the processor for `tenantId`: active there, with an endpoint and the scope. */
53
+ export async function installMayProcess(
54
+ db: Pick<FulfillmentDatabase, "get">,
55
+ tenantId: string,
56
+ installId: string,
57
+ ): Promise<boolean> {
58
+ const row = await db.get<{ ok: number }>(`SELECT 1 AS ok WHERE ${NOTIFIABLE_INSTALL}`, [installId, tenantId]);
59
+ return row?.ok === 1;
60
+ }
61
+
62
+ /**
63
+ * Sets or clears the (tenant, stage) processor. Switching or clearing releases the previous
64
+ * install's active and held assignments on that stage in the same batch; the config write is the
65
+ * batch's compare-and-set, so a concurrent change aborts it.
66
+ */
67
+ export async function setProcessor(
68
+ db: FulfillmentDatabase,
69
+ input: { tenantId: string; stage: CommerceStage; installId: string | null; actorId: string; now: string },
70
+ ): Promise<{ ok: true; installId: string | null } | { ok: false; code: "install-not-found" | "conflict" }> {
71
+ for (let attempt = 0; attempt < 3; attempt++) {
72
+ const current = await readProcessor(db, input.tenantId, input.stage);
73
+ if ((current?.installId ?? null) === input.installId) return { ok: true, installId: input.installId };
74
+ if (input.installId !== null && !(await installMayProcess(db, input.tenantId, input.installId))) {
75
+ return { ok: false, code: "install-not-found" };
76
+ }
77
+ let first: SqlStatement;
78
+ if (input.installId === null) {
79
+ first = {
80
+ sql: `UPDATE fulfillment_processor_config SET state = 'cleared', updated_by = ?, updated_at = ?
81
+ WHERE tenant_id = ? AND stage = ? AND state = 'active' AND install_id = ?`,
82
+ params: [input.actorId, input.now, input.tenantId, input.stage, current!.installId],
83
+ };
84
+ } else if (current) {
85
+ first = {
86
+ sql: `UPDATE fulfillment_processor_config SET install_id = ?, updated_by = ?, updated_at = ?
87
+ WHERE tenant_id = ? AND stage = ? AND state = 'active' AND install_id = ? AND ${NOTIFIABLE_INSTALL}`,
88
+ params: [input.installId, input.actorId, input.now, input.tenantId, input.stage, current.installId, input.installId, input.tenantId],
89
+ };
90
+ } else {
91
+ first = {
92
+ sql: `INSERT INTO fulfillment_processor_config (tenant_id, stage, install_id, state, updated_by, updated_at)
93
+ SELECT ?, ?, ?, 'active', ?, ? WHERE ${NOTIFIABLE_INSTALL}
94
+ ON CONFLICT (tenant_id, stage) DO UPDATE SET
95
+ install_id = excluded.install_id, state = 'active', updated_by = excluded.updated_by, updated_at = excluded.updated_at
96
+ WHERE fulfillment_processor_config.state = 'cleared'`,
97
+ params: [input.tenantId, input.stage, input.installId, input.actorId, input.now, input.installId, input.tenantId],
98
+ };
99
+ }
100
+ const statements: SqlStatement[] = [
101
+ first,
102
+ { sql: `SELECT json(CASE WHEN changes() >= 1 THEN 'true' ELSE 'cas-not-applied' END)`, params: [] },
103
+ ];
104
+ if (current) {
105
+ statements.push(
106
+ releaseInstallAssignmentsStatement({ tenantId: input.tenantId, installId: current.installId, now: input.now, stage: input.stage }),
107
+ );
108
+ }
109
+ try {
110
+ const changes = await db.batch(statements);
111
+ if ((changes[0] ?? 0) > 0) return { ok: true, installId: input.installId };
112
+ } catch (error) {
113
+ if (!isCasMiss(error)) throw error;
114
+ }
115
+ }
116
+ return { ok: false, code: "conflict" };
117
+ }
118
+
119
+ function isCasMiss(error: unknown): boolean {
120
+ let cause: unknown = error;
121
+ for (let depth = 0; depth < 4 && cause instanceof Error; depth++, cause = cause.cause) {
122
+ if (cause.message.includes("malformed JSON")) return true;
123
+ }
124
+ return false;
125
+ }
126
+
127
+ export async function readOpenAssignment(
128
+ db: Pick<FulfillmentDatabase, "get">,
129
+ tenantId: string,
130
+ stage: CommerceStage,
131
+ orderId: string,
132
+ ): Promise<OpenAssignment | null> {
133
+ const row = await db.get<{
134
+ assignment_id: string;
135
+ install_id: string;
136
+ state: string;
137
+ event_version: number;
138
+ assigned_at: string;
139
+ }>(
140
+ `SELECT assignment_id, install_id, state, event_version, assigned_at
141
+ FROM fulfillment_assignments
142
+ WHERE tenant_id = ? AND stage = ? AND order_id = ? AND state IN ('active', 'held')`,
143
+ [tenantId, stage, orderId],
144
+ );
145
+ if (!row || (row.state !== "active" && row.state !== "held")) return null;
146
+ return {
147
+ assignmentId: row.assignment_id,
148
+ installId: row.install_id,
149
+ state: row.state,
150
+ eventVersion: Number(row.event_version),
151
+ assignedAt: row.assigned_at,
152
+ };
153
+ }
154
+
155
+ /** SQL condition (no leading AND): the (tenant, stage) config names `installId` and that install is notifiable. */
156
+ export function processorGuard(tenantId: string, stage: CommerceStage, installId: string): SqlStatement {
157
+ return {
158
+ sql: `EXISTS (
159
+ SELECT 1 FROM fulfillment_processor_config AS pc
160
+ WHERE pc.tenant_id = ? AND pc.stage = ? AND pc.install_id = ? AND pc.state = 'active'
161
+ ) AND ${NOTIFIABLE_INSTALL}`,
162
+ params: [tenantId, stage, installId, installId, tenantId],
163
+ };
164
+ }
165
+
166
+ function orderAtVersion(tenantId: string, stage: CommerceStage, orderId: string, version: number): SqlStatement {
167
+ return {
168
+ sql: `EXISTS (
169
+ SELECT 1 FROM scrappy_commerce_orders AS ov
170
+ WHERE ov.tenant_id = ? AND ov.stage = ? AND ov.order_id = ? AND ov.event_version = ?
171
+ )`,
172
+ params: [tenantId, stage, orderId, version],
173
+ };
174
+ }
175
+
176
+ /** Writes the active assignment only when the order committed at `version` in this batch. */
177
+ export function insertAssignmentStatement(input: {
178
+ assignmentId: string;
179
+ tenantId: string;
180
+ stage: CommerceStage;
181
+ orderId: string;
182
+ installId: string;
183
+ version: number;
184
+ now: string;
185
+ }): SqlStatement {
186
+ const gate = orderAtVersion(input.tenantId, input.stage, input.orderId, input.version);
187
+ return {
188
+ sql: `INSERT INTO fulfillment_assignments
189
+ (assignment_id, tenant_id, stage, order_id, install_id, state, event_version, assigned_at, updated_at)
190
+ SELECT ?, ?, ?, ?, ?, 'active', ?, ?, ? WHERE ${gate.sql}`,
191
+ params: [
192
+ input.assignmentId,
193
+ input.tenantId,
194
+ input.stage,
195
+ input.orderId,
196
+ input.installId,
197
+ input.version,
198
+ input.now,
199
+ input.now,
200
+ ...gate.params,
201
+ ],
202
+ };
203
+ }
204
+
205
+ /** Moves one assignment from any of `from` to `to` when the order committed at `version`, and `guard` holds. */
206
+ export function moveAssignmentStatement(input: {
207
+ assignmentId: string;
208
+ tenantId: string;
209
+ stage: CommerceStage;
210
+ orderId: string;
211
+ from: readonly AssignmentState[];
212
+ to: AssignmentState;
213
+ version: number;
214
+ now: string;
215
+ guard?: SqlStatement;
216
+ unlessGuard?: boolean;
217
+ }): SqlStatement {
218
+ const gate = orderAtVersion(input.tenantId, input.stage, input.orderId, input.version);
219
+ const marks = input.from.map(() => "?").join(", ");
220
+ const guard = input.guard ? ` AND ${input.unlessGuard ? "NOT " : ""}(${input.guard.sql})` : "";
221
+ return {
222
+ sql: `UPDATE fulfillment_assignments SET state = ?, event_version = ?, updated_at = ?
223
+ WHERE assignment_id = ? AND tenant_id = ? AND stage = ? AND order_id = ? AND state IN (${marks})
224
+ AND ${gate.sql}${guard}`,
225
+ params: [
226
+ input.to,
227
+ input.version,
228
+ input.now,
229
+ input.assignmentId,
230
+ input.tenantId,
231
+ input.stage,
232
+ input.orderId,
233
+ ...input.from,
234
+ ...gate.params,
235
+ ...(input.guard?.params ?? []),
236
+ ],
237
+ };
238
+ }
239
+
240
+ /** Event gate: the assignment stands at `state` as of `version` — i.e. the transition it reports committed. */
241
+ export function assignmentAtGate(assignmentId: string, state: AssignmentState, version: number): SqlStatement {
242
+ return {
243
+ sql: `EXISTS (SELECT 1 FROM fulfillment_assignments WHERE assignment_id = ? AND state = ? AND event_version = ?)`,
244
+ params: [assignmentId, state, version],
245
+ };
246
+ }
247
+
248
+ /** Releases every open assignment `installId` holds on `tenantId` (optionally one stage). No event. */
249
+ export function releaseInstallAssignmentsStatement(input: {
250
+ tenantId: string;
251
+ installId: string;
252
+ now: string;
253
+ stage?: CommerceStage;
254
+ }): SqlStatement {
255
+ return {
256
+ sql: `UPDATE fulfillment_assignments SET state = 'released', updated_at = ?
257
+ WHERE tenant_id = ? AND install_id = ? AND state IN ('active', 'held')${input.stage ? " AND stage = ?" : ""}`,
258
+ params: [input.now, input.tenantId, input.installId, ...(input.stage ? [input.stage] : [])],
259
+ };
260
+ }
261
+
262
+ /** Clears every active processor config naming `installId` on `tenantId`. */
263
+ export function clearInstallProcessorStatement(input: {
264
+ tenantId: string;
265
+ installId: string;
266
+ actor: string;
267
+ now: string;
268
+ }): SqlStatement {
269
+ return {
270
+ sql: `UPDATE fulfillment_processor_config SET state = 'cleared', updated_by = ?, updated_at = ?
271
+ WHERE tenant_id = ? AND install_id = ? AND state = 'active'`,
272
+ params: [input.actor, input.now, input.tenantId, input.installId],
273
+ };
274
+ }
275
+
276
+ /** The statements that detach `installId` from fulfillment on `tenantId`: release its assignments, clear its config. */
277
+ export function detachInstallStatements(input: {
278
+ tenantId: string;
279
+ installId: string;
280
+ actor: string;
281
+ now: string;
282
+ }): SqlStatement[] {
283
+ return [releaseInstallAssignmentsStatement(input), clearInstallProcessorStatement(input)];
284
+ }
@@ -5,6 +5,7 @@ import { foldCommerceEvents, type StoredCommercePayload } from "../orders/data/o
5
5
  import { admissionBindsOrder, currentCartEvent, readCommerceAdmission } from "../checkout/commerce/commerceAdmission.js";
6
6
  import { FulfillmentService } from "./fulfillmentService.js";
7
7
  import { fulfillmentDbFromQueryable } from "./store.js";
8
+ import { dispatchCommerceDeliveries } from "./commerceRuntime.js";
8
9
  const UNKNOWN: CommerceEvidence = { verification: "UNKNOWN", tax: "UNKNOWN", risk: "UNKNOWN", stock: "UNKNOWN" };
9
10
 
10
11
  export async function projectCommerceOrder(
@@ -87,6 +88,7 @@ export async function projectCommerceOrder(
87
88
  const service = new FulfillmentService(fulfillmentDbFromQueryable(db), {
88
89
  policy: { adultSignatureRequired: false, prohibitedCarriers: [], carrierAccountId: null },
89
90
  now: () => input.now,
91
+ notify: dispatchCommerceDeliveries,
90
92
  });
91
93
  const applied = await service.applySourceSnapshot(
92
94
  { role: "owner", actorId: "commerce-source", tenantId: input.tenantId, stage: input.stage },
@@ -3,6 +3,10 @@ import { ScrappyOrderLedger } from "../orders/data/ordersStore.js";
3
3
  import { COMMERCE_SCHEDULER, type CommerceStage } from "@tot/public-runtime";
4
4
  import type { Queryable } from "../catalog/d1/catalog.js";
5
5
  import { sweepDurableCommerce } from "../integrations/webhooks/deliveryEngine.js";
6
+ import { COMMERCE_DELIVERY_GRACE_MS, dueCommerceDeliveries } from "../integrations/webhooks/deliveryStore.js";
7
+ import { deliveryAttemptFor } from "../integrations/webhooks/getDispatcher.js";
8
+ import type { AttemptDeliveryFn } from "../integrations/webhooks/dispatcher.js";
9
+
6
10
  import { projectCommerceOrder } from "./commerceProjection.js";
7
11
 
8
12
  export async function recoverTenantCommerce(
@@ -10,6 +14,7 @@ export async function recoverTenantCommerce(
10
14
  tenantId: string,
11
15
  now: string,
12
16
  deployedSha: string,
17
+ attempt: AttemptDeliveryFn = deliveryAttemptFor(db),
13
18
  ): Promise<number | null> {
14
19
  let incomplete=false;
15
20
  for (const stage of ["preview", "publication"] as const) {
@@ -48,12 +53,24 @@ export async function recoverTenantCommerce(
48
53
  runId,
49
54
  );
50
55
  }
56
+ const webhooks = await dueCommerceDeliveries(db, tenantId, now, COMMERCE_DELIVERY_GRACE_MS);
57
+ for (const deliveryId of webhooks.due) {
58
+ try {
59
+ await attempt(deliveryId);
60
+ } catch {
61
+ incomplete = true;
62
+ }
63
+ }
51
64
  if(incomplete)throw new Error("commerce-recovery-incomplete");
52
65
  const due = await db.first<{ available_at: string }>(
53
66
  `SELECT MIN(available_at) available_at FROM scrappy_commerce_outbox WHERE tenant_id=? AND state='pending'`,
54
67
  tenantId,
55
68
  );
56
- return due?.available_at ? Math.max(Date.parse(now) + 1000, Date.parse(due.available_at)) : null;
69
+ const after = await dueCommerceDeliveries(db, tenantId, now, COMMERCE_DELIVERY_GRACE_MS);
70
+ const candidates = [due?.available_at, after.nextAt, after.due.length > 0 ? now : null]
71
+ .filter((value): value is string => typeof value === "string")
72
+ .map((value) => Date.parse(value));
73
+ return candidates.length > 0 ? Math.max(Date.parse(now) + 1000, Math.min(...candidates)) : null;
57
74
  }
58
75
 
59
76
  export async function replayCommerceOrder(
@@ -1,9 +1,12 @@
1
1
  /** Commerce reaches the existing tenant scheduler through its own narrow RPC interface. */
2
2
  import { getWorkerEnv } from "../publication/shared/runtimeEnv.js";
3
3
  import type { CommerceCallerConfig, SignedCommerceResult } from "../orders/data/orderForwardReceiver.js";
4
+ import { COMMERCE_DELIVERY_GRACE_MS } from "../integrations/webhooks/deliveryStore.js";
5
+ import { getDispatcher } from "../integrations/webhooks/getDispatcher.js";
4
6
  export interface CommercePipeline {
5
7
  commerceReceive(body: unknown, token: string, config: CommerceCallerConfig): Promise<SignedCommerceResult>;
6
8
  commerceReplay(tenantId: string, stage: "preview" | "publication", outboxId: string): Promise<boolean>;
9
+ scheduleCommerce(tenantId: string, at: number): Promise<void>;
7
10
  }
8
11
  export async function commercePipelineFor(tenantId: string): Promise<CommercePipeline | null> {
9
12
  const namespace = (await getWorkerEnv())?.TENANT_PIPELINE as
@@ -11,3 +14,28 @@ export async function commercePipelineFor(tenantId: string): Promise<CommercePip
11
14
  | undefined;
12
15
  return namespace && typeof namespace.idFromName === "function" ? namespace.get(namespace.idFromName(tenantId)) : null;
13
16
  }
17
+
18
+ /**
19
+ * After a commerce commit: arm the tenant's commerce alarm past the enqueue grace, then enqueue
20
+ * each committed delivery. Both are best effort; a delivery neither path reaches stays pending
21
+ * and the alarm redelivers it under the same id.
22
+ */
23
+ export async function dispatchCommerceDeliveries(
24
+ tenantId: string,
25
+ deliveryIds: readonly string[],
26
+ now: number = Date.now(),
27
+ ): Promise<void> {
28
+ if (deliveryIds.length === 0) return;
29
+ try {
30
+ await (await commercePipelineFor(tenantId))?.scheduleCommerce(tenantId, now + COMMERCE_DELIVERY_GRACE_MS + 1000);
31
+ } catch {
32
+ // The delivery rows are committed; the next alarm or commerce receipt for this tenant sweeps them.
33
+ }
34
+ try {
35
+ const dispatcher = await getDispatcher();
36
+ if (dispatcher instanceof Response) return;
37
+ for (const deliveryId of deliveryIds) await dispatcher.enqueue({ deliveryId });
38
+ } catch {
39
+ // Same: the pending rows remain the durable record.
40
+ }
41
+ }