@tokenoftrust/storefront-runner 2.4.52 → 2.4.54

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.
@@ -10,7 +10,22 @@
10
10
  * `AppRegistryService` uses, so this runs unchanged against real D1 in the
11
11
  * Worker and an in-memory `node:sqlite` in tests.
12
12
  */
13
- import type { Queryable } from "../../d1/catalog.js";
13
+ import type { Queryable, QueryStatement } from "../../d1/catalog.js";
14
+ import {
15
+ canonicalJson,
16
+ idempotencyKey,
17
+ paginateOrders,
18
+ sha256Hex,
19
+ type CommerceStage,
20
+ type PaymentState,
21
+ } from "@tot/public-runtime";
22
+ import {
23
+ foldCommerceEvents,
24
+ packCommercePayload,
25
+ type CommerceSnapshot,
26
+ type CommerceSourceEvent,
27
+ type StoredCommercePayload,
28
+ } from "./orderMapper.js";
14
29
 
15
30
  export type OrderStatus = "created" | "fulfilled" | "refunded" | "cancelled";
16
31
 
@@ -179,3 +194,318 @@ export class OrdersStore {
179
194
  return row ? toDomain(row) : null;
180
195
  }
181
196
  }
197
+
198
+ interface CommerceOrderRow {
199
+ tenant_id: string;
200
+ stage: string;
201
+ order_id: string;
202
+ source: string;
203
+ source_order_id: string;
204
+ currency: string;
205
+ placed_at: string;
206
+ payment_state: PaymentState;
207
+ compliance_state: CommerceSnapshot["compliance"];
208
+ stock_state: CommerceSnapshot["stock"];
209
+ fulfillment_state: CommerceSnapshot["fulfillment"];
210
+ label_state: CommerceSnapshot["label"];
211
+ amount_minor: number;
212
+ event_version: number;
213
+ }
214
+
215
+ export interface CommerceOrderQueryRow {
216
+ id: string;
217
+ placedAt: string;
218
+ payment: PaymentState;
219
+ fulfillment: CommerceSnapshot["fulfillment"];
220
+ amountMinor: number;
221
+ eventVersion: number;
222
+ }
223
+
224
+ export interface CommerceAggregate {
225
+ count: number;
226
+ amountMinor: number;
227
+ byPayment: Partial<Record<PaymentState, number>>;
228
+ }
229
+
230
+ export interface CommitCommerceInput {
231
+ tenantId: string;
232
+ stage: CommerceStage;
233
+ event: CommerceSourceEvent;
234
+ now: string;
235
+ replay?: { issuer: string; jti: string };
236
+ }
237
+
238
+ export type CommitCommerceResult =
239
+ | { ok: true; duplicate: boolean; snapshot: CommerceSnapshot; outboxId: string }
240
+ | { ok: false; reason: "replay" };
241
+
242
+ async function atomic(db: Queryable, statements: readonly QueryStatement[]): Promise<void> {
243
+ if (typeof db.batch === "function") {
244
+ await db.batch(statements);
245
+ return;
246
+ }
247
+ await db.exec("BEGIN");
248
+ try {
249
+ for (const statement of statements) await db.run(statement.sql, ...(statement.params ?? []));
250
+ await db.exec("COMMIT");
251
+ } catch (error) {
252
+ await db.exec("ROLLBACK");
253
+ throw error;
254
+ }
255
+ }
256
+
257
+ function constraintTable(error: unknown): string | null {
258
+ if (!(error instanceof Error)) return null;
259
+ const match = /scrappy_[a-z_]+/.exec(error.message);
260
+ return match ? match[0] : null;
261
+ }
262
+
263
+ /** Durable commerce projection. Order, inbox, outbox, and caller replay commit together. */
264
+ export class ScrappyOrderLedger {
265
+ constructor(private readonly db: Queryable) {}
266
+
267
+ async commit(input: CommitCommerceInput): Promise<CommitCommerceResult> {
268
+ const { tenantId, stage, event, now } = input;
269
+ if (input.replay) {
270
+ const seen = await this.db.first<{ jti: string }>(
271
+ `SELECT jti FROM scrappy_caller_replay WHERE tenant_id = ? AND stage = ? AND issuer = ? AND jti = ?`,
272
+ tenantId,
273
+ stage,
274
+ input.replay.issuer,
275
+ input.replay.jti,
276
+ );
277
+ if (seen) return { ok: false, reason: "replay" };
278
+ }
279
+
280
+ const existing = await this.db.first<{ outbox_id: string; payload: string }>(
281
+ `SELECT outbox_id, payload FROM scrappy_commerce_outbox
282
+ WHERE tenant_id = ? AND stage = ? AND source = ? AND source_event_id = ?`,
283
+ tenantId,
284
+ stage,
285
+ event.source,
286
+ event.sourceEventId,
287
+ );
288
+ if (existing) {
289
+ if (input.replay) {
290
+ try {
291
+ await this.insertReplay(input);
292
+ } catch (error) {
293
+ if (constraintTable(error) === "scrappy_caller_replay") return { ok: false, reason: "replay" };
294
+ throw error;
295
+ }
296
+ }
297
+ const snapshot = await this.readSnapshot(tenantId, stage, event.orderId, event);
298
+ return { ok: true, duplicate: true, snapshot, outboxId: existing.outbox_id };
299
+ }
300
+
301
+ const priorRows = await this.db.all<{ payload: string }>(
302
+ `SELECT payload FROM scrappy_commerce_outbox WHERE tenant_id = ? AND stage = ? AND order_id = ?`,
303
+ tenantId,
304
+ stage,
305
+ event.orderId,
306
+ );
307
+ const prior: CommerceSourceEvent[] = [];
308
+ for (const row of priorRows) {
309
+ const parsed = JSON.parse(row.payload) as StoredCommercePayload;
310
+ prior.push(parsed.source);
311
+ }
312
+ const snapshot = foldCommerceEvents([...prior, event]);
313
+ const packed = packCommercePayload(event, snapshot);
314
+ const key = idempotencyKey(tenantId, stage, event.source, event.sourceEventId);
315
+ const outboxId = (await sha256Hex(key)).slice(0, 32);
316
+ const digest = await sha256Hex(canonicalJson(event));
317
+ const statements: QueryStatement[] = [];
318
+ if (input.replay) {
319
+ statements.push({
320
+ sql: `INSERT INTO scrappy_caller_replay (tenant_id, stage, issuer, jti, seen_at) VALUES (?,?,?,?,?)`,
321
+ params: [tenantId, stage, input.replay.issuer, input.replay.jti, now],
322
+ });
323
+ }
324
+ statements.push(
325
+ {
326
+ sql: `INSERT INTO scrappy_commerce_orders (
327
+ tenant_id, stage, order_id, source, source_order_id, currency, placed_at,
328
+ payment_state, compliance_state, stock_state, fulfillment_state, label_state,
329
+ amount_minor, event_version
330
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
331
+ ON CONFLICT(tenant_id, stage, order_id) DO UPDATE SET
332
+ source = excluded.source,
333
+ source_order_id = excluded.source_order_id,
334
+ currency = excluded.currency,
335
+ placed_at = excluded.placed_at,
336
+ payment_state = excluded.payment_state,
337
+ compliance_state = excluded.compliance_state,
338
+ stock_state = excluded.stock_state,
339
+ fulfillment_state = excluded.fulfillment_state,
340
+ label_state = excluded.label_state,
341
+ amount_minor = excluded.amount_minor,
342
+ event_version = excluded.event_version`,
343
+ params: [
344
+ tenantId,
345
+ stage,
346
+ snapshot.orderId,
347
+ snapshot.source,
348
+ snapshot.sourceOrderId,
349
+ snapshot.currency,
350
+ snapshot.placedAt,
351
+ snapshot.payment,
352
+ snapshot.compliance,
353
+ snapshot.stock,
354
+ snapshot.fulfillment,
355
+ snapshot.label,
356
+ snapshot.amountMinor,
357
+ snapshot.eventVersion,
358
+ ],
359
+ },
360
+ {
361
+ sql: `INSERT INTO scrappy_commerce_inbox (
362
+ tenant_id, stage, source, source_event_id, event_version, idempotency_key, payload_digest, received_at
363
+ ) VALUES (?,?,?,?,?,?,?,?)`,
364
+ params: [tenantId, stage, event.source, event.sourceEventId, event.eventVersion, key, digest, now],
365
+ },
366
+ {
367
+ sql: `INSERT INTO scrappy_commerce_outbox (
368
+ outbox_id, tenant_id, stage, order_id, source, source_event_id, event_version,
369
+ event_type, payload, available_at, attempt_count, state
370
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,0,'pending')`,
371
+ params: [
372
+ outboxId,
373
+ tenantId,
374
+ stage,
375
+ snapshot.orderId,
376
+ event.source,
377
+ event.sourceEventId,
378
+ event.eventVersion,
379
+ "order.observed",
380
+ JSON.stringify(packed),
381
+ now,
382
+ ],
383
+ },
384
+ );
385
+
386
+ try {
387
+ await atomic(this.db, statements);
388
+ } catch (error) {
389
+ const table = constraintTable(error);
390
+ if (table === "scrappy_caller_replay") return { ok: false, reason: "replay" };
391
+ if (table === "scrappy_commerce_inbox" || table === "scrappy_commerce_outbox") {
392
+ const raced = await this.db.first<{ outbox_id: string }>(
393
+ `SELECT outbox_id FROM scrappy_commerce_outbox
394
+ WHERE tenant_id = ? AND stage = ? AND source = ? AND source_event_id = ?`,
395
+ tenantId,
396
+ stage,
397
+ event.source,
398
+ event.sourceEventId,
399
+ );
400
+ if (raced) {
401
+ const snapshotNow = await this.readSnapshot(tenantId, stage, event.orderId, event);
402
+ return { ok: true, duplicate: true, snapshot: snapshotNow, outboxId: raced.outbox_id };
403
+ }
404
+ }
405
+ throw error;
406
+ }
407
+ return { ok: true, duplicate: false, snapshot, outboxId };
408
+ }
409
+
410
+ async queryOrders(
411
+ tenantId: string,
412
+ stage: CommerceStage,
413
+ limit: number,
414
+ cursor: string | null,
415
+ ): Promise<{ orders: CommerceOrderQueryRow[]; nextCursor: string | null; invalidCursor: boolean }> {
416
+ const rows = await this.db.all<CommerceOrderRow>(
417
+ `SELECT * FROM scrappy_commerce_orders WHERE tenant_id = ? AND stage = ?`,
418
+ tenantId,
419
+ stage,
420
+ );
421
+ const orders = rows.map((row) => ({
422
+ id: row.order_id,
423
+ placedAt: row.placed_at,
424
+ payment: row.payment_state,
425
+ fulfillment: row.fulfillment_state,
426
+ amountMinor: row.amount_minor,
427
+ eventVersion: row.event_version,
428
+ }));
429
+ return paginateOrders(orders, limit, cursor);
430
+ }
431
+
432
+ async aggregateOrders(tenantId: string, stage: CommerceStage): Promise<CommerceAggregate> {
433
+ const rows = await this.db.all<{ payment_state: PaymentState; n: number; amount: number | null }>(
434
+ `SELECT payment_state, COUNT(*) AS n, SUM(amount_minor) AS amount
435
+ FROM scrappy_commerce_orders WHERE tenant_id = ? AND stage = ?
436
+ GROUP BY payment_state`,
437
+ tenantId,
438
+ stage,
439
+ );
440
+ const byPayment: Partial<Record<PaymentState, number>> = {};
441
+ let count = 0;
442
+ let amountMinor = 0;
443
+ for (const row of rows) {
444
+ const n = Number(row.n);
445
+ byPayment[row.payment_state] = n;
446
+ count += n;
447
+ amountMinor += Number(row.amount ?? 0);
448
+ }
449
+ return { count, amountMinor, byPayment };
450
+ }
451
+
452
+ /** Reset one outbox row for this tenant and stage. Does not write another order. */
453
+ async replayOutbox(tenantId: string, stage: CommerceStage, outboxId: string, now: string): Promise<boolean> {
454
+ const row = await this.db.first<{ outbox_id: string }>(
455
+ `SELECT outbox_id FROM scrappy_commerce_outbox WHERE outbox_id = ? AND tenant_id = ? AND stage = ?`,
456
+ outboxId,
457
+ tenantId,
458
+ stage,
459
+ );
460
+ if (!row) return false;
461
+ await this.db.run(
462
+ `UPDATE scrappy_commerce_outbox
463
+ SET state = 'pending', available_at = ?, attempt_count = 0
464
+ WHERE outbox_id = ? AND tenant_id = ? AND stage = ?`,
465
+ now,
466
+ outboxId,
467
+ tenantId,
468
+ stage,
469
+ );
470
+ return true;
471
+ }
472
+
473
+ async readOrder(tenantId: string, stage: CommerceStage, orderId: string): Promise<CommerceOrderRow | null> {
474
+ return this.db.first<CommerceOrderRow>(
475
+ `SELECT * FROM scrappy_commerce_orders WHERE tenant_id = ? AND stage = ? AND order_id = ?`,
476
+ tenantId,
477
+ stage,
478
+ orderId,
479
+ );
480
+ }
481
+
482
+ private async insertReplay(input: CommitCommerceInput): Promise<void> {
483
+ if (!input.replay) return;
484
+ await atomic(this.db, [
485
+ {
486
+ sql: `INSERT INTO scrappy_caller_replay (tenant_id, stage, issuer, jti, seen_at) VALUES (?,?,?,?,?)`,
487
+ params: [input.tenantId, input.stage, input.replay.issuer, input.replay.jti, input.now],
488
+ },
489
+ ]);
490
+ }
491
+
492
+ private async readSnapshot(
493
+ tenantId: string,
494
+ stage: CommerceStage,
495
+ orderId: string,
496
+ event: CommerceSourceEvent,
497
+ ): Promise<CommerceSnapshot> {
498
+ const rows = await this.db.all<{ payload: string }>(
499
+ `SELECT payload FROM scrappy_commerce_outbox WHERE tenant_id = ? AND stage = ? AND order_id = ?`,
500
+ tenantId,
501
+ stage,
502
+ orderId,
503
+ );
504
+ const events: CommerceSourceEvent[] = [];
505
+ for (const row of rows) {
506
+ const parsed = JSON.parse(row.payload) as StoredCommercePayload;
507
+ if (parsed.source) events.push(parsed.source);
508
+ }
509
+ return foldCommerceEvents(events.length > 0 ? events : [event]);
510
+ }
511
+ }
@@ -22,11 +22,13 @@
22
22
  * public target can't 302 its way to an internal one; either case never
