@porulle/core 0.9.0 → 0.10.1

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 (113) 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/adapter.d.ts +8 -0
  21. package/dist/kernel/database/adapter.d.ts.map +1 -1
  22. package/dist/kernel/database/migrate.d.ts +1 -1
  23. package/dist/kernel/database/migrate.d.ts.map +1 -1
  24. package/dist/kernel/database/migrate.js +5 -2
  25. package/dist/kernel/database/scoped-db.d.ts.map +1 -1
  26. package/dist/kernel/database/scoped-db.js +21 -23
  27. package/dist/kernel/errors.d.ts +12 -0
  28. package/dist/kernel/errors.d.ts.map +1 -1
  29. package/dist/kernel/errors.js +16 -0
  30. package/dist/kernel/jobs/adapter.d.ts +27 -8
  31. package/dist/kernel/jobs/adapter.d.ts.map +1 -1
  32. package/dist/kernel/jobs/adapter.js +1 -2
  33. package/dist/kernel/jobs/drizzle-adapter.d.ts +13 -3
  34. package/dist/kernel/jobs/drizzle-adapter.d.ts.map +1 -1
  35. package/dist/kernel/jobs/drizzle-adapter.js +46 -5
  36. package/dist/kernel/jobs/runner.d.ts +2 -1
  37. package/dist/kernel/jobs/runner.d.ts.map +1 -1
  38. package/dist/kernel/jobs/runner.js +100 -15
  39. package/dist/kernel/jobs/types.d.ts +13 -0
  40. package/dist/kernel/jobs/types.d.ts.map +1 -1
  41. package/dist/kernel/plugin/manifest.d.ts +2 -0
  42. package/dist/kernel/plugin/manifest.d.ts.map +1 -1
  43. package/dist/kernel/plugin/manifest.js +9 -0
  44. package/dist/modules/catalog/entity-service.d.ts +1 -0
  45. package/dist/modules/catalog/entity-service.d.ts.map +1 -1
  46. package/dist/modules/catalog/entity-service.js +15 -1
  47. package/dist/modules/catalog/repository/index.d.ts +2 -2
  48. package/dist/modules/catalog/repository/index.d.ts.map +1 -1
  49. package/dist/modules/catalog/repository/index.js +9 -1
  50. package/dist/modules/catalog/schema.d.ts +51 -0
  51. package/dist/modules/catalog/schema.d.ts.map +1 -1
  52. package/dist/modules/catalog/schema.js +12 -1
  53. package/dist/modules/catalog/schemas.d.ts +3 -1
  54. package/dist/modules/catalog/schemas.d.ts.map +1 -1
  55. package/dist/modules/channels/adapter.d.ts +117 -0
  56. package/dist/modules/channels/adapter.d.ts.map +1 -0
  57. package/dist/modules/channels/adapter.js +3 -0
  58. package/dist/modules/inventory/repository/index.js +4 -4
  59. package/dist/modules/inventory/service.d.ts.map +1 -1
  60. package/dist/modules/inventory/service.js +7 -3
  61. package/dist/modules/orders/service.d.ts.map +1 -1
  62. package/dist/modules/orders/service.js +7 -4
  63. package/dist/modules/orders/stale-order-cleanup.d.ts.map +1 -1
  64. package/dist/modules/orders/stale-order-cleanup.js +25 -16
  65. package/dist/modules/promotions/repository/index.d.ts +1 -1
  66. package/dist/modules/promotions/repository/index.d.ts.map +1 -1
  67. package/dist/modules/promotions/repository/index.js +6 -3
  68. package/dist/modules/promotions/service.d.ts +1 -0
  69. package/dist/modules/promotions/service.d.ts.map +1 -1
  70. package/dist/modules/promotions/service.js +1 -1
  71. package/dist/modules/webhooks/repository/index.d.ts +1 -1
  72. package/dist/modules/webhooks/repository/index.d.ts.map +1 -1
  73. package/dist/modules/webhooks/repository/index.js +16 -10
  74. package/dist/runtime/commerce.d.ts.map +1 -1
  75. package/dist/runtime/commerce.js +9 -0
  76. package/dist/runtime/kernel.d.ts.map +1 -1
  77. package/dist/runtime/kernel.js +14 -2
  78. package/dist/runtime/server.d.ts +1 -4
  79. package/dist/runtime/server.d.ts.map +1 -1
  80. package/dist/runtime/server.js +16 -34
  81. package/package.json +3 -3
  82. package/src/config/defaults.ts +1 -1
  83. package/src/config/types.ts +7 -3
  84. package/src/generated/plugin-capabilities.d.ts +1 -0
  85. package/src/generated/plugin-manifest.ts +1 -0
  86. package/src/generated/plugin-repositories.d.ts +1 -0
  87. package/src/index.ts +31 -1
  88. package/src/interfaces/rest/router.ts +2 -0
  89. package/src/interfaces/rest/routes/checkout.ts +1 -0
  90. package/src/kernel/database/adapter.ts +8 -0
  91. package/src/kernel/database/migrate.ts +8 -2
  92. package/src/kernel/database/scoped-db.ts +25 -26
  93. package/src/kernel/errors.ts +20 -0
  94. package/src/kernel/jobs/adapter.ts +39 -8
  95. package/src/kernel/jobs/drizzle-adapter.ts +72 -9
  96. package/src/kernel/jobs/runner.ts +194 -72
  97. package/src/kernel/jobs/types.ts +25 -0
  98. package/src/kernel/plugin/manifest.ts +12 -0
  99. package/src/modules/catalog/entity-service.ts +9 -1
  100. package/src/modules/catalog/repository/index.ts +13 -3
  101. package/src/modules/catalog/schema.ts +33 -18
  102. package/src/modules/catalog/schemas.ts +3 -1
  103. package/src/modules/channels/adapter.ts +139 -0
  104. package/src/modules/inventory/repository/index.ts +4 -4
  105. package/src/modules/inventory/service.ts +12 -2
  106. package/src/modules/orders/service.ts +8 -12
  107. package/src/modules/orders/stale-order-cleanup.ts +35 -24
  108. package/src/modules/promotions/repository/index.ts +6 -2
  109. package/src/modules/promotions/service.ts +2 -0
  110. package/src/modules/webhooks/repository/index.ts +16 -14
  111. package/src/runtime/commerce.ts +10 -0
  112. package/src/runtime/kernel.ts +16 -2
  113. package/src/runtime/server.ts +22 -42
