@porulle/core 0.8.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 (162) hide show
  1. package/dist/auth/middleware.d.ts.map +1 -1
  2. package/dist/auth/middleware.js +26 -7
  3. package/dist/auth/setup.d.ts.map +1 -1
  4. package/dist/auth/setup.js +7 -5
  5. package/dist/config/defaults.d.ts.map +1 -1
  6. package/dist/config/defaults.js +3 -1
  7. package/dist/config/types.d.ts +13 -3
  8. package/dist/config/types.d.ts.map +1 -1
  9. package/dist/generated/plugin-manifest.d.ts +3 -0
  10. package/dist/generated/plugin-manifest.d.ts.map +1 -1
  11. package/dist/generated/plugin-manifest.js +1 -0
  12. package/dist/hooks/checkout-completion.d.ts.map +1 -1
  13. package/dist/hooks/checkout-completion.js +2 -2
  14. package/dist/hooks/checkout.d.ts.map +1 -1
  15. package/dist/hooks/checkout.js +1 -1
  16. package/dist/index.d.ts +5 -2
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +2 -0
  19. package/dist/interfaces/rest/router.d.ts.map +1 -1
  20. package/dist/interfaces/rest/router.js +2 -0
  21. package/dist/interfaces/rest/routes/admin/staff.d.ts.map +1 -1
  22. package/dist/interfaces/rest/routes/admin/staff.js +37 -3
  23. package/dist/interfaces/rest/routes/checkout.d.ts +9 -0
  24. package/dist/interfaces/rest/routes/checkout.d.ts.map +1 -1
  25. package/dist/interfaces/rest/routes/checkout.js +82 -22
  26. package/dist/interfaces/rest/routes/inventory.d.ts.map +1 -1
  27. package/dist/interfaces/rest/routes/inventory.js +13 -0
  28. package/dist/interfaces/rest/routes/orders.d.ts.map +1 -1
  29. package/dist/interfaces/rest/routes/orders.js +13 -0
  30. package/dist/interfaces/rest/schemas/catalog.d.ts +391 -0
  31. package/dist/interfaces/rest/schemas/catalog.d.ts.map +1 -1
  32. package/dist/interfaces/rest/schemas/orders.js +2 -2
  33. package/dist/interfaces/rest/schemas/responses.d.ts +85 -0
  34. package/dist/interfaces/rest/schemas/responses.d.ts.map +1 -1
  35. package/dist/interfaces/rest/schemas/search.d.ts +17 -0
  36. package/dist/interfaces/rest/schemas/search.d.ts.map +1 -1
  37. package/dist/kernel/database/scoped-db.d.ts +2 -1
  38. package/dist/kernel/database/scoped-db.d.ts.map +1 -1
  39. package/dist/kernel/database/scoped-db.js +74 -15
  40. package/dist/kernel/errors.d.ts +12 -0
  41. package/dist/kernel/errors.d.ts.map +1 -1
  42. package/dist/kernel/errors.js +16 -0
  43. package/dist/kernel/jobs/adapter.d.ts +27 -8
  44. package/dist/kernel/jobs/adapter.d.ts.map +1 -1
  45. package/dist/kernel/jobs/adapter.js +1 -2
  46. package/dist/kernel/jobs/drizzle-adapter.d.ts +13 -3
  47. package/dist/kernel/jobs/drizzle-adapter.d.ts.map +1 -1
  48. package/dist/kernel/jobs/drizzle-adapter.js +46 -5
  49. package/dist/kernel/jobs/runner.d.ts +2 -1
  50. package/dist/kernel/jobs/runner.d.ts.map +1 -1
  51. package/dist/kernel/jobs/runner.js +100 -15
  52. package/dist/kernel/jobs/types.d.ts +13 -0
  53. package/dist/kernel/jobs/types.d.ts.map +1 -1
  54. package/dist/kernel/plugin/manifest.d.ts +5 -0
  55. package/dist/kernel/plugin/manifest.d.ts.map +1 -1
  56. package/dist/kernel/plugin/manifest.js +9 -0
  57. package/dist/modules/analytics/drizzle-adapter.d.ts.map +1 -1
  58. package/dist/modules/analytics/drizzle-adapter.js +16 -3
  59. package/dist/modules/cart/service.d.ts.map +1 -1
  60. package/dist/modules/cart/service.js +3 -2
  61. package/dist/modules/catalog/entity-service.d.ts +1 -0
  62. package/dist/modules/catalog/entity-service.d.ts.map +1 -1
  63. package/dist/modules/catalog/entity-service.js +49 -3
  64. package/dist/modules/catalog/repository/index.d.ts +2 -2
  65. package/dist/modules/catalog/repository/index.d.ts.map +1 -1
  66. package/dist/modules/catalog/repository/index.js +9 -1
  67. package/dist/modules/catalog/schema.d.ts +51 -0
  68. package/dist/modules/catalog/schema.d.ts.map +1 -1
  69. package/dist/modules/catalog/schema.js +12 -1
  70. package/dist/modules/catalog/schemas.d.ts +3 -1
  71. package/dist/modules/catalog/schemas.d.ts.map +1 -1
  72. package/dist/modules/channels/adapter.d.ts +117 -0
  73. package/dist/modules/channels/adapter.d.ts.map +1 -0
  74. package/dist/modules/channels/adapter.js +3 -0
  75. package/dist/modules/customers/service.d.ts.map +1 -1
  76. package/dist/modules/customers/service.js +6 -0
  77. package/dist/modules/inventory/repository/index.js +4 -4
  78. package/dist/modules/inventory/service.d.ts.map +1 -1
  79. package/dist/modules/inventory/service.js +7 -3
  80. package/dist/modules/orders/schema.d.ts +17 -0
  81. package/dist/modules/orders/schema.d.ts.map +1 -1
  82. package/dist/modules/orders/schema.js +4 -0
  83. package/dist/modules/orders/service.d.ts +16 -2
  84. package/dist/modules/orders/service.d.ts.map +1 -1
  85. package/dist/modules/orders/service.js +226 -26
  86. package/dist/modules/orders/stale-order-cleanup.d.ts.map +1 -1
  87. package/dist/modules/orders/stale-order-cleanup.js +37 -16
  88. package/dist/modules/pricing/service.d.ts.map +1 -1
  89. package/dist/modules/pricing/service.js +5 -5
  90. package/dist/modules/promotions/repository/index.d.ts +1 -1
  91. package/dist/modules/promotions/repository/index.d.ts.map +1 -1
  92. package/dist/modules/promotions/repository/index.js +6 -3
  93. package/dist/modules/promotions/service.d.ts +1 -0
  94. package/dist/modules/promotions/service.d.ts.map +1 -1
  95. package/dist/modules/promotions/service.js +1 -1
  96. package/dist/modules/webhooks/repository/index.d.ts +1 -1
  97. package/dist/modules/webhooks/repository/index.d.ts.map +1 -1
  98. package/dist/modules/webhooks/repository/index.js +16 -10
  99. package/dist/runtime/kernel.d.ts.map +1 -1
  100. package/dist/runtime/kernel.js +14 -2
  101. package/dist/runtime/server.d.ts +1 -4
  102. package/dist/runtime/server.d.ts.map +1 -1
  103. package/dist/runtime/server.js +16 -34
  104. package/dist/test-utils/create-test-config.d.ts.map +1 -1
  105. package/dist/test-utils/create-test-config.js +2 -0
  106. package/dist/test-utils/order-test-helpers.d.ts +12 -0
  107. package/dist/test-utils/order-test-helpers.d.ts.map +1 -0
  108. package/dist/test-utils/order-test-helpers.js +18 -0
  109. package/dist/test-utils/rest-api-test-utils.d.ts.map +1 -1
  110. package/dist/test-utils/rest-api-test-utils.js +1 -0
  111. package/dist/test-utils/test-actors.js +1 -1
  112. package/dist/testing.d.ts +1 -0
  113. package/dist/testing.d.ts.map +1 -1
  114. package/dist/testing.js +1 -0
  115. package/package.json +1 -1
  116. package/src/auth/middleware.ts +26 -7
  117. package/src/auth/setup.ts +10 -4
  118. package/src/config/defaults.ts +3 -1
  119. package/src/config/types.ts +16 -3
  120. package/src/generated/plugin-capabilities.d.ts +1 -0
  121. package/src/generated/plugin-manifest.ts +1 -0
  122. package/src/generated/plugin-repositories.d.ts +1 -0
  123. package/src/hooks/checkout-completion.ts +22 -16
  124. package/src/hooks/checkout.ts +11 -8
  125. package/src/index.ts +31 -1
  126. package/src/interfaces/rest/router.ts +2 -0
  127. package/src/interfaces/rest/routes/admin/staff.ts +45 -3
  128. package/src/interfaces/rest/routes/checkout.ts +94 -28
  129. package/src/interfaces/rest/routes/inventory.ts +14 -0
  130. package/src/interfaces/rest/routes/orders.ts +11 -0
  131. package/src/interfaces/rest/schemas/orders.ts +2 -2
  132. package/src/kernel/database/scoped-db.ts +83 -15
  133. package/src/kernel/errors.ts +20 -0
  134. package/src/kernel/jobs/adapter.ts +39 -8
  135. package/src/kernel/jobs/drizzle-adapter.ts +72 -9
  136. package/src/kernel/jobs/runner.ts +194 -72
  137. package/src/kernel/jobs/types.ts +25 -0
  138. package/src/kernel/plugin/manifest.ts +15 -0
  139. package/src/modules/analytics/drizzle-adapter.ts +19 -3
  140. package/src/modules/cart/service.ts +3 -2
  141. package/src/modules/catalog/entity-service.ts +37 -2
  142. package/src/modules/catalog/repository/index.ts +13 -3
  143. package/src/modules/catalog/schema.ts +33 -18
  144. package/src/modules/catalog/schemas.ts +3 -1
  145. package/src/modules/channels/adapter.ts +139 -0
  146. package/src/modules/customers/service.ts +6 -0
  147. package/src/modules/inventory/repository/index.ts +4 -4
  148. package/src/modules/inventory/service.ts +12 -2
  149. package/src/modules/orders/schema.ts +4 -0
  150. package/src/modules/orders/service.ts +404 -64
  151. package/src/modules/orders/stale-order-cleanup.ts +52 -27
  152. package/src/modules/pricing/service.ts +5 -6
  153. package/src/modules/promotions/repository/index.ts +6 -2
  154. package/src/modules/promotions/service.ts +2 -0
  155. package/src/modules/webhooks/repository/index.ts +16 -14
  156. package/src/runtime/kernel.ts +16 -2
  157. package/src/runtime/server.ts +22 -42
  158. package/src/test-utils/create-test-config.ts +2 -0
  159. package/src/test-utils/order-test-helpers.ts +26 -0
  160. package/src/test-utils/rest-api-test-utils.ts +1 -0
  161. package/src/test-utils/test-actors.ts +1 -1
  162. package/src/testing.ts +1 -0