23
23
  * reads as `delivered`.
24
24
  */
25
- import { WebhookDeliveryStore, type WebhookDelivery, type WebhookEvent, type WebhookTopic } from "./deliveryStore.js";
25
+ import { WebhookDeliveryStore, ScrappyCommerceOutbox, type CommerceOutboxDue, type WebhookDelivery, type WebhookEvent, type WebhookTopic } from "./deliveryStore.js";
26
26
  import type { AppInstall } from "../apps/registryService.js";
27
27
  import { buildCloudEventEnvelope } from "./events.js";
28
28
  import { computeBackoffDelaySeconds, MAX_DELIVERY_ATTEMPTS, type AttemptOutcome } from "./dispatcher.js";
29
29
  import { validateWebhookEndpoint } from "./endpointGuard.js";
30
+ import { COMMERCE_SCHEDULER } from "@tot/public-runtime";
31
+ import type { Queryable } from "../d1/catalog.js";
30
32
 
31
33
  /** Bounded per-attempt timeout — a hung receiver must not hold a dispatcher slot open indefinitely. */
32
34
  const DEFAULT_TIMEOUT_MS = 10_000;
@@ -277,3 +279,110 @@ async function postWithTimeout(
277
279
  clearTimeout(timer);
278
280
  }
279
281
  }
