@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.
- package/dist/auth/middleware.d.ts.map +1 -1
- package/dist/auth/middleware.js +26 -7
- package/dist/auth/setup.d.ts.map +1 -1
- package/dist/auth/setup.js +7 -5
- package/dist/config/defaults.d.ts.map +1 -1
- package/dist/config/defaults.js +3 -1
- package/dist/config/types.d.ts +13 -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/hooks/checkout-completion.d.ts.map +1 -1
- package/dist/hooks/checkout-completion.js +2 -2
- package/dist/hooks/checkout.d.ts.map +1 -1
- package/dist/hooks/checkout.js +1 -1
- 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/admin/staff.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/admin/staff.js +37 -3
- package/dist/interfaces/rest/routes/checkout.d.ts +9 -0
- package/dist/interfaces/rest/routes/checkout.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/checkout.js +82 -22
- package/dist/interfaces/rest/routes/inventory.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/inventory.js +13 -0
- package/dist/interfaces/rest/routes/orders.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/orders.js +13 -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/orders.js +2 -2
- package/dist/interfaces/rest/schemas/responses.d.ts +85 -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/scoped-db.d.ts +2 -1
- package/dist/kernel/database/scoped-db.d.ts.map +1 -1
- package/dist/kernel/database/scoped-db.js +74 -15
- 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 +5 -0
- package/dist/kernel/plugin/manifest.d.ts.map +1 -1
- package/dist/kernel/plugin/manifest.js +9 -0
- package/dist/modules/analytics/drizzle-adapter.d.ts.map +1 -1
- package/dist/modules/analytics/drizzle-adapter.js +16 -3
- package/dist/modules/cart/service.d.ts.map +1 -1
- package/dist/modules/cart/service.js +3 -2
- 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 +49 -3
- 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/customers/service.d.ts.map +1 -1
- package/dist/modules/customers/service.js +6 -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/schema.d.ts +17 -0
- package/dist/modules/orders/schema.d.ts.map +1 -1
- package/dist/modules/orders/schema.js +4 -0
- package/dist/modules/orders/service.d.ts +16 -2
- package/dist/modules/orders/service.d.ts.map +1 -1
- package/dist/modules/orders/service.js +226 -26
- package/dist/modules/orders/stale-order-cleanup.d.ts.map +1 -1
- package/dist/modules/orders/stale-order-cleanup.js +37 -16
- package/dist/modules/pricing/service.d.ts.map +1 -1
- package/dist/modules/pricing/service.js +5 -5
- 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/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/dist/test-utils/create-test-config.d.ts.map +1 -1
- package/dist/test-utils/create-test-config.js +2 -0
- package/dist/test-utils/order-test-helpers.d.ts +12 -0
- package/dist/test-utils/order-test-helpers.d.ts.map +1 -0
- package/dist/test-utils/order-test-helpers.js +18 -0
- package/dist/test-utils/rest-api-test-utils.d.ts.map +1 -1
- package/dist/test-utils/rest-api-test-utils.js +1 -0
- package/dist/test-utils/test-actors.js +1 -1
- package/dist/testing.d.ts +1 -0
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +1 -0
- package/package.json +1 -1
- package/src/auth/middleware.ts +26 -7
- package/src/auth/setup.ts +10 -4
- package/src/config/defaults.ts +3 -1
- package/src/config/types.ts +16 -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/hooks/checkout-completion.ts +22 -16
- package/src/hooks/checkout.ts +11 -8
- package/src/index.ts +31 -1
- package/src/interfaces/rest/router.ts +2 -0
- package/src/interfaces/rest/routes/admin/staff.ts +45 -3
- package/src/interfaces/rest/routes/checkout.ts +94 -28
- package/src/interfaces/rest/routes/inventory.ts +14 -0
- package/src/interfaces/rest/routes/orders.ts +11 -0
- package/src/interfaces/rest/schemas/orders.ts +2 -2
- package/src/kernel/database/scoped-db.ts +83 -15
- 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 +15 -0
- package/src/modules/analytics/drizzle-adapter.ts +19 -3
- package/src/modules/cart/service.ts +3 -2
- package/src/modules/catalog/entity-service.ts +37 -2
- 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/customers/service.ts +6 -0
- package/src/modules/inventory/repository/index.ts +4 -4
- package/src/modules/inventory/service.ts +12 -2
- package/src/modules/orders/schema.ts +4 -0
- package/src/modules/orders/service.ts +404 -64
- package/src/modules/orders/stale-order-cleanup.ts +52 -27
- package/src/modules/pricing/service.ts +5 -6
- 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/kernel.ts +16 -2
- package/src/runtime/server.ts +22 -42
- package/src/test-utils/create-test-config.ts +2 -0
- package/src/test-utils/order-test-helpers.ts +26 -0
- package/src/test-utils/rest-api-test-utils.ts +1 -0
- package/src/test-utils/test-actors.ts +1 -1
- 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
|
|
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;
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
702
|
+
async createVariant(
|
|
703
|
+
data: Omit<VariantInsert, "organizationId" | "sourceStoreId">,
|
|
704
|
+
ctx?: TxContext,
|
|
705
|
+
): Promise<Variant> {
|
|
702
706
|
const db = this.getDb(ctx);
|
|
703
|
-
const
|
|
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);
|