@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.
- package/dist/config/defaults.js +1 -1
- package/dist/config/types.d.ts +4 -3
- package/dist/config/types.d.ts.map +1 -1
- package/dist/generated/plugin-manifest.d.ts +3 -0
- package/dist/generated/plugin-manifest.d.ts.map +1 -1
- package/dist/generated/plugin-manifest.js +1 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/interfaces/rest/router.d.ts.map +1 -1
- package/dist/interfaces/rest/router.js +2 -0
- package/dist/interfaces/rest/routes/checkout.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/checkout.js +1 -0
- package/dist/interfaces/rest/schemas/catalog.d.ts +391 -0
- package/dist/interfaces/rest/schemas/catalog.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/responses.d.ts +51 -0
- package/dist/interfaces/rest/schemas/responses.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/search.d.ts +17 -0
- package/dist/interfaces/rest/schemas/search.d.ts.map +1 -1
- package/dist/kernel/database/adapter.d.ts +8 -0
- package/dist/kernel/database/adapter.d.ts.map +1 -1
- package/dist/kernel/database/migrate.d.ts +1 -1
- package/dist/kernel/database/migrate.d.ts.map +1 -1
- package/dist/kernel/database/migrate.js +5 -2
- package/dist/kernel/database/scoped-db.d.ts.map +1 -1
- package/dist/kernel/database/scoped-db.js +21 -23
- package/dist/kernel/errors.d.ts +12 -0
- package/dist/kernel/errors.d.ts.map +1 -1
- package/dist/kernel/errors.js +16 -0
- package/dist/kernel/jobs/adapter.d.ts +27 -8
- package/dist/kernel/jobs/adapter.d.ts.map +1 -1
- package/dist/kernel/jobs/adapter.js +1 -2
- package/dist/kernel/jobs/drizzle-adapter.d.ts +13 -3
- package/dist/kernel/jobs/drizzle-adapter.d.ts.map +1 -1
- package/dist/kernel/jobs/drizzle-adapter.js +46 -5
- package/dist/kernel/jobs/runner.d.ts +2 -1
- package/dist/kernel/jobs/runner.d.ts.map +1 -1
- package/dist/kernel/jobs/runner.js +100 -15
- package/dist/kernel/jobs/types.d.ts +13 -0
- package/dist/kernel/jobs/types.d.ts.map +1 -1
- package/dist/kernel/plugin/manifest.d.ts +2 -0
- package/dist/kernel/plugin/manifest.d.ts.map +1 -1
- package/dist/kernel/plugin/manifest.js +9 -0
- package/dist/modules/catalog/entity-service.d.ts +1 -0
- package/dist/modules/catalog/entity-service.d.ts.map +1 -1
- package/dist/modules/catalog/entity-service.js +15 -1
- package/dist/modules/catalog/repository/index.d.ts +2 -2
- package/dist/modules/catalog/repository/index.d.ts.map +1 -1
- package/dist/modules/catalog/repository/index.js +9 -1
- package/dist/modules/catalog/schema.d.ts +51 -0
- package/dist/modules/catalog/schema.d.ts.map +1 -1
- package/dist/modules/catalog/schema.js +12 -1
- package/dist/modules/catalog/schemas.d.ts +3 -1
- package/dist/modules/catalog/schemas.d.ts.map +1 -1
- package/dist/modules/channels/adapter.d.ts +117 -0
- package/dist/modules/channels/adapter.d.ts.map +1 -0
- package/dist/modules/channels/adapter.js +3 -0
- package/dist/modules/inventory/repository/index.js +4 -4
- package/dist/modules/inventory/service.d.ts.map +1 -1
- package/dist/modules/inventory/service.js +7 -3
- package/dist/modules/orders/service.d.ts.map +1 -1
- package/dist/modules/orders/service.js +7 -4
- package/dist/modules/orders/stale-order-cleanup.d.ts.map +1 -1
- package/dist/modules/orders/stale-order-cleanup.js +25 -16
- package/dist/modules/promotions/repository/index.d.ts +1 -1
- package/dist/modules/promotions/repository/index.d.ts.map +1 -1
- package/dist/modules/promotions/repository/index.js +6 -3
- package/dist/modules/promotions/service.d.ts +1 -0
- package/dist/modules/promotions/service.d.ts.map +1 -1
- package/dist/modules/promotions/service.js +1 -1
- package/dist/modules/webhooks/repository/index.d.ts +1 -1
- package/dist/modules/webhooks/repository/index.d.ts.map +1 -1
- package/dist/modules/webhooks/repository/index.js +16 -10
- package/dist/runtime/commerce.d.ts.map +1 -1
- package/dist/runtime/commerce.js +9 -0
- package/dist/runtime/kernel.d.ts.map +1 -1
- package/dist/runtime/kernel.js +14 -2
- package/dist/runtime/server.d.ts +1 -4
- package/dist/runtime/server.d.ts.map +1 -1
- package/dist/runtime/server.js +16 -34
- package/package.json +3 -3
- package/src/config/defaults.ts +1 -1
- package/src/config/types.ts +7 -3
- package/src/generated/plugin-capabilities.d.ts +1 -0
- package/src/generated/plugin-manifest.ts +1 -0
- package/src/generated/plugin-repositories.d.ts +1 -0
- package/src/index.ts +31 -1
- package/src/interfaces/rest/router.ts +2 -0
- package/src/interfaces/rest/routes/checkout.ts +1 -0
- package/src/kernel/database/adapter.ts +8 -0
- package/src/kernel/database/migrate.ts +8 -2
- package/src/kernel/database/scoped-db.ts +25 -26
- package/src/kernel/errors.ts +20 -0
- package/src/kernel/jobs/adapter.ts +39 -8
- package/src/kernel/jobs/drizzle-adapter.ts +72 -9
- package/src/kernel/jobs/runner.ts +194 -72
- package/src/kernel/jobs/types.ts +25 -0
- package/src/kernel/plugin/manifest.ts +12 -0
- package/src/modules/catalog/entity-service.ts +9 -1
- package/src/modules/catalog/repository/index.ts +13 -3
- package/src/modules/catalog/schema.ts +33 -18
- package/src/modules/catalog/schemas.ts +3 -1
- package/src/modules/channels/adapter.ts +139 -0
- package/src/modules/inventory/repository/index.ts +4 -4
- package/src/modules/inventory/service.ts +12 -2
- package/src/modules/orders/service.ts +8 -12
- package/src/modules/orders/stale-order-cleanup.ts +35 -24
- package/src/modules/promotions/repository/index.ts +6 -2
- package/src/modules/promotions/service.ts +2 -0
- package/src/modules/webhooks/repository/index.ts +16 -14
- package/src/runtime/commerce.ts +10 -0
- package/src/runtime/kernel.ts +16 -2
- package/src/runtime/server.ts +22 -42
|
@@ -32,25 +32,23 @@ function tableHasOrganizationId(table: unknown): boolean {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
/**
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/src/kernel/errors.ts
CHANGED
|
@@ -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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
|
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 {
|
|
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
|
|
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 (
|
|
95
|
+
if (concurrencyKey && supersedes) {
|
|
33
96
|
await this.db
|
|
34
97
|
.delete(commerceJobs)
|
|
35
98
|
.where(
|
|
36
99
|
and(
|
|
37
|
-
eq(commerceJobs.
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
67
|
-
|
|
142
|
+
const oldestByKey = new Map<string, CommerceJob>();
|
|
143
|
+
const jobsToRun: CommerceJob[] = [];
|
|
144
|
+
const jobsToRelease: CommerceJob[] = [];
|
|
68
145
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
|
|
168
|
+
for (const job of jobsToRelease) {
|
|
169
|
+
await tx
|
|
102
170
|
.update(commerceJobs)
|
|
103
171
|
.set({
|
|
104
|
-
status: "
|
|
105
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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:
|
|
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
|
-
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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: "
|
|
146
|
-
|
|
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 {
|
|
275
|
+
return {
|
|
276
|
+
processed: outcomes.filter((outcome) => outcome === "processed").length,
|
|
277
|
+
failed: outcomes.filter((outcome) => outcome === "failed").length,
|
|
278
|
+
};
|
|
157
279
|
}
|
package/src/kernel/jobs/types.ts
CHANGED
|
@@ -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;
|