282
+
283
+ const COMMERCE_PRIVATE_KEYS = new Set([
284
+ "customer",
285
+ "customerEmail",
286
+ "customerName",
287
+ "email",
288
+ "phone",
289
+ "line1",
290
+ "line2",
291
+ "postalCode",
292
+ "shippingAddress",
293
+ "billingAddress",
294
+ "address",
295
+ "firstName",
296
+ "lastName",
297
+ "name",
298
+ ]);
299
+
300
+ function commercePayloadHasPrivateIdentity(value: unknown): boolean {
301
+ if (value === null || typeof value !== "object") return false;
302
+ if (Array.isArray(value)) return value.some((item) => commercePayloadHasPrivateIdentity(item));
303
+ return Object.entries(value as Record<string, unknown>).some(
304
+ ([key, child]) => COMMERCE_PRIVATE_KEYS.has(key) || commercePayloadHasPrivateIdentity(child),
305
+ );
306
+ }
307
+
308
+ function publicCommercePayload(payload: string): Record<string, unknown> | null {
309
+ let parsed: unknown;
310
+ try {
311
+ parsed = JSON.parse(payload);
312
+ } catch {
313
+ return null;
314
+ }
315
+ if (commercePayloadHasPrivateIdentity(parsed)) return null;
316
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return null;
317
+ const body = parsed as Record<string, unknown>;
318
+ const event = body.public;
319
+ if (event === null || typeof event !== "object" || Array.isArray(event)) return null;
320
+ if ((event as Record<string, unknown>).type !== "order.observed") return null;
321
+ return event as Record<string, unknown>;
322
+ }
323
+
324
+ export type CommerceSweepResult =
325
+ | { ok: true; delivered: number; retried: number; dead: number }
326
+ | { ok: false; reason: "scheduler" };
327
+
328
+ export type CommerceDeliver = (event: Record<string, unknown>) => Promise<void>;
329
+
330
+ /**
331
+ * Deliver due commerce outbox rows. A scheduler other than the declared alarm
332
+ * leaves every row pending.
333
+ */
334
+ export async function sweepDurableCommerce(input: {
335
+ db: Queryable;
336
+ scheduler: string;
337
+ now: string;
338
+ deliver?: CommerceDeliver;
339
+ }): Promise<CommerceSweepResult> {
340
+ if (input.scheduler !== COMMERCE_SCHEDULER) return { ok: false, reason: "scheduler" };
341
+ const port = new ScrappyCommerceOutbox(input.db);
342
+ const due = await port.due(input.now);
343
+ let delivered = 0;
344
+ let retried = 0;
345
+ let dead = 0;
346
+ const tenants: { tenantId: string; stage: string }[] = [];
347
+ const seenTenant = (tenantId: string, stage: string) => {
348
+ if (!tenants.some((item) => item.tenantId === tenantId && item.stage === stage)) {
349
+ tenants.push({ tenantId, stage });
350
+ }
351
+ };
352
+ for (const row of due) {
353
+ seenTenant(row.tenant_id, row.stage);
354
+ const outcome = await settleCommerceRow(port, row, input.now, input.deliver);
355
+ if (outcome === "delivered") delivered += 1;
356
+ else if (outcome === "retried") retried += 1;
357
+ else dead += 1;
358
+ }
359
+ for (const tenant of tenants) await port.noteCursor(tenant.tenantId, tenant.stage, input.now);
360
+ return { ok: true, delivered, retried, dead };
361
+ }
362
+
363
+ async function settleCommerceRow(
364
+ port: ScrappyCommerceOutbox,
365
+ row: CommerceOutboxDue,
366
+ now: string,
367
+ deliver: CommerceDeliver | undefined,
368
+ ): Promise<"delivered" | "retried" | "dead"> {
369
+ const event = publicCommercePayload(row.payload);
370
+ if (!event) {
371
+ await port.markDead(row.outbox_id);
372
+ return "dead";
373
+ }
374
+ try {
375
+ if (deliver) await deliver(event);
376
+ await port.markDelivered(row.outbox_id);
377
+ return "delivered";
378
+ } catch {
379
+ const next = row.attempt_count + 1;
380
+ if (next >= MAX_DELIVERY_ATTEMPTS) {
381
+ await port.markDead(row.outbox_id, next);
382
+ return "dead";
383
+ }
384
+ const availableAt = new Date(Date.parse(now) + computeBackoffDelaySeconds(next) * 1000).toISOString();
385
+ await port.markRetry(row.outbox_id, next, availableAt);
386
+ return "retried";
387
+ }
388
+ }
@@ -665,3 +665,73 @@ export class WebhookDeliveryStore {
665
665
  return toDomain(await this.mustFindDeliveryRow(deliveryId));
666
666
  }
