@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,9 +1,10 @@
1
- /**
2
- * Minimal interface for enqueueing background jobs.
3
- * The full DrizzleJobsAdapter implements this; hooks receive
4
- * it on HookContext.jobs so they can defer work without caring
5
- * about the underlying storage.
6
- */
1
+ import type {
2
+ JobProcessingOrder,
3
+ TaskContext,
4
+ TaskDefinition,
5
+ } from "./types.js";
6
+
7
+ /** Enqueue-only surface exposed to hooks and services. */
7
8
  export interface JobsAdapter {
8
9
  enqueue(
9
10
  taskSlug: string,
@@ -12,6 +13,37 @@ export interface JobsAdapter {
12
13
  ): Promise<string>;
13
14
  }
14
15
 
16
+ export interface RunJobsOptions {
17
+ queue?: string;
18
+ limit?: number;
19
+ }
20
+
21
+ export interface RunJobsResult {
22
+ processed: number;
23
+ failed: number;
24
+ }
25
+
26
+ export interface ExecutionEngineSetup {
27
+ tasks: ReadonlyMap<string, TaskDefinition>;
28
+ context: TaskContext;
29
+ processingOrder?: JobProcessingOrder;
30
+ }
31
+
32
+ export type ExecutionDriver =
33
+ | {
34
+ mode: "pull";
35
+ run(options?: RunJobsOptions): Promise<RunJobsResult>;
36
+ }
37
+ | {
38
+ mode: "push";
39
+ };
40
+
41
+ /** Full job-engine contract selected through `config.jobs.adapter`. */
42
+ export interface ExecutionEngine extends JobsAdapter {
43
+ readonly execution: ExecutionDriver;
44
+ register(setup: ExecutionEngineSetup): void;
45
+ }
46
+
15
47
  export interface EnqueueOptions {
16
48
  organizationId: string;
17
49
  queue?: string;
@@ -22,8 +54,7 @@ export interface EnqueueOptions {
22
54
  }
23
55
 
24
56
  /**
25
- * No-op adapter used when no jobs backend is configured.
26
- * All enqueue calls silently succeed and return a placeholder ID.
57
+ * No-op enqueue surface used by isolated hook contexts.
27
58
  */
28
59
  export class NullJobsAdapter implements JobsAdapter {
29
60
  async enqueue(
@@ -1,21 +1,79 @@
1
1
  import { eq, and } from "drizzle-orm";
2
2
  import type { DrizzleDatabase } from "../database/drizzle-db.js";
3
3
  import type { TaskDefinition } from "./types.js";
4
- import type { JobsAdapter, EnqueueOptions } from "./adapter.js";
4
+ import type {
5
+ EnqueueOptions,
6
+ ExecutionEngine,
7
+ ExecutionEngineSetup,
8
+ RunJobsOptions,
9
+ } from "./adapter.js";
5
10
  import { OrgResolutionError } from "../errors.js";
6
11
  import { commerceJobs } from "./schema.js";
12
+ import { runPendingJobs } from "./runner.js";
13
+ import {
14
+ getJobReapThresholdMs,
15
+ getJobsReaperIntervalMs,
16
+ runStaleJobReaper,
17
+ } from "./reaper.js";
7
18
 
8
19
  /**
9
20
  * PostgreSQL-backed job queue adapter using the application's own database.
10
21
  * Stores jobs in the `commerce_jobs` table. Supports concurrency keys
11
22
  * and supersede semantics for deduplication.
12
23
  */
13
- export class DrizzleJobsAdapter implements JobsAdapter {
24
+ export class DrizzleJobsAdapter implements ExecutionEngine {
25
+ private setup: ExecutionEngineSetup | undefined;
26
+ private lastStaleJobReaperAt = 0;
27
+
28
+ readonly execution = {
29
+ mode: "pull" as const,
30
+ run: async (options: RunJobsOptions = {}) => {
31
+ if (!this.setup) {
32
+ throw new Error(
33
+ "DrizzleJobsAdapter must be registered before running jobs.",
34
+ );
35
+ }
36
+
37
+ const now = Date.now();
38
+ if (now - this.lastStaleJobReaperAt >= getJobsReaperIntervalMs()) {
39
+ this.lastStaleJobReaperAt = now;
40
+ try {
41
+ await runStaleJobReaper(
42
+ this.db,
43
+ getJobReapThresholdMs(),
44
+ this.setup.context.logger,
45
+ );
46
+ } catch (error) {
47
+ this.setup.context.logger.error("Stale job reaper failed", {
48
+ error: error instanceof Error ? error.message : String(error),
49
+ });
50
+ }
51
+ }
52
+
53
+ return runPendingJobs({
54
+ db: this.db,
55
+ tasks: new Map(this.setup.tasks),
56
+ logger: this.setup.context.logger,
57
+ services: this.setup.context.services,
58
+ ...(options.queue !== undefined ? { queue: options.queue } : {}),
59
+ ...(options.limit !== undefined ? { limit: options.limit } : {}),
60
+ ...(this.setup.processingOrder !== undefined
61
+ ? { processingOrder: this.setup.processingOrder }
62
+ : {}),
63
+ });
64
+ },
65
+ };
66
+
14
67
  constructor(
15
68
  private db: DrizzleDatabase,
16
- private tasks: Map<string, TaskDefinition>,
69
+ private tasks: Map<string, TaskDefinition> = new Map(),
17
70
  ) {}
18
71
 
72
+ register(setup: ExecutionEngineSetup): void {
73
+ this.setup = setup;
74
+ this.tasks = new Map(setup.tasks);
75
+ }
76
+
19
77
  async enqueue(
20
78
  taskSlug: string,
21
79
  input: Record<string, unknown>,
@@ -28,22 +86,27 @@ export class DrizzleJobsAdapter implements JobsAdapter {
28
86
  );
29
87
  }
30
88
 
89
+ const task = this.tasks.get(taskSlug);
90
+ const concurrencyKey =
91
+ options.concurrencyKey ?? task?.concurrency?.key(input);
92
+ const supersedes = options.supersedes ?? task?.concurrency?.supersedes;
93
+
31
94
  // If supersedes is set, delete existing pending jobs with the same concurrency key
32
- if (options.concurrencyKey && options.supersedes) {
95
+ if (concurrencyKey && supersedes) {
33
96
  await this.db
34
97
  .delete(commerceJobs)
35
98
  .where(
36
99
  and(
37
- eq(commerceJobs.concurrencyKey, options.concurrencyKey),
100
+ eq(commerceJobs.organizationId, organizationId),
101
+ eq(commerceJobs.taskSlug, taskSlug),
102
+ eq(commerceJobs.concurrencyKey, concurrencyKey),
38
103
  eq(commerceJobs.status, "pending"),
39
104
  ),
40
105
  );
41
106
  }
42
107
 
43
108
  // Look up task definition for default retry config
44
- const task = this.tasks.get(taskSlug);
45
- const maxAttempts =
46
- options.maxAttempts ?? task?.retries?.attempts ?? 1;
109
+ const maxAttempts = options.maxAttempts ?? task?.retries?.attempts ?? 1;
47
110
 
48
111
  const rows = await this.db
49
112
  .insert(commerceJobs)
@@ -56,7 +119,7 @@ export class DrizzleJobsAdapter implements JobsAdapter {
56
119
  waitUntil: options.delayMs
57
120
  ? new Date(Date.now() + options.delayMs)
58
121
  : null,
59
- concurrencyKey: options.concurrencyKey ?? null,
122
+ concurrencyKey: concurrencyKey ?? null,
60
123
  })
61
124
  .returning({ id: commerceJobs.id });
62
125
 
@@ -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
@@ -344,6 +346,16 @@ export function defineCommercePlugin(
344
346
  };
345
347
  }
346
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
+
347
359
  return result;
348
360
  };
349
361
  }
@@ -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);
@@ -382,6 +389,7 @@ export class EntityService {
382
389
  const entity = await this.repo.findEntityById(id, ctx);
383
390
  if (!entity) return Err(new CommerceNotFoundError("Entity not found."));
384
391
  try { this.assertSameOrg(entity, actor); } catch (error) { return Err(toCommerceError(error)); }
392
+ try { this.assertEntityWritable(entity, actor); } catch (error) { return Err(toCommerceError(error)); }
385
393
  const updateData: Partial<SellableEntity> = { status };
386
394
  if (status === "active") { updateData.publishedAt = new Date(); updateData.isVisible = true; }
387
395
  const updated = await this.repo.updateEntity(id, updateData, ctx);
@@ -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);