@@ -1,7 +1,7 @@
1
1
  import { eq, and, sql } from "drizzle-orm";
2
2
  import type { DrizzleDatabase } from "../database/drizzle-db.js";
3
3
  import type { Logger, ServiceContainer } from "../hooks/types.js";
4
- import type { TaskDefinition } from "./types.js";
4
+ import type { JobProcessingOrder, TaskDefinition } from "./types.js";
5
5
  import { commerceJobs } from "./schema.js";
6
6
 
7
7
  export interface RunPendingJobsArgs {
@@ -9,10 +9,53 @@ export interface RunPendingJobsArgs {
9
9
  tasks: Map<string, TaskDefinition>;
10
10
  queue?: string;
11
11
  limit?: number;
12
+ processingOrder?: JobProcessingOrder;
12
13
  logger: Logger;
13
14
  services: ServiceContainer;
14
15
  }
15
16
 
17
+ type CommerceJob = typeof commerceJobs.$inferSelect;
18
+
19
+ function isExclusiveTask(task: TaskDefinition | undefined): boolean {
20
+ return Boolean(task?.concurrency && task.concurrency.exclusive !== false);
21
+ }
22
+
23
+ function compareByProcessingOrder(
24
+ processingOrder: JobProcessingOrder | undefined,
25
+ ): (left: CommerceJob, right: CommerceJob) => number {
26
+ if (typeof processingOrder === "function") {
27
+ return (left, right) =>
28
+ processingOrder(
29
+ {
30
+ ...left,
31
+ input: left.input as Record<string, unknown>,
32
+ },
33
+ {
34
+ ...right,
35
+ input: right.input as Record<string, unknown>,
36
+ },
37
+ );
38
+ }
39
+
40
+ const field = processingOrder?.field ?? "createdAt";
41
+ const direction = processingOrder?.direction === "desc" ? -1 : 1;
42
+ return (left, right) => {
43
+ const leftValue = left[field];
44
+ const rightValue = right[field];
45
+ const compared =
46
+ leftValue instanceof Date && rightValue instanceof Date
47
+ ? leftValue.getTime() - rightValue.getTime()
48
+ : typeof leftValue === "number" && typeof rightValue === "number"
49
+ ? leftValue - rightValue
50
+ : String(leftValue).localeCompare(String(rightValue));
51
+ if (compared !== 0) return compared * direction;
52
+ return (
53
+ left.createdAt.getTime() - right.createdAt.getTime() ||
54
+ left.id.localeCompare(right.id)
55
+ );
56
+ };
57
+ }
58
+
16
59
  /**
17
60
  * Claims and processes pending jobs from the `commerce_jobs` table.
18
61
  *
@@ -29,16 +72,24 @@ export async function runPendingJobs(
29
72
  tasks,
30
73
  queue = "default",
31
74
  limit = 10,
75
+ processingOrder,
32
76
  logger,
33
77
  services,
34
78
  } = args;
35
79
 
36
- let processed = 0;
37
- let failed = 0;
38
-
39
80
  // Phase 1: Claim jobs atomically
40
81
  const claimed = await db.transaction(async (tx) => {
41
- const pending = await tx
82
+ const processing = await tx
83
+ .select({ concurrencyKey: commerceJobs.concurrencyKey })
84
+ .from(commerceJobs)
85
+ .where(eq(commerceJobs.status, "processing"));
86
+ const processingKeys = new Set(
87
+ processing.flatMap((job) =>
88
+ job.concurrencyKey ? [job.concurrencyKey] : [],
89
+ ),
90
+ );
91
+
92
+ const candidates = await tx
42
93
  .select()
43
94
  .from(commerceJobs)
44
95
  .where(
@@ -48,9 +99,34 @@ export async function runPendingJobs(
48
99
  sql`(${commerceJobs.waitUntil} IS NULL OR ${commerceJobs.waitUntil} <= now())`,
49
100
  ),
50
101
  )
51
- .orderBy(commerceJobs.createdAt)
52
- .limit(limit)
53
- .for("update", { skipLocked: true });
102
+ .orderBy(commerceJobs.createdAt);
103
+
104
+ candidates.sort(compareByProcessingOrder(processingOrder));
105
+
106
+ const pending: CommerceJob[] = [];
107
+ for (const candidate of candidates) {
108
+ if (pending.length >= limit) break;
109
+ if (
110
+ candidate.concurrencyKey &&
111
+ isExclusiveTask(tasks.get(candidate.taskSlug)) &&
112
+ processingKeys.has(candidate.concurrencyKey)
113
+ ) {
114
+ continue;
115
+ }
116
+
117
+ const [locked] = await tx
118
+ .select()
119
+ .from(commerceJobs)
120
+ .where(
121
+ and(
122
+ eq(commerceJobs.id, candidate.id),
123
+ eq(commerceJobs.status, "pending"),
124
+ ),
125
+ )
126
+ .limit(1)
127
+ .for("update", { skipLocked: true });
128
+ if (locked) pending.push(locked);
129
+ }
54
130
 
55
131
  for (const job of pending) {
56
132
  await tx
@@ -63,95 +139,141 @@ export async function runPendingJobs(
63
139
  .where(eq(commerceJobs.id, job.id));
64
140
  }
65
141
 
66
- return pending;
67
- });
142
+ const oldestByKey = new Map<string, CommerceJob>();
143
+ const jobsToRun: CommerceJob[] = [];
144
+ const jobsToRelease: CommerceJob[] = [];
68
145
 
69
- // Phase 2: Execute each claimed job outside the claim transaction
70
- for (const job of claimed) {
71
- const task = tasks.get(job.taskSlug);
72
-
73
- if (!task) {
74
- logger.warn("Unknown task slug — job marked as failed. Register the task handler in config.jobs.tasks.", {
75
- taskSlug: job.taskSlug,
76
- jobId: job.id,
77
- });
78
- await db
79
- .update(commerceJobs)
80
- .set({
81
- status: "failed",
82
- error: `Unknown task slug: ${job.taskSlug}`,
83
- updatedAt: new Date(),
84
- completedAt: new Date(),
85
- })
86
- .where(eq(commerceJobs.id, job.id));
87
- failed++;
88
- continue;
146
+ for (const job of pending) {
147
+ if (!job.concurrencyKey || !isExclusiveTask(tasks.get(job.taskSlug))) {
148
+ jobsToRun.push(job);
149
+ continue;
150
+ }
151
+
152
+ const oldest = oldestByKey.get(job.concurrencyKey);
153
+ if (!oldest) {
154
+ oldestByKey.set(job.concurrencyKey, job);
155
+ continue;
156
+ }
157
+
158
+ if (job.createdAt < oldest.createdAt) {
159
+ oldestByKey.set(job.concurrencyKey, job);
160
+ jobsToRelease.push(oldest);
161
+ } else {
162
+ jobsToRelease.push(job);
163
+ }
89
164
  }
90
165
 
91
- try {
92
- const result = await task.handler({
93
- input: job.input as Record<string, unknown>,
94
- ctx: { logger, db, services },
95
- job: {
96
- attemptNumber: job.attempts + 1,
97
- maxAttempts: job.maxAttempts,
98
- },
99
- });
166
+ jobsToRun.push(...oldestByKey.values());
100
167
 
101
- await db
168
+ for (const job of jobsToRelease) {
169
+ await tx
102
170
  .update(commerceJobs)
103
171
  .set({
104
- status: "succeeded",
105
- output: result.output,
106
- attempts: job.attempts + 1,
172
+ status: "pending",
173
+ processingStartedAt: null,
107
174
  updatedAt: new Date(),
108
- completedAt: new Date(),
109
175
  })
110
176
  .where(eq(commerceJobs.id, job.id));
177
+ }
178
+
179
+ return jobsToRun.sort(compareByProcessingOrder(processingOrder));
180
+ });
111
181
 
112
- processed++;
113
- } catch (err) {
114
- logger.error("Job handler failed", {
115
- taskSlug: job.taskSlug,
116
- jobId: job.id,
117
- error: err instanceof Error ? err.message : String(err),
118
- });
119
- const attempts = job.attempts + 1;
120
- const maxAttempts = job.maxAttempts;
121
-
122
- if (attempts >= maxAttempts) {
182
+ // Phase 2: Execute each claimed job outside the claim transaction
183
+ const outcomes = await Promise.all(
184
+ claimed.map(async (job) => {
185
+ const task = tasks.get(job.taskSlug);
186
+
187
+ if (!task) {
188
+ logger.warn(
189
+ "Unknown task slug job marked as failed. Register the task handler in config.jobs.tasks.",
190
+ {
191
+ taskSlug: job.taskSlug,
192
+ jobId: job.id,
193
+ },
194
+ );
123
195
  await db
124
196
  .update(commerceJobs)
125
197
  .set({
126
198
  status: "failed",
127
- error: err instanceof Error ? err.message : String(err),
128
- attempts,
199
+ error: `Unknown task slug: ${job.taskSlug}`,
129
200
  updatedAt: new Date(),
130
201
  completedAt: new Date(),
131
202
  })
132
203
  .where(eq(commerceJobs.id, job.id));
133
- failed++;
134
- } else {
135
- // Compute backoff delay
136
- const retries = task.retries;
137
- const delay =
138
- retries?.backoff?.type === "exponential"
139
- ? retries.backoff.delay * Math.pow(2, attempts - 1)
140
- : (retries?.backoff?.delay ?? 1000);
204
+ return "failed" as const;
205
+ }
206
+
207
+ try {
208
+ const result = await task.handler({
209
+ input: job.input as Record<string, unknown>,
210
+ ctx: { logger, db, services },
211
+ job: {
212
+ attemptNumber: job.attempts + 1,
213
+ maxAttempts: job.maxAttempts,
214
+ },
215
+ });
141
216
 
142
217
  await db
143
218
  .update(commerceJobs)
144
219
  .set({
145
- status: "pending",
146
- error: err instanceof Error ? err.message : String(err),
147
- attempts,
148
- waitUntil: new Date(Date.now() + delay),
220
+ status: "succeeded",
221
+ output: result.output,
222
+ attempts: job.attempts + 1,
149
223
  updatedAt: new Date(),
224
+ completedAt: new Date(),
150
225
  })
151
226
  .where(eq(commerceJobs.id, job.id));
227
+
228
+ return "processed" as const;
229
+ } catch (err) {
230
+ logger.error("Job handler failed", {
231
+ taskSlug: job.taskSlug,
232
+ jobId: job.id,
233
+ error: err instanceof Error ? err.message : String(err),
234
+ });
235
+ const attempts = job.attempts + 1;
236
+ const maxAttempts = job.maxAttempts;
237
+
238
+ if (attempts >= maxAttempts) {
239
+ await db
240
+ .update(commerceJobs)
241
+ .set({
242
+ status: "failed",
243
+ error: err instanceof Error ? err.message : String(err),
244
+ attempts,
245
+ updatedAt: new Date(),
246
+ completedAt: new Date(),
247
+ })
248
+ .where(eq(commerceJobs.id, job.id));
249
+ return "failed" as const;
250
+ } else {
251
+ // Compute backoff delay
252
+ const retries = task.retries;
253
+ const delay =
254
+ retries?.backoff?.type === "exponential"
255
+ ? retries.backoff.delay * Math.pow(2, attempts - 1)
256
+ : (retries?.backoff?.delay ?? 1000);
257
+
258
+ await db
259
+ .update(commerceJobs)
260
+ .set({
261
+ status: "pending",
262
+ error: err instanceof Error ? err.message : String(err),
263
+ attempts,
264
+ waitUntil: new Date(Date.now() + delay),
265
+ processingStartedAt: null,
266
+ updatedAt: new Date(),
267
+ })
268
+ .where(eq(commerceJobs.id, job.id));
269
+ return "retrying" as const;
270
+ }
152
271
  }
153
- }
154
- }
272
+ }),
273
+ );
155
274
 
156
- return { processed, failed };
275
+ return {
276
+ processed: outcomes.filter((outcome) => outcome === "processed").length,
277
+ failed: outcomes.filter((outcome) => outcome === "failed").length,
278
+ };
157
279
  }
@@ -17,6 +17,31 @@ export interface TaskRetryConfig {
17
17
  backoff?: { type: "fixed" | "exponential"; delay: number };
18
18
  }
19
19
 
20
+ export interface JobProcessingOrderRecord {
21
+ id: string;
22
+ taskSlug: string;
23
+ input: Record<string, unknown>;
24
+ attempts: number;
25
+ createdAt: Date;
26
+ updatedAt: Date;
27
+ }
28
+
29
+ export type JobProcessingOrderField =
30
+ | "createdAt"
31
+ | "updatedAt"
32
+ | "attempts"
33
+ | "taskSlug";
34
+
35
+ export type JobProcessingOrder =
36
+ | {
37
+ field: JobProcessingOrderField;
38
+ direction?: "asc" | "desc";
39
+ }
40
+ | ((
41
+ left: JobProcessingOrderRecord,
42
+ right: JobProcessingOrderRecord,
43
+ ) => number);
44
+
20
45
  /** Present when the handler is invoked by `runPendingJobs` (not for ad-hoc calls). */
21
46
  export interface TaskJobMeta {
22
47
  attemptNumber: number;
@@ -14,6 +14,7 @@ import type {
14
14
  CommercePlugin,
15
15
  PluginPermission,
16
16
  } from "../../config/types.js";
17
+ import type { TaskDefinition } from "../jobs/types.js";
17
18
 
18
19
  // ─── Plugin Logger ────────────────────────────────────────────────────
19
20
 
@@ -147,6 +148,7 @@ export interface CommercePluginManifest {
147
148
  hooks?: () => PluginHookRegistration[];
148
149
  routes?: (ctx: PluginContext) => PluginRouteRegistration[];
149
150
  analyticsModels?: () => unknown[];
151
+ jobs?: () => TaskDefinition[];
150
152
  /**
151
153
  * Named API-key scopes this plugin mints credentials under (issue #51),
152
154
  * merged into `config.auth.apiKeyScopes`. User-defined scopes with the
@@ -158,6 +160,9 @@ export interface CommercePluginManifest {
158
160
  permissions?: Record<string, string[]>;
159
161
  /** Expiry bounds in DAYS (fractions allowed — 1/24 = one hour). */
160
162
  keyExpiration?: { minExpiresIn?: number; maxExpiresIn?: number };
163
+ /** Better Auth ownership model for keys minted under this scope. */
164
+ references?: "user" | "organization";
165
+ enableMetadata?: boolean;
161
166
  }>;
162
167
  }
163
168
 
@@ -341,6 +346,16 @@ export function defineCommercePlugin(
341
346
  };
342
347
  }