667
667
  }
668
+
669
+ export interface CommerceOutboxDue {
670
+ outbox_id: string;
671
+ tenant_id: string;
672
+ stage: string;
673
+ payload: string;
674
+ attempt_count: number;
675
+ available_at: string;
676
+ }
677
+
678
+ /** Due commerce outbox rows. The order ledger owns the insert; this port owns sweep progress. */
679
+ export class ScrappyCommerceOutbox {
680
+ constructor(private readonly db: Queryable) {}
681
+
682
+ async due(now: string): Promise<CommerceOutboxDue[]> {
683
+ const rows = await this.db.all<CommerceOutboxDue>(
684
+ `SELECT outbox_id, tenant_id, stage, payload, attempt_count, available_at
685
+ FROM scrappy_commerce_outbox
686
+ WHERE state = 'pending' AND available_at <= ?
687
+ ORDER BY available_at`,
688
+ now,
689
+ );
690
+ return rows.map((row) => ({ ...row, attempt_count: Number(row.attempt_count) }));
691
+ }
692
+
693
+ async markDelivered(outboxId: string): Promise<void> {
694
+ await this.db.run(
695
+ `UPDATE scrappy_commerce_outbox SET state = 'delivered' WHERE outbox_id = ? AND state = 'pending'`,
696
+ outboxId,
697
+ );
698
+ }
699
+
700
+ async markRetry(outboxId: string, attemptCount: number, availableAt: string): Promise<void> {
701
+ await this.db.run(
702
+ `UPDATE scrappy_commerce_outbox
703
+ SET state = 'pending', attempt_count = ?, available_at = ?
704
+ WHERE outbox_id = ? AND state = 'pending'`,
705
+ attemptCount,
706
+ availableAt,
707
+ outboxId,
708
+ );
709
+ }
710
+
711
+ async markDead(outboxId: string, attemptCount?: number): Promise<void> {
712
+ if (attemptCount === undefined) {
713
+ await this.db.run(
714
+ `UPDATE scrappy_commerce_outbox SET state = 'dead' WHERE outbox_id = ? AND state = 'pending'`,
715
+ outboxId,
716
+ );
717
+ return;
718
+ }
719
+ await this.db.run(
720
+ `UPDATE scrappy_commerce_outbox
721
+ SET state = 'dead', attempt_count = ?
722
+ WHERE outbox_id = ? AND state = 'pending'`,
723
+ attemptCount,
724
+ outboxId,
725
+ );
726
+ }
727
+
728
+ async noteCursor(tenantId: string, stage: string, cursor: string): Promise<void> {
729
+ await this.db.run(
730
+ `INSERT INTO scrappy_sweep_cursor (tenant_id, stage, cursor) VALUES (?,?,?)
731
+ ON CONFLICT(tenant_id, stage) DO UPDATE SET cursor = excluded.cursor`,
732
+ tenantId,
733
+ stage,
734
+ cursor,
735
+ );
736
+ }
737
+ }
@@ -15,15 +15,16 @@
15
15
  * design-resolution + entry-wiring context).
