@porulle/core 0.9.0 → 0.10.0

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.
Files changed (103) hide show
  1. package/dist/config/defaults.js +1 -1
  2. package/dist/config/types.d.ts +4 -3
  3. package/dist/config/types.d.ts.map +1 -1
  4. package/dist/generated/plugin-manifest.d.ts +3 -0
  5. package/dist/generated/plugin-manifest.d.ts.map +1 -1
  6. package/dist/generated/plugin-manifest.js +1 -0
  7. package/dist/index.d.ts +5 -2
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +2 -0
  10. package/dist/interfaces/rest/router.d.ts.map +1 -1
  11. package/dist/interfaces/rest/router.js +2 -0
  12. package/dist/interfaces/rest/routes/checkout.d.ts.map +1 -1
  13. package/dist/interfaces/rest/routes/checkout.js +1 -0
  14. package/dist/interfaces/rest/schemas/catalog.d.ts +391 -0
  15. package/dist/interfaces/rest/schemas/catalog.d.ts.map +1 -1
  16. package/dist/interfaces/rest/schemas/responses.d.ts +51 -0
  17. package/dist/interfaces/rest/schemas/responses.d.ts.map +1 -1
  18. package/dist/interfaces/rest/schemas/search.d.ts +17 -0
  19. package/dist/interfaces/rest/schemas/search.d.ts.map +1 -1
  20. package/dist/kernel/database/scoped-db.d.ts.map +1 -1
  21. package/dist/kernel/database/scoped-db.js +21 -23
  22. package/dist/kernel/errors.d.ts +12 -0
  23. package/dist/kernel/errors.d.ts.map +1 -1
  24. package/dist/kernel/errors.js +16 -0
  25. package/dist/kernel/jobs/adapter.d.ts +27 -8
  26. package/dist/kernel/jobs/adapter.d.ts.map +1 -1
  27. package/dist/kernel/jobs/adapter.js +1 -2
  28. package/dist/kernel/jobs/drizzle-adapter.d.ts +13 -3
  29. package/dist/kernel/jobs/drizzle-adapter.d.ts.map +1 -1
  30. package/dist/kernel/jobs/drizzle-adapter.js +46 -5
  31. package/dist/kernel/jobs/runner.d.ts +2 -1
  32. package/dist/kernel/jobs/runner.d.ts.map +1 -1
  33. package/dist/kernel/jobs/runner.js +100 -15
  34. package/dist/kernel/jobs/types.d.ts +13 -0
  35. package/dist/kernel/jobs/types.d.ts.map +1 -1
  36. package/dist/kernel/plugin/manifest.d.ts +2 -0
  37. package/dist/kernel/plugin/manifest.d.ts.map +1 -1
  38. package/dist/kernel/plugin/manifest.js +9 -0
  39. package/dist/modules/catalog/entity-service.d.ts +1 -0
  40. package/dist/modules/catalog/entity-service.d.ts.map +1 -1
  41. package/dist/modules/catalog/entity-service.js +15 -1
  42. package/dist/modules/catalog/repository/index.d.ts +2 -2
  43. package/dist/modules/catalog/repository/index.d.ts.map +1 -1
  44. package/dist/modules/catalog/repository/index.js +9 -1
  45. package/dist/modules/catalog/schema.d.ts +51 -0
  46. package/dist/modules/catalog/schema.d.ts.map +1 -1
  47. package/dist/modules/catalog/schema.js +12 -1
  48. package/dist/modules/catalog/schemas.d.ts +3 -1
  49. package/dist/modules/catalog/schemas.d.ts.map +1 -1
  50. package/dist/modules/channels/adapter.d.ts +117 -0
  51. package/dist/modules/channels/adapter.d.ts.map +1 -0
  52. package/dist/modules/channels/adapter.js +3 -0
  53. package/dist/modules/inventory/repository/index.js +4 -4
  54. package/dist/modules/inventory/service.d.ts.map +1 -1
  55. package/dist/modules/inventory/service.js +7 -3
  56. package/dist/modules/orders/service.d.ts.map +1 -1
  57. package/dist/modules/orders/service.js +7 -4
  58. package/dist/modules/orders/stale-order-cleanup.d.ts.map +1 -1
  59. package/dist/modules/orders/stale-order-cleanup.js +25 -16
  60. package/dist/modules/promotions/repository/index.d.ts +1 -1
  61. package/dist/modules/promotions/repository/index.d.ts.map +1 -1
  62. package/dist/modules/promotions/repository/index.js +6 -3
  63. package/dist/modules/promotions/service.d.ts +1 -0
  64. package/dist/modules/promotions/service.d.ts.map +1 -1
  65. package/dist/modules/promotions/service.js +1 -1
  66. package/dist/modules/webhooks/repository/index.d.ts +1 -1
  67. package/dist/modules/webhooks/repository/index.d.ts.map +1 -1
  68. package/dist/modules/webhooks/repository/index.js +16 -10
  69. package/dist/runtime/kernel.d.ts.map +1 -1
  70. package/dist/runtime/kernel.js +14 -2
  71. package/dist/runtime/server.d.ts +1 -4
  72. package/dist/runtime/server.d.ts.map +1 -1
  73. package/dist/runtime/server.js +16 -34
  74. package/package.json +1 -1
  75. package/src/config/defaults.ts +1 -1
  76. package/src/config/types.ts +7 -3
  77. package/src/generated/plugin-capabilities.d.ts +1 -0
  78. package/src/generated/plugin-manifest.ts +1 -0
  79. package/src/generated/plugin-repositories.d.ts +1 -0
  80. package/src/index.ts +31 -1
  81. package/src/interfaces/rest/router.ts +2 -0
  82. package/src/interfaces/rest/routes/checkout.ts +1 -0
  83. package/src/kernel/database/scoped-db.ts +25 -26
  84. package/src/kernel/errors.ts +20 -0
  85. package/src/kernel/jobs/adapter.ts +39 -8
  86. package/src/kernel/jobs/drizzle-adapter.ts +72 -9
  87. package/src/kernel/jobs/runner.ts +194 -72
  88. package/src/kernel/jobs/types.ts +25 -0
  89. package/src/kernel/plugin/manifest.ts +12 -0
  90. package/src/modules/catalog/entity-service.ts +9 -1
  91. package/src/modules/catalog/repository/index.ts +13 -3
  92. package/src/modules/catalog/schema.ts +33 -18
  93. package/src/modules/catalog/schemas.ts +3 -1
  94. package/src/modules/channels/adapter.ts +139 -0
  95. package/src/modules/inventory/repository/index.ts +4 -4
  96. package/src/modules/inventory/service.ts +12 -2
  97. package/src/modules/orders/service.ts +8 -12
  98. package/src/modules/orders/stale-order-cleanup.ts +35 -24
  99. package/src/modules/promotions/repository/index.ts +6 -2
  100. package/src/modules/promotions/service.ts +2 -0
  101. package/src/modules/webhooks/repository/index.ts +16 -14
  102. package/src/runtime/kernel.ts +16 -2
  103. package/src/runtime/server.ts +22 -42