343
348
 
349
+ if (manifest.jobs) {
350
+ result = {
351
+ ...result,
352
+ jobs: {
353
+ ...(result.jobs ?? {}),
354
+ tasks: [...(result.jobs?.tasks ?? []), ...manifest.jobs()],
355
+ },
356
+ };
357
+ }
358
+
344
359
  return result;
345
360
  };
346
361
  }
@@ -216,6 +216,17 @@ export class DrizzleAnalyticsAdapter implements AnalyticsAdapter {
216
216
  }
217
217
  }
218
218
 
219
+ // Validate that requested time dimensions exist in the cube
220
+ for (const td of params.timeDimensions ?? []) {
221
+ const shortName = td.dimension.split(".")[1];
222
+ if (shortName && !cube.dimensions[shortName]) {
223
+ const available = Object.keys(cube.dimensions).map((d) => `${cubeName}.${d}`).join(", ");
224
+ return Err(new CommerceValidationError(
225
+ `Unknown time dimension: "${td.dimension}". Available dimensions for ${cubeName}: ${available}`,
226
+ ));
227
+ }
228
+ }
229
+
219
230
  try {
220
231
  const rows = await this.executeQuery(cube, params, scope);
221
232
  return Ok({
@@ -330,14 +341,17 @@ export class DrizzleAnalyticsAdapter implements AnalyticsAdapter {
330
341
  const shortName = dim.split(".")[1]!;
331
342
  const dimDef = cube.dimensions[shortName];
332
343
  if (!dimDef) continue;
333
- selectParts.push(sql.raw(`${dimDef.sql} AS "${dim}"`));
344
+ const alias = `${cube.name}.${shortName}`;
345
+ selectParts.push(sql.raw(`${dimDef.sql} AS "${alias}"`));
334
346
  groupByParts.push(sql.raw(dimDef.sql));
335
347
  }
336
348
 
337
349
  // Time dimensions → SELECT + GROUP BY + WHERE (dateRange)
338
350
  for (const td of params.timeDimensions ?? []) {
351
+ const shortName = td.dimension.split(".")[1]!;
339
352
  const selectExpr = compileTimeDimensionSelect(cube, td);
340
- selectParts.push(sql`${selectExpr} AS ${sql.raw(`"${td.dimension}"`)}`);
353
+ const alias = `${cube.name}.${shortName}`;
354
+ selectParts.push(sql`${selectExpr} AS ${sql.raw(`"${alias}"`)}`);
341
355
  groupByParts.push(selectExpr);
342
356
 
343
357
  // Date range filter
@@ -357,7 +371,9 @@ export class DrizzleAnalyticsAdapter implements AnalyticsAdapter {
357
371
 
358
372
  // Measures → SELECT
359
373
  for (const measure of params.measures) {
360
- selectParts.push(sql`${compileMeasure(cube, measure)} AS ${sql.raw(`"${measure}"`)}`);
374
+ const shortName = measure.split(".")[1]!;
375
+ const alias = `${cube.name}.${shortName}`;
376
+ selectParts.push(sql`${compileMeasure(cube, measure)} AS ${sql.raw(`"${alias}"`)}`);
361
377
  }
362
378
 
363
379
  // If no select parts (shouldn't happen with validation), bail
@@ -227,8 +227,9 @@ export class CartService {
227
227
  );
228
228
  }
229
229
 
230
- // Validate entity exists
231
- const entity = await this.catalogRepo.findEntityById(input.entityId, ctx);
230
+ // Validate entity exists AND belongs to the actor's org (org-scoped lookup
231
+ // so another tenant's entity id cannot be added to this cart).
232
+ const entity = await this.catalogRepo.findEntityById(input.entityId, ctx, orgId);
232
233
  if (!entity) return Err(new CommerceNotFoundError("Entity not found."));
233
234
 
234
235
  // Check if entity has variants
@@ -98,6 +98,10 @@ export class EntityService {
98
98
  }
99
99
  }
100
100
 
101
+ private assertEntityWritable(entity: SellableEntity, actor: Actor | null): void {
102
+ if (entity.sourceStoreId != null) assertPermission(actor, "catalog:sync");
103
+ }
104
+
101
105
  private async validateAndCreateCustomFields(entityId: string, entityType: string, customFields: Record<string, unknown> | undefined, ctx?: TxContext): Promise<Result<void>> {
102
106
  if (!customFields) return Ok(undefined);
103
107
  const entityConfig = this.deps.config.entities?.[entityType];
@@ -212,7 +216,8 @@ export class EntityService {
212
216
  const afterHooks = this.deps.hooks.resolve("catalog.afterCreate") as CatalogCreateAfterHook[];
213
217
  const context: HookContext = createHookContext({ actor, tx: ctx?.tx ?? null, logger: createLogger("catalog.create"), services: this.deps.services, context: { moduleName: "catalog" }, ...hookDatabaseArg(this.deps.database) });
214
218
  const processedInput = await runBeforeHooks(beforeHooks, input, "create", context);
215
- const entity = await this.repo.createEntity({ organizationId: orgId, type: processedInput.type, slug: processedInput.slug, status: "draft", isVisible: false, ...(processedInput.taxClass !== undefined ? { taxClass: processedInput.taxClass } : {}), metadata: processedInput.metadata ?? {} }, ctx);
219
+ if (processedInput.sourceStoreId != null) assertPermission(actor, "catalog:sync");
220
+ const entity = await this.repo.createEntity({ organizationId: orgId, type: processedInput.type, slug: processedInput.slug, status: "draft", isVisible: false, ...(processedInput.sourceStoreId !== undefined ? { sourceStoreId: processedInput.sourceStoreId } : {}), ...(processedInput.taxClass !== undefined ? { taxClass: processedInput.taxClass } : {}), metadata: processedInput.metadata ?? {} }, ctx);
216
221
  if (processedInput.attributes) {
217
222
  await this.repo.createAttribute({ entityId: entity.id, locale: processedInput.attributes.locale ?? "en", title: processedInput.attributes.title, subtitle: processedInput.attributes.subtitle, description: processedInput.attributes.description, richDescription: processedInput.attributes.richDescription, seoTitle: processedInput.attributes.seoTitle, seoDescription: processedInput.attributes.seoDescription }, ctx);
218
223
  }
@@ -228,6 +233,7 @@ export class EntityService {
228
233
  const existing = await this.repo.findEntityById(id, ctx);
229
234
  if (!existing) return Err(new CommerceNotFoundError("Entity not found."));
230
235
  this.assertSameOrg(existing, actor);
236
+ this.assertEntityWritable(existing, actor);
231
237
  const beforeHooks = this.deps.hooks.resolve("catalog.beforeUpdate") as CatalogUpdateBeforeHook[];
232
238
  const afterHooks = this.deps.hooks.resolve("catalog.afterUpdate") as CatalogUpdateAfterHook[];
233
239
  const context: HookContext = createHookContext({ actor, tx: ctx?.tx ?? null, logger: createLogger("catalog.update"), services: this.deps.services, context: { moduleName: "catalog" }, ...hookDatabaseArg(this.deps.database) });
@@ -244,6 +250,7 @@ export class EntityService {
244
250
  const existing = await this.repo.findEntityById(id, ctx);
245
251
  if (!existing) return Err(new CommerceNotFoundError("Entity not found."));
246
252
  this.assertSameOrg(existing, actor);
253
+ this.assertEntityWritable(existing, actor);
247
254
  await this.repo.deleteAttributesByEntityId(id, ctx);
248
255
  await this.repo.deleteCustomFieldsByEntityId(id, ctx);
249
256
  await this.repo.deleteEntityCategoriesByEntityId(id, ctx);
@@ -271,7 +278,12 @@ export class EntityService {
271
278
  entity = refetched;
272
279
  }
273
280
  }
274
- if (actor && entity.organizationId && entity.organizationId !== resolveOrgId(actor)) return Err(new CommerceNotFoundError("Entity not found."));
281
+ const resolvedActor = actor ?? ctx?.actor ?? null;
282
+ try {
283
+ this.assertSameOrg(entity, resolvedActor);
284
+ } catch (error) {
285
+ return Err(toCommerceError(error));
286
+ }
275
287
  const result = await this.hydrateEntity(entity, processed.options ?? options, ctx);
276
288
  const entityAfterHooks = this.deps.hooks.resolve(`catalog.${entity.type}.afterRead`) as CatalogReadAfterHook[];
277
289
  const report = mergeHookReports(await runAfterHooks(globalAfterHooks, null, result, "read", context), await runAfterHooks(entityAfterHooks, null, result, "read", context));
@@ -377,6 +389,7 @@ export class EntityService {
377
389
  const entity = await this.repo.findEntityById(id, ctx);
378
390
  if (!entity) return Err(new CommerceNotFoundError("Entity not found."));
379
391
  try { this.assertSameOrg(entity, actor); } catch (error) { return Err(toCommerceError(error)); }
392
+ try { this.assertEntityWritable(entity, actor); } catch (error) { return Err(toCommerceError(error)); }
380
393
  const updateData: Partial<SellableEntity> = { status };
381
394
  if (status === "active") { updateData.publishedAt = new Date(); updateData.isVisible = true; }
382
395
  const updated = await this.repo.updateEntity(id, updateData, ctx);
@@ -407,6 +420,11 @@ export class EntityService {
407
420
  assertPermission(actor, "catalog:update");
408
421
  const entity = await this.repo.findEntityById(input.entityId, ctx);
409
422
  if (!entity) return Err(new CommerceNotFoundError("Entity not found."));
423
+ try {
424
+ this.assertSameOrg(entity, actor);
425
+ } catch (error) {
426
+ return Err(toCommerceError(error));
427
+ }
410
428
  const optionType = await this.repo.createOptionType({ entityId: input.entityId, name: input.name, displayName: input.name, sortOrder: 0 }, ctx);
411
429
  if (input.values) {
412
430
  for (const value of input.values) {
@@ -420,6 +438,13 @@ export class EntityService {
420
438
  assertPermission(actor, "catalog:update");
421
439
  const optionType = await this.repo.findOptionTypeById(input.optionTypeId, ctx);
422
440
  if (!optionType) return Err(new CommerceNotFoundError("Option type not found."));
441
+ const entity = await this.repo.findEntityById(optionType.entityId, ctx);
442
+ if (!entity) return Err(new CommerceNotFoundError("Entity not found."));
443
+ try {
444
+ this.assertSameOrg(entity, actor);
445
+ } catch (error) {
446
+ return Err(toCommerceError(error));
447
+ }
423
448
  return Ok(await this.repo.createOptionValue({ optionTypeId: input.optionTypeId, value: input.value, displayValue: input.value, sortOrder: 0, metadata: {} }, ctx));
424
449
  }
425
450
 
@@ -427,6 +452,11 @@ export class EntityService {
427
452
  assertPermission(actor, "catalog:update");
428
453
  const entity = await this.repo.findEntityById(input.entityId, ctx);
429
454
  if (!entity) return Err(new CommerceNotFoundError("Entity not found."));
455
+ try {
456
+ this.assertSameOrg(entity, actor);
457
+ } catch (error) {
458
+ return Err(toCommerceError(error));
459
+ }
430
460
  const entityOptionTypes = await this.repo.findOptionTypesByEntityId(input.entityId, ctx);
431
461
  const optionValueIds: string[] = [];
432
462
  for (const [optName, optVal] of Object.entries(input.options)) {
@@ -461,6 +491,11 @@ export class EntityService {
461
491
  }
462
492
  const entity = await this.repo.findEntityById(entityId, ctx);
463
493
  if (!entity) return Err(new CommerceNotFoundError("Entity not found."));
494
+ try {
495
+ this.assertSameOrg(entity, actor);
496
+ } catch (error) {
497
+ return Err(toCommerceError(error));
498
+ }
464
499
  const entityOptionTypes = await this.repo.findOptionTypesByEntityId(entityId, ctx);
465
500
  const sortedOptionTypes = entityOptionTypes.sort((a, b) => a.sortOrder - b.sortOrder);
466
501
  const optionValueGroups: string[][] = [];
@@ -1,5 +1,6 @@
1
1
  import { eq, and, inArray, type SQL } from "drizzle-orm";
2
2
  import type { TxContext } from "../../../kernel/database/tx-context.js";
3
+ import { CommerceNotFoundError } from "../../../kernel/errors.js";
3
4
  import type {
4
5
  DrizzleDatabase,
5
6
  DbOrTx,
@@ -698,15 +699,24 @@ export class CatalogRepository {
698
699
  return rows[0];
699
700
  }
700
701
 
701
- async createVariant(data: VariantInsert, ctx?: TxContext): Promise<Variant> {
702
+ async createVariant(
703
+ data: Omit<VariantInsert, "organizationId" | "sourceStoreId">,
704
+ ctx?: TxContext,
705
+ ): Promise<Variant> {
702
706
  const db = this.getDb(ctx);
703
- const rows = await db.insert(variants).values(data).returning();
707
+ const entity = await this.findEntityById(data.entityId, ctx);
708
+ if (!entity) throw new CommerceNotFoundError("Entity not found.");
709
+ const rows = await db.insert(variants).values({
710
+ ...data,
711
+ organizationId: entity.organizationId,
712
+ sourceStoreId: entity.sourceStoreId,
713
+ }).returning();
704
714
  return rows[0]!;
705
715
  }
706
716
 
707
717
  async updateVariant(
708
718
  id: string,
709
- data: Partial<Omit<VariantInsert, "id">>,
719
+ data: Partial<Omit<VariantInsert, "id" | "organizationId" | "sourceStoreId">>,
710
720
  ctx?: TxContext,
711
721
  ): Promise<Variant | undefined> {
712
722
  const db = this.getDb(ctx);