16
16
  */
17
17
  import { WebhookDeliveryStore } from "./deliveryStore.js";
18
- import { attemptDelivery } from "./deliveryEngine.js";
18
+ import { attemptDelivery, sweepDurableCommerce, type CommerceDeliver, type CommerceSweepResult } from "./deliveryEngine.js";
19
19
  import { InMemoryDispatcher, type AttemptDeliveryFn, type WebhookDispatcher } from "./dispatcher.js";
20
20
  import { CloudflareQueueDispatcher, type CloudflareQueueSender } from "./cloudflareQueueDispatcher.js";
21
21
  import { AppRegistryService } from "../apps/registryService.js";
22
22
  import { D1CredentialStore } from "../apps/credentials.js";
23
23
  import { DEFAULT_APP_GATEWAY_KID, APP_GATEWAY_KID_ENV } from "../apps/gatewayKeys.js";
24
24
  import { json } from "../apps/registryAccess.js";
25
- import { fromD1 } from "../d1/catalog.js";
25
+ import { fromD1, type Queryable } from "../d1/catalog.js";
26
26
  import { readD1, readEnv, readQueue } from "../env.js";
27
+ import { COMMERCE_SCHEDULER } from "@tot/public-runtime";
27
28
 
28
29
  /** Binding name `getDispatcher()` checks to pick the Cloudflare Queues adapter. Not yet declared in `wrangler.toml`. */