@@ -32,25 +32,23 @@ function tableHasOrganizationId(table: unknown): boolean {
32
32
  }
33
33
 
34
34
  /**
35
- * Pre-apply the org predicate to a WHERE-able builder (UPDATE/DELETE) and wrap
36
- * its `.where()` so a caller-supplied condition is AND-ed with the org filter
37
- * rather than replacing it. Pre-applying `orgEq` immediately means a builder
38
- * executed with NO caller `.where()` (e.g. `update(t).set(...)`) is still
39
- * constrained to the orgit can never touch another tenant's rows.
35
+ * Wrap a WHERE-able Drizzle builder so every `.where()` call AND-s `orgEq` in
36
+ * AND re-wraps its result. Re-wrapping is what makes a chained
37
+ * `.where(a).where(b)` safe: Drizzle's second `.where()` REPLACES the first, so
38
+ * without re-wrapping the chained call would reach the raw builder and execute
39
+ * with only the caller's condition dropping the org predicate and leaking
40
+ * across tenants. Every link in the chain re-injects `orgEq`.
40
41
  */
41
- function scopeWhereBuilder(
42
- builder: { where: (c: SQL | undefined) => unknown },
43
- orgEq: SQL,
44
- ): unknown {
45
- const scoped = builder.where(orgEq);
46
- return new Proxy(scoped as Record<string, unknown>, {
42
+ function wrapWhereable(builder: unknown, orgEq: SQL): unknown {
43
+ return new Proxy(builder as Record<string, unknown>, {
47
44
  get(st, sp, sr) {
48
45
  const sv = Reflect.get(st, sp, sr);
49
46
  if (sp === "where" && typeof sv === "function") {
50
47
  return (condition?: unknown, ...wRest: unknown[]) => {
51
48
  const c = condition as SQL | undefined;
52
49
  const merged = c ? and(orgEq, c) : orgEq;
53
- return (sv as (...a: unknown[]) => unknown).call(st, merged, ...wRest);
50
+ const next = (sv as (...a: unknown[]) => unknown).call(st, merged, ...wRest);
51
+ return wrapWhereable(next, orgEq);
54
52
  };
55
53
  }
56
54
  return typeof sv === "function" ? sv.bind(st) : sv;
@@ -58,6 +56,20 @@ function scopeWhereBuilder(
58
56
  });
59
57
  }
60
58
 
59
+ /**
60
+ * Pre-apply the org predicate to a WHERE-able builder (UPDATE/DELETE) and wrap
61
+ * its `.where()` so a caller-supplied condition is AND-ed with the org filter
62
+ * rather than replacing it. Pre-applying `orgEq` immediately means a builder
63
+ * executed with NO caller `.where()` (e.g. `update(t).set(...)`) is still
64
+ * constrained to the org — it can never touch another tenant's rows.
65
+ */
66
+ function scopeWhereBuilder(
67
+ builder: { where: (c: SQL | undefined) => unknown },
68
+ orgEq: SQL,
69
+ ): unknown {
70
+ return wrapWhereable(builder.where(orgEq), orgEq);
71
+ }
72
+
61
73
  export function createScopedDb<TDb>(rawDb: TDb, orgSource: ScopedOrganizationId): TDb {
62
74
  if (!rawDb || typeof rawDb !== "object") return rawDb;
63
75
 
@@ -112,20 +124,7 @@ export function createScopedDb<TDb>(rawDb: TDb, orgSource: ScopedOrganizationId)
112
124
 
113
125
  const orgEq = eq(orgCol, resolveOrganizationId(orgSource));
114
126
  const inner = chain as { where: (c: SQL | undefined) => unknown };
115
- const scoped = inner.where(orgEq);
116
- return new Proxy(scoped as Record<string, unknown>, {
117
- get(st, sp, sr) {
118
- const sv = Reflect.get(st, sp, sr);
119
- if (sp === "where" && typeof sv === "function") {
120
- return (condition?: unknown, ...wRest: unknown[]) => {
121
- const c = condition as SQL | undefined;
122
- const merged = c ? and(orgEq, c) : orgEq;
123
- return sv.call(st, merged, ...wRest);
124
- };
125
- }
126
- return typeof sv === "function" ? sv.bind(st) : sv;
127
- },
128
- });
127
+ return wrapWhereable(inner.where(orgEq), orgEq);
129
128
  };
130
129
  }
131
130
  return typeof sbVal === "function" ? sbVal.bind(sbTarget) : sbVal;
@@ -32,6 +32,26 @@ export class CommerceValidationError extends Error implements CommerceError {
32
32
  }
33
33
  }
34
34
 
35
+ /** Canonical message for a missing inventory record — the single source used by
36
+ * the inventory repository and mapped to the typed error below. */
37
+ export const INVENTORY_RECORD_NOT_FOUND_MESSAGE =
38
+ "No inventory record found for this entity.";
39
+
40
+ /** Raised when an inventory reserve/release targets an entity with no inventory
41
+ * record. Consumers (e.g. order status transitions that tolerate a missing
42
+ * record) discriminate on `code === "INVENTORY_RECORD_NOT_FOUND"`, never the
43
+ * message string. */
44
+ export class CommerceInventoryRecordNotFoundError extends Error implements CommerceError {
45
+ code = "INVENTORY_RECORD_NOT_FOUND" as const;
46
+ constructor(
47
+ message: string = INVENTORY_RECORD_NOT_FOUND_MESSAGE,
48
+ public details?: unknown,
49
+ ) {
50
+ super(message);
51
+ this.name = "CommerceInventoryRecordNotFoundError";
52
+ }
53
+ }
54
+
35
55
  export class CommerceForbiddenError extends Error implements CommerceError {
36
56
  code = "FORBIDDEN" as const;
37
57
  constructor(
@@ -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;