@@ -1,4 +1,5 @@
1
1
  import type { AnyPgColumn } from "drizzle-orm/pg-core";
2
+ import { sql } from "drizzle-orm";
2
3
  import {
3
4
  boolean,
4
5
  index,
@@ -17,6 +18,8 @@ export const sellableEntities = pgTable(
17
18
  {
18
19
  id: uuid("id").defaultRandom().primaryKey(),
19
20
  organizationId: text("organization_id").notNull().references(() => organization.id, { onDelete: "cascade" }),
21
+ // Connected-store id when externally sourced; null means platform-native.
22
+ sourceStoreId: text("source_store_id"),
20
23
  type: text("type").notNull(),
21
24
  slug: text("slug").notNull(),
22
25
  status: text("status", {
@@ -179,24 +182,36 @@ export const optionValues = pgTable("option_values", {
179
182
  metadata: jsonb("metadata").$type<Record<string, unknown>>().default({}),
180
183
  });
181
184
 
182
- export const variants = pgTable("variants", {
183
- id: uuid("id").defaultRandom().primaryKey(),
184
- entityId: uuid("entity_id")
185
- .references(() => sellableEntities.id, { onDelete: "cascade" })
186
- .notNull(),
187
- sku: text("sku").unique(),
188
- barcode: text("barcode"),
189
- // Overrides the entity's taxClass when set (issue #57).
190
- taxClass: text("tax_class"),
191
- status: text("status", { enum: ["active", "discontinued"] })
192
- .notNull()
193
- .default("active"),
194
- sortOrder: integer("sort_order").notNull().default(0),
195
- metadata: jsonb("metadata").$type<Record<string, unknown>>().default({}),
196
- }, (table) => ({
197
- barcodeIdx: index("idx_variants_barcode").on(table.barcode),
198
- skuIdx: index("idx_variants_sku").on(table.sku),
199
- }));
185
+ export const variants = pgTable(
186
+ "variants",
187
+ {
188
+ id: uuid("id").defaultRandom().primaryKey(),
189
+ entityId: uuid("entity_id")
190
+ .references(() => sellableEntities.id, { onDelete: "cascade" })
191
+ .notNull(),
192
+ organizationId: text("organization_id").notNull().references(() => organization.id, { onDelete: "cascade" }),
193
+ sourceStoreId: text("source_store_id"),
194
+ sku: text("sku"),
195
+ barcode: text("barcode"),
196
+ // Overrides the entity's taxClass when set (issue #57).
197
+ taxClass: text("tax_class"),
198
+ status: text("status", { enum: ["active", "discontinued"] })
199
+ .notNull()
200
+ .default("active"),
201
+ sortOrder: integer("sort_order").notNull().default(0),
202
+ metadata: jsonb("metadata").$type<Record<string, unknown>>().default({}),
203
+ },
204
+ (table) => ({
205
+ barcodeIdx: index("idx_variants_barcode").on(table.barcode),
206
+ skuIdx: index("idx_variants_sku").on(table.sku),
207
+ nativeSkuUnique: uniqueIndex("variants_native_org_sku_unique")
208
+ .on(table.organizationId, table.sku)
209
+ .where(sql`${table.sourceStoreId} IS NULL AND ${table.sku} IS NOT NULL`),
210
+ sourceSkuUnique: uniqueIndex("variants_source_store_sku_unique")
211
+ .on(table.sourceStoreId, table.sku)
212
+ .where(sql`${table.sourceStoreId} IS NOT NULL AND ${table.sku} IS NOT NULL`),
213
+ }),
214
+ );
200
215
 
201
216
  export const variantOptionValues = pgTable("variant_option_values", {
202
217
  variantId: uuid("variant_id")
@@ -92,7 +92,9 @@ export const GenerateVariantsBodySchema = z
92
92
 
93
93
  // ─── Derived Input Types ─────────────────────────────────────────────────────
94
94
 
95
- export type CreateEntityInput = z.infer<typeof CreateEntityBodySchema>;
95
+ export type CreateEntityInput = z.infer<typeof CreateEntityBodySchema> & {
96
+ sourceStoreId?: string;
97
+ };
96
98
 
97
99
  export type UpdateEntityInput = z.infer<typeof UpdateEntityBodySchema>;
98
100
 
@@ -0,0 +1,139 @@
1
+ import type { Result } from "../../kernel/result.js";
2
+
3
+ export interface ChannelConnectorError {
4
+ code: string;
5
+ message: string;
6
+ retriable?: boolean;
7
+ }
8
+
9
+ export interface ChannelConnectorCapabilities {
10
+ readonly importCatalog: boolean;
11
+ readonly importInventory: boolean;
12
+ readonly pushOrder: boolean;
13
+ readonly receiveWebhooks: boolean;
14
+ readonly reserve?: boolean;
15
+ }
16
+
17
+ export interface ChannelStore {
18
+ id: string;
19
+ organizationId: string;
20
+ provider: string;
21
+ credentials: Record<string, unknown>;
22
+ storeDomain: string;
23
+ status: "connected" | "disconnected" | "error";
24
+ webhookSecret: string | null;
25
+ }
26
+
27
+ export interface ChannelCatalogVariant {
28
+ externalId: string;
29
+ sku?: string;
30
+ barcode?: string;
31
+ metadata?: Record<string, unknown>;
32
+ }
33
+
34
+ export interface ChannelCatalogItem {
35
+ externalId: string;
36
+ slug: string;
37
+ title: string;
38
+ description?: string;
39
+ variants: ChannelCatalogVariant[];
40
+ metadata?: Record<string, unknown>;
41
+ }
42
+
43
+ export interface ChannelCatalogPage {
44
+ items: ChannelCatalogItem[];
45
+ nextCursor?: string | null;
46
+ }
47
+
48
+ export interface ChannelInventoryLevel {
49
+ externalId: string;
50
+ available: number;
51
+ }
52
+
53
+ export interface ChannelOrderLine {
54
+ externalVariantId: string;
55
+ sku?: string;
56
+ title: string;
57
+ quantity: number;
58
+ unitPrice: number;
59
+ totalPrice: number;
60
+ }
61
+
62
+ export interface ChannelOrderSlice {
63
+ orderId: string;
64
+ currency: string;
65
+ grandTotal: number;
66
+ lines: ChannelOrderLine[];
67
+ customer: {
68
+ name: string;
69
+ email: string;
70
+ shippingAddress: Record<string, unknown>;
71
+ };
72
+ }
73
+
74
+ export interface ChannelPushOrderResult {
75
+ remoteOrderId: string;
76
+ remoteUrl?: string;
77
+ }
78
+
79
+ export interface ChannelOrderStatus {
80
+ status: "pending" | "confirmed" | "failed" | "cancelled" | "fulfilled";
81
+ }
82
+
83
+ export interface ChannelWebhookEvent {
84
+ id: string;
85
+ type: string;
86
+ data: unknown;
87
+ }
88
+
89
+ export interface ChannelReservation {
90
+ id: string;
91
+ expiresAt?: Date;
92
+ }
93
+
94
+ export interface ChannelRefundResult {
95
+ remoteRefundId: string;
96
+ status: string;
97
+ }
98
+
99
+ export interface ChannelConnector {
100
+ readonly providerId: string;
101
+ readonly capabilities: ChannelConnectorCapabilities;
102
+ buildAuthUrl?(params: {
103
+ storeDomain: string;
104
+ state: string;
105
+ redirectUri: string;
106
+ callbackUri: string;
107
+ scopes: string[];
108
+ }): Result<string, ChannelConnectorError>;
109
+ completeAuth?(
110
+ request: Request,
111
+ ctx: { storeDomain: string },
112
+ ): Promise<Result<{ credentials: Record<string, unknown>; storeDomain: string }, ChannelConnectorError>>;
113
+ importCatalog(store: ChannelStore, cursor?: string): Promise<Result<ChannelCatalogPage>>;
114
+ fetchInventory(store: ChannelStore, ids?: string[]): Promise<Result<ChannelInventoryLevel[]>>;
115
+ pushOrder(store: ChannelStore, slice: ChannelOrderSlice): Promise<Result<ChannelPushOrderResult, ChannelConnectorError>>;
116
+ fetchOrderStatus(store: ChannelStore, remoteId: string): Promise<Result<ChannelOrderStatus, ChannelConnectorError>>;
117
+ verifyWebhook(store: ChannelStore, request: Request): Promise<Result<ChannelWebhookEvent>>;
118
+ verifyAppWebhook?(
119
+ request: Request,
120
+ ): Promise<Result<{ topic: string; shopDomain: string; data: unknown }, ChannelConnectorError>>;
121
+ registerWebhooks?(
122
+ store: ChannelStore,
123
+ topics: string[],
124
+ callbackUrl: string,
125
+ ): Promise<Result<{ registered: number }, ChannelConnectorError>>;
126
+ reserve?(
127
+ store: ChannelStore,
128
+ lines: ChannelOrderLine[],
129
+ ): Promise<Result<ChannelReservation>>;
130
+ refundExecute(
131
+ store: ChannelStore,
132
+ slice: ChannelOrderSlice,
133
+ amount: number,
134
+ ): Promise<Result<ChannelRefundResult>>;
135
+ }
136
+
137
+ export function defineChannelConnector<T extends ChannelConnector>(connector: T): T {
138
+ return connector;
139
+ }
@@ -1,5 +1,5 @@
1
1
  import { eq, and, inArray, isNull, sql } from "drizzle-orm";
2
- import { CommerceValidationError } from "../../../kernel/errors.js";
2
+ import { CommerceValidationError, INVENTORY_RECORD_NOT_FOUND_MESSAGE } from "../../../kernel/errors.js";
3
3
  import type { TxContext } from "../../../kernel/database/tx-context.js";
4
4
  import type {
5
5
  DrizzleDatabase,
@@ -495,7 +495,7 @@ export class InventoryRepository {
495
495
  if (!level) {
496
496
  return {
497
497
  ok: false,
498
- reason: "No inventory record found for this entity.",
498
+ reason: INVENTORY_RECORD_NOT_FOUND_MESSAGE,
499
499
  };
500
500
  }
501
501
 
@@ -550,7 +550,7 @@ export class InventoryRepository {
550
550
  if (!level) {
551
551
  return {
552
552
  ok: false,
553
- reason: "No inventory record found for this entity.",
553
+ reason: INVENTORY_RECORD_NOT_FOUND_MESSAGE,
554
554
  };
555
555
  }
556
556
 
@@ -600,7 +600,7 @@ export class InventoryRepository {
600
600
  if (!level) {
601
601
  return {
602
602
  ok: false,
603
- reason: "No inventory record found for this entity.",
603
+ reason: INVENTORY_RECORD_NOT_FOUND_MESSAGE,
604
604
  };
605
605
  }
606
606
 
@@ -3,8 +3,10 @@ import { assertPermission } from "../../auth/permissions.js";
3
3
  import type { Actor } from "../../auth/types.js";
4
4
  import type { CommerceConfig } from "../../config/types.js";
5
5
  import {
6
+ CommerceInventoryRecordNotFoundError,
6
7
  CommerceNotFoundError,
7
8
  CommerceValidationError,
9
+ INVENTORY_RECORD_NOT_FOUND_MESSAGE,
8
10
  toCommerceError,
9
11
  } from "../../kernel/errors.js";
10
12
  import { runAfterHooks } from "../../kernel/hooks/executor.js";
@@ -232,7 +234,11 @@ export class InventoryService {
232
234
  );
233
235
 
234
236
  if (!reserveResult.ok) {
235
- return Err(new CommerceValidationError(reserveResult.reason));
237
+ return Err(
238
+ reserveResult.reason === INVENTORY_RECORD_NOT_FOUND_MESSAGE
239
+ ? new CommerceInventoryRecordNotFoundError()
240
+ : new CommerceValidationError(reserveResult.reason),
241
+ );
236
242
  }
237
243
 
238
244
  await this.repo.createMovement(
@@ -281,7 +287,11 @@ export class InventoryService {
281
287
  );
282
288
 
283
289
  if (!releaseResult.ok) {
284
- return Err(new CommerceValidationError(releaseResult.reason));
290
+ return Err(
291
+ releaseResult.reason === INVENTORY_RECORD_NOT_FOUND_MESSAGE
292
+ ? new CommerceInventoryRecordNotFoundError()
293
+ : new CommerceValidationError(releaseResult.reason),
294
+ );
285
295
  }
286
296
 
287
297
  await this.repo.createMovement(
@@ -10,6 +10,7 @@ import {
10
10
  } from "../../kernel/errors.js";
11
11
  import { runAfterHooks, runBeforeHooks } from "../../kernel/hooks/executor.js";
12
12
  import { createHookContext } from "../../kernel/hooks/create-context.js";
13
+ import type { JobsAdapter } from "../../kernel/jobs/adapter.js";
13
14
  import type {
14
15
  AfterHook,
15
16
  BeforeHook,
@@ -130,6 +131,7 @@ function context(
130
131
  tx,
131
132
  logger: createLogger("orders"),
132
133
  services,
134
+ ...(services.jobs ? { jobs: services.jobs as JobsAdapter } : {}),
133
135
  context: { moduleName: "orders" },
134
136
  database: { db: database.db as PluginDb },
135
137
  });
@@ -808,12 +810,9 @@ export class OrderService {
808
810
  );
809
811
  if (!releaseResult.ok) {
810
812
  const err = toCommerceError(releaseResult.error);
811
- if (
812
- !(
813
- err instanceof CommerceValidationError &&
814
- err.message === "No inventory record found for this entity."
815
- )
816
- ) {
813
+ // Tolerate a missing inventory record (nothing to release); surface
814
+ // any other inventory error. Check the typed code, not the message.
815
+ if (err.code !== "INVENTORY_RECORD_NOT_FOUND") {
817
816
  return Err(err);
818
817
  }
819
818
  }
@@ -935,12 +934,9 @@ export class OrderService {
935
934
  );
936
935
  if (!releaseResult.ok) {
937
936
  const err = toCommerceError(releaseResult.error);
938
- if (
939
- !(
940
- err instanceof CommerceValidationError &&
941
- err.message === "No inventory record found for this entity."
942
- )
943
- ) {
937
+ // Tolerate a missing inventory record (nothing to release); surface
938
+ // any other inventory error. Check the typed code, not the message.
939
+ if (err.code !== "INVENTORY_RECORD_NOT_FOUND") {
944
940
  return Err(err);
945
941
  }
946
942
  }
@@ -45,17 +45,6 @@ export const staleOrderCleanupTask: TaskDefinition<
45
45
  const thresholdHours = input.thresholdHours ?? 48;
46
46
  const cutoff = new Date(Date.now() - thresholdHours * 60 * 60 * 1000);
47
47
 
48
- // Find stale pending orders
49
- const staleOrders = await ctx.db
50
- .select()
51
- .from(orders)
52
- .where(
53
- and(
54
- eq(orders.status, "pending"),
55
- lt(orders.placedAt, cutoff),
56
- ),
57
- );
58
-
59
48
  const cancelledIds: string[] = [];
60
49
 
61
50
  // Cancel each stale order via the service (triggers inventory release + payment refund)
@@ -63,20 +52,42 @@ export const staleOrderCleanupTask: TaskDefinition<
63
52
  cancel(orderId: string, actor: Actor | null, reason: string): Promise<unknown>;
64
53
  };
65
54
 
66
- for (const order of staleOrders) {
67
- try {
68
- await orderService.cancel(
69
- order.id,
70
- systemActorForOrg(order.organizationId),
71
- `Auto-cancelled: pending for >${thresholdHours}h`,
55
+ // This is a cross-org system sweep, but the row reads stay org-scoped: first
56
+ // enumerate the orgs that have stale orders (id-only projection), then read
57
+ // each org's stale orders under an explicit organizationId predicate. No
58
+ // query returns another tenant's order rows.
59
+ const staleOrgs = await ctx.db
60
+ .selectDistinct({ organizationId: orders.organizationId })
61
+ .from(orders)
62
+ .where(and(eq(orders.status, "pending"), lt(orders.placedAt, cutoff)));
63
+
64
+ for (const { organizationId } of staleOrgs) {
65
+ const staleOrders = await ctx.db
66
+ .select()
67
+ .from(orders)
68
+ .where(
69
+ and(
70
+ eq(orders.organizationId, organizationId),
71
+ eq(orders.status, "pending"),
72
+ lt(orders.placedAt, cutoff),
73
+ ),
72
74
  );
73
- cancelledIds.push(order.id);
74
- ctx.logger.info(`Stale order ${order.orderNumber} auto-cancelled`, {
75
- orderId: order.id,
76
- placedAt: order.placedAt,
77
- });
78
- } catch (error) {
79
- ctx.logger.error(`Failed to cancel stale order ${order.orderNumber}`, { error });
75
+
76
+ for (const order of staleOrders) {
77
+ try {
78
+ await orderService.cancel(
79
+ order.id,
80
+ systemActorForOrg(order.organizationId),
81
+ `Auto-cancelled: pending for >${thresholdHours}h`,
82
+ );
83
+ cancelledIds.push(order.id);
84
+ ctx.logger.info(`Stale order ${order.orderNumber} auto-cancelled`, {
85
+ orderId: order.id,
86
+ placedAt: order.placedAt,
87
+ });
88
+ } catch (error) {
89
+ ctx.logger.error(`Failed to cancel stale order ${order.orderNumber}`, { error });
90
+ }
80
91
  }
81
92
  }
82
93
 
@@ -182,6 +182,7 @@ export class PromotionsRepository {
182
182
 
183
183
  async createUsage(
184
184
  data: PromotionUsageInsert,
185
+ orgId: string,
185
186
  ctx?: TxContext,
186
187
  ): Promise<PromotionUsage> {
187
188
  const db = this.getDb(ctx);
@@ -190,10 +191,13 @@ export class PromotionsRepository {
190
191
  // race conditions between concurrent checkouts using the same coupon.
191
192
  // A plain count-then-insert has a TOCTOU gap; this single statement
192
193
  // ensures the insert only succeeds if the limit has not been reached.
194
+ // Both reads are scoped by organizationId: these are raw/lock queries that
195
+ // bypass the scoped-db proxy, so a promotionId from another tenant must
196
+ // resolve to no row rather than reading/locking a foreign promotion.
193
197
  const promo = await db
194
198
  .select({ usageLimitTotal: promotions.usageLimitTotal })
195
199
  .from(promotions)
196
- .where(eq(promotions.id, data.promotionId));
200
+ .where(and(eq(promotions.id, data.promotionId), eq(promotions.organizationId, orgId)));
197
201
 
198
202
  const limit = promo[0]?.usageLimitTotal;
199
203
 
@@ -206,7 +210,7 @@ export class PromotionsRepository {
206
210
  // concurrent callers. If the caller is already inside a transaction
207
211
  // (ctx.tx), the lock is held until that transaction commits.
208
212
  await db.execute(
209
- sql`SELECT id FROM promotions WHERE id = ${data.promotionId} FOR UPDATE`,
213
+ sql`SELECT id FROM promotions WHERE id = ${data.promotionId} AND organization_id = ${orgId} FOR UPDATE`,
210
214
  );
211
215
 
212
216
  const currentCount = await this.countUsages(data.promotionId, ctx);
@@ -500,6 +500,7 @@ export class PromotionService {
500
500
  async recordUsage(
501
501
  input: {
502
502
  promotions: AppliedPromotion[];
503
+ organizationId: string;
503
504
  customerId?: string;
504
505
  orderId?: string;
505
506
  },
@@ -513,6 +514,7 @@ export class PromotionService {
513
514
  customerId: input.customerId ?? null,
514
515
  orderId: input.orderId ?? null,
515
516
  },
517
+ input.organizationId,
516
518
  ctx,
517
519
  );
518
520
  usages.push(usage);
@@ -1,4 +1,4 @@
1
- import { eq, and, lte, isNull, or, desc, sql } from "drizzle-orm";
1
+ import { eq, and, lte, isNull, or, desc, sql, inArray } from "drizzle-orm";
2
2
  import type { TxContext } from "../../../kernel/database/tx-context.js";
3
3
  import type {
4
4
  DrizzleDatabase,
@@ -195,28 +195,30 @@ export class WebhooksRepository {
195
195
  }
196
196
 
197
197
  async findFailedDeliveries(
198
+ orgId: string,
198
199
  endpointId?: string,
199
200
  ctx?: TxContext,
200
201
  ): Promise<WebhookDelivery[]> {
201
202
  const db = this.getDb(ctx);
202
203
 
203
- if (endpointId) {
204
- return db
205
- .select()
206
- .from(webhookDeliveries)
207
- .where(
208
- and(
209
- eq(webhookDeliveries.endpointId, endpointId),
210
- sql`${webhookDeliveries.failedAt} IS NOT NULL`,
211
- ),
212
- )
213
- .orderBy(desc(webhookDeliveries.failedAt));
214
- }
204
+ // webhook_deliveries has no organizationId column — org lives on the parent
205
+ // endpoint. Scope failed deliveries to endpoints owned by orgId so this can
206
+ // never read another tenant's deliveries.
207
+ const orgEndpointIds = db
208
+ .select({ id: webhookEndpoints.id })
209
+ .from(webhookEndpoints)
210
+ .where(eq(webhookEndpoints.organizationId, orgId));
211
+
212
+ const conditions = [
213
+ sql`${webhookDeliveries.failedAt} IS NOT NULL`,
214
+ inArray(webhookDeliveries.endpointId, orgEndpointIds),
215
+ ];
216
+ if (endpointId) conditions.push(eq(webhookDeliveries.endpointId, endpointId));
215
217
 
216
218
  return db
217
219
  .select()
218
220
  .from(webhookDeliveries)
219
- .where(sql`${webhookDeliveries.failedAt} IS NOT NULL`)
221
+ .where(and(...conditions))
220
222
  .orderBy(desc(webhookDeliveries.failedAt));
221
223
  }
222
224
 
@@ -11,6 +11,7 @@ import { withTiming } from "../kernel/service-timing.js";
11
11
  import { setBootDefaultOrgId } from "../auth/org.js";
12
12
  import { setBootStrictOrgResolution } from "../auth/strict-org-resolution.js";
13
13
  import { DrizzleJobsAdapter } from "../kernel/jobs/drizzle-adapter.js";
14
+ import type { ExecutionEngine } from "../kernel/jobs/adapter.js";
14
15
  import { CompensationFailuresRepository } from "../kernel/compensation/repository.js";
15
16
 
16
17
  import {
@@ -72,8 +73,21 @@ export function createKernel(config: CommerceConfig): Kernel {
72
73
  const jobsTaskMap = new Map(
73
74
  (config.jobs?.tasks ?? []).map((t) => [t.slug, t]),
74
75
  );
75
- const jobsAdapter = new DrizzleJobsAdapter(db, jobsTaskMap);
76
- serviceContainer.jobs = jobsAdapter;
76
+ const jobsEngine: ExecutionEngine =
77
+ config.jobs?.adapter ?? new DrizzleJobsAdapter(db);
78
+ const jobLogger = {
79
+ info: (message: string, data?: unknown) => logger.info(message, data),
80
+ warn: (message: string, data?: unknown) => logger.warn(message, data),
81
+ error: (message: string, data?: unknown) => logger.error(message, data),
82
+ };
83
+ jobsEngine.register({
84
+ tasks: jobsTaskMap,
85
+ context: { logger: jobLogger, db, services: serviceContainer },
86
+ ...(config.jobs?.processingOrder !== undefined
87
+ ? { processingOrder: config.jobs.processingOrder }
88
+ : {}),
89
+ });
90
+ serviceContainer.jobs = jobsEngine;
77
91
 
78
92
  const topoGraph = kernelModulesForTopoSort();
79
93
  const order = topoSortModules(topoGraph);
@@ -17,7 +17,6 @@ import { createCustomerPortalRoutes } from "../interfaces/rest/customer-portal.j
17
17
  import { createKernel } from "./kernel.js";
18
18
  import { ensureDefaultOrg } from "../auth/org.js";
19
19
  import { createLogger, type Logger } from "./logger.js";
20
- import type { DrizzleDatabase } from "../kernel/database/drizzle-db.js";
21
20
  import { createCommerce, type CommerceInstance } from "./commerce.js";
22
21
  import { rewriteCommerceAliasRequest } from "./url-alias-rewrite.js";
23
22
  import { mapErrorToResponse } from "../kernel/error-mapper.js";
@@ -408,48 +407,18 @@ export async function createServer(config: CommerceConfig) {
408
407
  // 2. GET /api/jobs/run: cron endpoint (serverless — Vercel, Cloudflare)
409
408
  // 3. runPendingJobs() export (custom worker process)
410
409
 
411
- const { runPendingJobs } = await import("../kernel/jobs/runner.js");
412
- const { runStaleJobReaper, getJobReapThresholdMs, getJobsReaperIntervalMs } =
413
- await import("../kernel/jobs/reaper.js");
414
- const taskMap = new Map<string, unknown>();
415
- for (const task of config.jobs?.tasks ?? []) {
416
- const t = task as { slug?: string; name?: string };
417
- taskMap.set(t.slug ?? t.name ?? "", task);
418
- }
419
-
420
- const jobLogger = {
421
- info: (msg: string, data?: unknown) => logger.info(data != null ? { data } : {}, msg),
422
- warn: (msg: string, data?: unknown) => logger.warn(data != null ? { data } : {}, msg),
423
- error: (msg: string, data?: unknown) => logger.error(data != null ? { data } : {}, msg),
424
- };
425
-
426
- let lastStaleJobReaperAt = 0;
410
+ const jobsEngine = (kernel.services as Record<string, unknown>)
411
+ .jobs as import("../kernel/jobs/adapter.js").ExecutionEngine;
427
412
 
428
- const maybeRunStaleJobReaper = async () => {
429
- const interval = getJobsReaperIntervalMs();
430
- const now = Date.now();
431
- if (now - lastStaleJobReaperAt < interval) return;
432
- lastStaleJobReaperAt = now;
433
- try {
434
- await runStaleJobReaper(
435
- kernel.database.db as DrizzleDatabase,
436
- getJobReapThresholdMs(),
437
- jobLogger,
413
+ const runJobs = async (queue?: string, limit?: number) => {
414
+ if (jobsEngine.execution.mode !== "pull") {
415
+ throw new Error(
416
+ "The configured push execution engine is driven by its native runtime and cannot be polled with runJobs().",
438
417
  );
439
- } catch (err) {
440
- logger.error({ err }, "Stale job reaper failed");
441
418
  }
442
- };
443
-
444
- const runJobs = async (queue?: string, limit?: number) => {
445
- await maybeRunStaleJobReaper();
446
- return runPendingJobs({
447
- db: kernel.database.db as DrizzleDatabase,
448
- tasks: taskMap as Parameters<typeof runPendingJobs>[0]["tasks"],
419
+ return jobsEngine.execution.run({
449
420
  queue: queue ?? "default",
450
421
  limit: limit ?? 10,
451
- logger: jobLogger,
452
- services: kernel.services as Parameters<typeof runPendingJobs>[0]["services"],
453
422
  });
454
423
  };
455
424
 
@@ -478,6 +447,11 @@ export async function createServer(config: CommerceConfig) {
478
447
  // Strategy 2: In-process polling for long-running servers (ECS, Cloud Run, Docker).
479
448
  // Enable via config.jobs.autorun.enabled = true
480
449
  if (config.jobs?.autorun?.enabled) {
450
+ if (jobsEngine.execution.mode !== "pull") {
451
+ throw new Error(
452
+ "jobs.autorun is only valid for pull execution engines. Serve the configured push engine through its native runtime.",
453
+ );
454
+ }
481
455
  const intervalMs = config.jobs.autorun.intervalMs ?? 10_000;
482
456
 
483
457
  const jobInterval = setInterval(async () => {
@@ -494,10 +468,16 @@ export async function createServer(config: CommerceConfig) {
494
468
 
495
469
  logger.info({ intervalMs }, "Job queue autorun started (in-process polling)");
496
470
  } else {
497
- logger.info(
498
- "Job queue autorun disabled. Use GET /api/jobs/run for serverless cron, " +
499
- "or set config.jobs.autorun.enabled = true for in-process polling.",
500
- );
471
+ if (jobsEngine.execution.mode === "push") {
472
+ logger.info(
473
+ "Push job engine registered. Serve it through its native runtime.",
474
+ );
475
+ } else {
476
+ logger.info(
477
+ "Job queue autorun disabled. Use GET /api/jobs/run for serverless cron, " +
478
+ "or set config.jobs.autorun.enabled = true for in-process polling.",
479
+ );
480
+ }
501
481
  }
502
482
 
503
483
  const dispatchFetch = app.fetch.bind(app);