29
30
  export const WEBHOOK_QUEUE_BINDING = "WEBHOOK_DELIVERY_QUEUE";
@@ -47,3 +48,29 @@ export async function getDispatcher(): Promise<WebhookDispatcher | Response> {
47
48
  if (queueSender) return new CloudflareQueueDispatcher(queueSender);
48
49
  return new InMemoryDispatcher(attempt);
49
50
  }
51
+
52
+ /**
53
+ * Commerce work is already durable when it is saved. This dispatcher only
54
+ * sweeps that outbox, and only when the declared scheduler is configured.
55
+ * It never falls back to an in-memory queue.
56
+ */
57
+ export class CommerceAlarmDispatcher {
58
+ constructor(
59
+ private readonly db: Queryable,
60
+ private readonly scheduler: string,
61
+ ) {}
62
+
63
+ async enqueue(): Promise<void> {}
64
+
65
+ sweep(now: string, deliver?: CommerceDeliver): Promise<CommerceSweepResult> {
66
+ return sweepDurableCommerce({ db: this.db, scheduler: this.scheduler, now, deliver });
67
+ }
68
+ }
69
+
70
+ export async function getReliableDispatcher(): Promise<CommerceAlarmDispatcher | Response> {
71
+ const scheduler = await readEnv("SCRAPPY_COMMERCE_SCHEDULER");
72
+ if (scheduler !== COMMERCE_SCHEDULER) return json({ error: "commerce scheduler not configured" }, 503);
73
+ const d1 = await readD1("STOREFRONT_APPS_DB");
74
+ if (!d1) return json({ error: "commerce scheduler not configured" }, 503);
75
+ return new CommerceAlarmDispatcher(fromD1(d1), scheduler);
76
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "2.4.52",
3
+ "version": "2.4.54",
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",