@tokenoftrust/storefront-runner 2.4.53 → 2.4.55

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
+ }