@unifiedcommerce/core 0.2.0 → 0.2.2
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/package.json +2 -1
- package/src/adapters/console-email.ts +43 -0
- package/src/auth/access.ts +187 -0
- package/src/auth/auth-schema.ts +139 -0
- package/src/auth/middleware.ts +161 -0
- package/src/auth/org.ts +41 -0
- package/src/auth/permissions.ts +28 -0
- package/src/auth/setup.ts +171 -0
- package/src/auth/system-actor.ts +19 -0
- package/src/auth/types.ts +10 -0
- package/src/config/defaults.ts +82 -0
- package/src/config/define-config.ts +53 -0
- package/src/config/types.ts +301 -0
- package/src/generated/plugin-capabilities.d.ts +20 -0
- package/src/generated/plugin-manifest.ts +23 -0
- package/src/generated/plugin-repositories.d.ts +20 -0
- package/src/hooks/checkout-completion.ts +262 -0
- package/src/hooks/checkout.ts +677 -0
- package/src/hooks/order-emails.ts +62 -0
- package/src/index.ts +215 -0
- package/src/interfaces/mcp/agent-prompt.ts +174 -0
- package/src/interfaces/mcp/context-enrichment.ts +177 -0
- package/src/interfaces/mcp/server.ts +47 -0
- package/src/interfaces/mcp/tool-builder.ts +261 -0
- package/src/interfaces/mcp/tools/analytics.ts +76 -0
- package/src/interfaces/mcp/tools/cart.ts +57 -0
- package/src/interfaces/mcp/tools/catalog.ts +299 -0
- package/src/interfaces/mcp/tools/index.ts +22 -0
- package/src/interfaces/mcp/tools/inventory.ts +161 -0
- package/src/interfaces/mcp/tools/orders.ts +104 -0
- package/src/interfaces/mcp/tools/pricing.ts +94 -0
- package/src/interfaces/mcp/tools/promotions.ts +106 -0
- package/src/interfaces/mcp/tools/registry.ts +101 -0
- package/src/interfaces/mcp/tools/search.ts +42 -0
- package/src/interfaces/mcp/tools/webhooks.ts +48 -0
- package/src/interfaces/mcp/transport.ts +128 -0
- package/src/interfaces/rest/customer-portal.ts +299 -0
- package/src/interfaces/rest/index.ts +74 -0
- package/src/interfaces/rest/router.ts +333 -0
- package/src/interfaces/rest/routes/admin-jobs.ts +58 -0
- package/src/interfaces/rest/routes/audit.ts +50 -0
- package/src/interfaces/rest/routes/carts.ts +89 -0
- package/src/interfaces/rest/routes/catalog.ts +493 -0
- package/src/interfaces/rest/routes/checkout.ts +284 -0
- package/src/interfaces/rest/routes/inventory.ts +70 -0
- package/src/interfaces/rest/routes/media.ts +86 -0
- package/src/interfaces/rest/routes/orders.ts +78 -0
- package/src/interfaces/rest/routes/payments.ts +60 -0
- package/src/interfaces/rest/routes/pricing.ts +57 -0
- package/src/interfaces/rest/routes/promotions.ts +93 -0
- package/src/interfaces/rest/routes/search.ts +71 -0
- package/src/interfaces/rest/routes/webhooks.ts +46 -0
- package/src/interfaces/rest/schemas/admin-jobs.ts +40 -0
- package/src/interfaces/rest/schemas/audit.ts +46 -0
- package/src/interfaces/rest/schemas/carts.ts +125 -0
- package/src/interfaces/rest/schemas/catalog.ts +450 -0
- package/src/interfaces/rest/schemas/checkout.ts +66 -0
- package/src/interfaces/rest/schemas/customer-portal.ts +195 -0
- package/src/interfaces/rest/schemas/inventory.ts +138 -0
- package/src/interfaces/rest/schemas/media.ts +75 -0
- package/src/interfaces/rest/schemas/orders.ts +104 -0
- package/src/interfaces/rest/schemas/pricing.ts +80 -0
- package/src/interfaces/rest/schemas/promotions.ts +110 -0
- package/src/interfaces/rest/schemas/responses.ts +85 -0
- package/src/interfaces/rest/schemas/search.ts +58 -0
- package/src/interfaces/rest/schemas/shared.ts +62 -0
- package/src/interfaces/rest/schemas/webhooks.ts +68 -0
- package/src/interfaces/rest/utils.ts +104 -0
- package/src/interfaces/rest/webhook-router.ts +50 -0
- package/src/kernel/compensation/executor.ts +61 -0
- package/src/kernel/compensation/types.ts +26 -0
- package/src/kernel/database/adapter.ts +21 -0
- package/src/kernel/database/drizzle-db.ts +56 -0
- package/src/kernel/database/migrate.ts +76 -0
- package/src/kernel/database/plugin-types.ts +34 -0
- package/src/kernel/database/schema.ts +49 -0
- package/src/kernel/database/scoped-db.ts +68 -0
- package/src/kernel/database/tx-context.ts +46 -0
- package/src/kernel/error-mapper.ts +15 -0
- package/src/kernel/errors.ts +89 -0
- package/src/kernel/factory/repository-factory.ts +244 -0
- package/src/kernel/hooks/create-context.ts +43 -0
- package/src/kernel/hooks/executor.ts +88 -0
- package/src/kernel/hooks/registry.ts +74 -0
- package/src/kernel/hooks/types.ts +52 -0
- package/src/kernel/http-error.ts +44 -0
- package/src/kernel/jobs/adapter.ts +36 -0
- package/src/kernel/jobs/drizzle-adapter.ts +58 -0
- package/src/kernel/jobs/runner.ts +153 -0
- package/src/kernel/jobs/schema.ts +46 -0
- package/src/kernel/jobs/types.ts +30 -0
- package/src/kernel/local-api.ts +187 -0
- package/src/kernel/plugin/manifest.ts +271 -0
- package/src/kernel/query/executor.ts +184 -0
- package/src/kernel/query/registry.ts +46 -0
- package/src/kernel/result.ts +33 -0
- package/src/kernel/schema/extra-columns.ts +37 -0
- package/src/kernel/service-registry.ts +76 -0
- package/src/kernel/service-timing.ts +89 -0
- package/src/kernel/state-machine/machine.ts +101 -0
- package/src/modules/analytics/drizzle-adapter.ts +426 -0
- package/src/modules/analytics/hooks.ts +11 -0
- package/src/modules/analytics/models.ts +125 -0
- package/src/modules/analytics/repository/index.ts +6 -0
- package/src/modules/analytics/service.ts +245 -0
- package/src/modules/analytics/types.ts +180 -0
- package/src/modules/audit/hooks.ts +78 -0
- package/src/modules/audit/schema.ts +33 -0
- package/src/modules/audit/service.ts +151 -0
- package/src/modules/cart/access.ts +27 -0
- package/src/modules/cart/matcher.ts +26 -0
- package/src/modules/cart/repository/index.ts +234 -0
- package/src/modules/cart/schema.ts +42 -0
- package/src/modules/cart/schemas.ts +38 -0
- package/src/modules/cart/service.ts +541 -0
- package/src/modules/catalog/repository/index.ts +772 -0
- package/src/modules/catalog/schema.ts +203 -0
- package/src/modules/catalog/schemas.ts +104 -0
- package/src/modules/catalog/service.ts +1544 -0
- package/src/modules/customers/repository/index.ts +327 -0
- package/src/modules/customers/schema.ts +64 -0
- package/src/modules/customers/service.ts +171 -0
- package/src/modules/fulfillment/repository/index.ts +426 -0
- package/src/modules/fulfillment/schema.ts +101 -0
- package/src/modules/fulfillment/service.ts +555 -0
- package/src/modules/fulfillment/types.ts +59 -0
- package/src/modules/inventory/repository/index.ts +509 -0
- package/src/modules/inventory/schema.ts +94 -0
- package/src/modules/inventory/schemas.ts +38 -0
- package/src/modules/inventory/service.ts +490 -0
- package/src/modules/media/adapter.ts +17 -0
- package/src/modules/media/repository/index.ts +274 -0
- package/src/modules/media/schema.ts +41 -0
- package/src/modules/media/service.ts +151 -0
- package/src/modules/orders/repository/index.ts +287 -0
- package/src/modules/orders/schema.ts +66 -0
- package/src/modules/orders/service.ts +619 -0
- package/src/modules/orders/stale-order-cleanup.ts +76 -0
- package/src/modules/organization/service.ts +191 -0
- package/src/modules/payments/adapter.ts +47 -0
- package/src/modules/payments/repository/index.ts +6 -0
- package/src/modules/payments/service.ts +107 -0
- package/src/modules/pricing/repository/index.ts +291 -0
- package/src/modules/pricing/schema.ts +71 -0
- package/src/modules/pricing/schemas.ts +38 -0
- package/src/modules/pricing/service.ts +494 -0
- package/src/modules/promotions/repository/index.ts +325 -0
- package/src/modules/promotions/schema.ts +62 -0
- package/src/modules/promotions/schemas.ts +38 -0
- package/src/modules/promotions/service.ts +598 -0
- package/src/modules/search/adapter.ts +57 -0
- package/src/modules/search/hooks.ts +12 -0
- package/src/modules/search/repository/index.ts +6 -0
- package/src/modules/search/service.ts +315 -0
- package/src/modules/shipping/calculator.ts +188 -0
- package/src/modules/shipping/repository/index.ts +6 -0
- package/src/modules/shipping/service.ts +51 -0
- package/src/modules/tax/adapter.ts +60 -0
- package/src/modules/tax/repository/index.ts +6 -0
- package/src/modules/tax/service.ts +53 -0
- package/src/modules/webhooks/hook.ts +34 -0
- package/src/modules/webhooks/repository/index.ts +278 -0
- package/src/modules/webhooks/schema.ts +56 -0
- package/src/modules/webhooks/service.ts +117 -0
- package/src/modules/webhooks/signing.ts +6 -0
- package/src/modules/webhooks/ssrf-guard.ts +71 -0
- package/src/modules/webhooks/tasks.ts +52 -0
- package/src/modules/webhooks/worker.ts +134 -0
- package/src/runtime/commerce.ts +145 -0
- package/src/runtime/kernel.ts +426 -0
- package/src/runtime/logger.ts +36 -0
- package/src/runtime/server.ts +355 -0
- package/src/runtime/shutdown.ts +43 -0
- package/src/test-utils/create-pglite-adapter.ts +129 -0
- package/src/test-utils/create-plugin-test-app.ts +128 -0
- package/src/test-utils/create-repository-test-harness.ts +16 -0
- package/src/test-utils/create-test-config.ts +190 -0
- package/src/test-utils/create-test-kernel.ts +7 -0
- package/src/test-utils/create-test-plugin-context.ts +75 -0
- package/src/test-utils/rest-api-test-utils.ts +265 -0
- package/src/test-utils/test-actors.ts +62 -0
- package/src/test-utils/typed-hooks.ts +54 -0
- package/src/types/commerce-types.ts +34 -0
- package/src/utils/id.ts +3 -0
- package/src/utils/logger.ts +18 -0
- package/src/utils/pagination.ts +22 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
export type HookHandler = (...args: never[]) => unknown;
|
|
2
|
+
|
|
3
|
+
type HookEntry = {
|
|
4
|
+
prepended: HookHandler[];
|
|
5
|
+
configured: HookHandler[];
|
|
6
|
+
appended: HookHandler[];
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export class HookRegistry {
|
|
10
|
+
private registry = new Map<string, HookEntry>();
|
|
11
|
+
private logger?: { error: (obj: Record<string, unknown>, msg: string) => void };
|
|
12
|
+
|
|
13
|
+
registerConfigHooks(hookName: string, handlers: HookHandler[]): void {
|
|
14
|
+
this.ensureEntry(hookName);
|
|
15
|
+
this.registry.get(hookName)!.configured = [...handlers];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
append(hookName: string, handler: HookHandler): void {
|
|
19
|
+
this.ensureEntry(hookName);
|
|
20
|
+
this.registry.get(hookName)!.appended.push(handler);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
prepend(hookName: string, handler: HookHandler): void {
|
|
24
|
+
this.ensureEntry(hookName);
|
|
25
|
+
this.registry.get(hookName)!.prepended.push(handler);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
resolve(hookName: string): HookHandler[] {
|
|
29
|
+
const entry = this.registry.get(hookName);
|
|
30
|
+
if (!entry) return [];
|
|
31
|
+
return [...entry.prepended, ...entry.configured, ...entry.appended];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Emit a plugin event to all registered listeners.
|
|
36
|
+
*
|
|
37
|
+
* Unlike the before/after hook pattern (which transforms data through
|
|
38
|
+
* a pipeline), emit is fire-and-forget notification. Errors in handlers
|
|
39
|
+
* are caught and logged but do not propagate to the emitter.
|
|
40
|
+
*
|
|
41
|
+
* Usage:
|
|
42
|
+
* kernel.hooks.emit("production.afterComplete", { orderId, quantity });
|
|
43
|
+
*
|
|
44
|
+
* Any plugin can listen:
|
|
45
|
+
* hooks: () => [{ key: "production.afterComplete", handler: async (payload) => { ... } }]
|
|
46
|
+
*/
|
|
47
|
+
setLogger(logger: { error: (obj: Record<string, unknown>, msg: string) => void }): void {
|
|
48
|
+
this.logger = logger;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async emit(key: string, payload: unknown): Promise<void> {
|
|
52
|
+
const handlers = this.resolve(key);
|
|
53
|
+
for (const handler of handlers) {
|
|
54
|
+
try {
|
|
55
|
+
await (handler as (payload: unknown) => unknown)(payload);
|
|
56
|
+
} catch (err) {
|
|
57
|
+
this.logger?.error(
|
|
58
|
+
{ err, hookKey: key },
|
|
59
|
+
`Event handler failed for "${key}"`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private ensureEntry(hookName: string): void {
|
|
66
|
+
if (!this.registry.has(hookName)) {
|
|
67
|
+
this.registry.set(hookName, {
|
|
68
|
+
prepended: [],
|
|
69
|
+
configured: [],
|
|
70
|
+
appended: [],
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Actor } from "../../auth/types.js";
|
|
2
|
+
import type { JobsAdapter } from "../jobs/adapter.js";
|
|
3
|
+
import type { PluginDb } from "../database/plugin-types.js";
|
|
4
|
+
|
|
5
|
+
export type HookOperation =
|
|
6
|
+
| "create"
|
|
7
|
+
| "update"
|
|
8
|
+
| "delete"
|
|
9
|
+
| "read"
|
|
10
|
+
| "list"
|
|
11
|
+
| "statusChange"
|
|
12
|
+
| "addItem"
|
|
13
|
+
| "removeItem"
|
|
14
|
+
| "custom";
|
|
15
|
+
|
|
16
|
+
export interface Logger {
|
|
17
|
+
info(message: string, data?: unknown): void;
|
|
18
|
+
warn(message: string, data?: unknown): void;
|
|
19
|
+
error(message: string, data?: unknown): void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ServiceContainer {
|
|
23
|
+
[key: string]: unknown;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type HookOrigin = "rest" | "local" | "mcp";
|
|
27
|
+
|
|
28
|
+
export interface HookContext {
|
|
29
|
+
actor: Actor | null;
|
|
30
|
+
tx: unknown;
|
|
31
|
+
logger: Logger;
|
|
32
|
+
services: ServiceContainer;
|
|
33
|
+
context: Record<string, unknown>;
|
|
34
|
+
requestId: string;
|
|
35
|
+
origin: HookOrigin;
|
|
36
|
+
jobs: JobsAdapter;
|
|
37
|
+
/** Drizzle database instance. Fully typed — use directly for queries in hook handlers. */
|
|
38
|
+
db: PluginDb;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type BeforeHook<TData> = (args: {
|
|
42
|
+
data: TData;
|
|
43
|
+
operation: HookOperation;
|
|
44
|
+
context: HookContext;
|
|
45
|
+
}) => Promise<TData> | TData;
|
|
46
|
+
|
|
47
|
+
export type AfterHook<TData> = (args: {
|
|
48
|
+
data: TData | null;
|
|
49
|
+
result: TData;
|
|
50
|
+
operation: HookOperation;
|
|
51
|
+
context: HookContext;
|
|
52
|
+
}) => Promise<void> | void;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Converts a PluginResultErr into a structured HTTP error response.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the `throw new Error(result.error)` pattern in plugin routes
|
|
5
|
+
* which always produces HTTP 500 with no structured error code.
|
|
6
|
+
*
|
|
7
|
+
* Usage in routes:
|
|
8
|
+
* const result = await service.doSomething(orgId, input);
|
|
9
|
+
* if (!result.ok) return toHttpError(result);
|
|
10
|
+
* return result.value;
|
|
11
|
+
*
|
|
12
|
+
* The function infers HTTP status from the error code or message content:
|
|
13
|
+
* - "NOT_FOUND" or "not found" --> 404
|
|
14
|
+
* - "FORBIDDEN" or "permission" --> 403
|
|
15
|
+
* - "CONFLICT" or "already exists" --> 409
|
|
16
|
+
* - "VALIDATION" or "cannot/must" --> 422
|
|
17
|
+
* - Everything else --> 400
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { PluginResultErr } from "./result.js";
|
|
21
|
+
|
|
22
|
+
export interface HttpErrorResponse {
|
|
23
|
+
status: 400 | 403 | 404 | 409 | 422;
|
|
24
|
+
body: { error: { code: string; message: string } };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function toHttpError(result: PluginResultErr): HttpErrorResponse {
|
|
28
|
+
const message = result.error;
|
|
29
|
+
const code = result.code;
|
|
30
|
+
|
|
31
|
+
if (code === "NOT_FOUND" || /not found/i.test(message)) {
|
|
32
|
+
return { status: 404, body: { error: { code: "NOT_FOUND", message } } };
|
|
33
|
+
}
|
|
34
|
+
if (code === "FORBIDDEN" || /permission|forbidden|unauthorized/i.test(message)) {
|
|
35
|
+
return { status: 403, body: { error: { code: "FORBIDDEN", message } } };
|
|
36
|
+
}
|
|
37
|
+
if (code === "CONFLICT" || /already|duplicate|exists|unique/i.test(message)) {
|
|
38
|
+
return { status: 409, body: { error: { code: "CONFLICT", message } } };
|
|
39
|
+
}
|
|
40
|
+
if (code === "VALIDATION" || /invalid|cannot|must|required|exceeded|negative/i.test(message)) {
|
|
41
|
+
return { status: 422, body: { error: { code: "VALIDATION_FAILED", message } } };
|
|
42
|
+
}
|
|
43
|
+
return { status: 400, body: { error: { code: code ?? "BAD_REQUEST", message } } };
|
|
44
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
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
|
+
*/
|
|
7
|
+
export interface JobsAdapter {
|
|
8
|
+
enqueue(
|
|
9
|
+
taskSlug: string,
|
|
10
|
+
input: Record<string, unknown>,
|
|
11
|
+
options?: EnqueueOptions,
|
|
12
|
+
): Promise<string>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface EnqueueOptions {
|
|
16
|
+
queue?: string;
|
|
17
|
+
maxAttempts?: number;
|
|
18
|
+
delayMs?: number;
|
|
19
|
+
concurrencyKey?: string;
|
|
20
|
+
supersedes?: boolean;
|
|
21
|
+
organizationId?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* No-op adapter used when no jobs backend is configured.
|
|
26
|
+
* All enqueue calls silently succeed and return a placeholder ID.
|
|
27
|
+
*/
|
|
28
|
+
export class NullJobsAdapter implements JobsAdapter {
|
|
29
|
+
async enqueue(
|
|
30
|
+
_taskSlug: string,
|
|
31
|
+
_input: Record<string, unknown>,
|
|
32
|
+
_options?: EnqueueOptions,
|
|
33
|
+
): Promise<string> {
|
|
34
|
+
return "null-job-id";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { eq, and } from "drizzle-orm";
|
|
2
|
+
import type { DrizzleDatabase } from "../database/drizzle-db.js";
|
|
3
|
+
import type { TaskDefinition } from "./types.js";
|
|
4
|
+
import type { JobsAdapter, EnqueueOptions } from "./adapter.js";
|
|
5
|
+
import { DEFAULT_ORG_ID } from "../../auth/org.js";
|
|
6
|
+
import { commerceJobs } from "./schema.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* PostgreSQL-backed job queue adapter using the application's own database.
|
|
10
|
+
* Stores jobs in the `commerce_jobs` table. Supports concurrency keys
|
|
11
|
+
* and supersede semantics for deduplication.
|
|
12
|
+
*/
|
|
13
|
+
export class DrizzleJobsAdapter implements JobsAdapter {
|
|
14
|
+
constructor(
|
|
15
|
+
private db: DrizzleDatabase,
|
|
16
|
+
private tasks: Map<string, TaskDefinition>,
|
|
17
|
+
) {}
|
|
18
|
+
|
|
19
|
+
async enqueue(
|
|
20
|
+
taskSlug: string,
|
|
21
|
+
input: Record<string, unknown>,
|
|
22
|
+
options?: EnqueueOptions,
|
|
23
|
+
): Promise<string> {
|
|
24
|
+
// If supersedes is set, delete existing pending jobs with the same concurrency key
|
|
25
|
+
if (options?.concurrencyKey && options.supersedes) {
|
|
26
|
+
await this.db
|
|
27
|
+
.delete(commerceJobs)
|
|
28
|
+
.where(
|
|
29
|
+
and(
|
|
30
|
+
eq(commerceJobs.concurrencyKey, options.concurrencyKey),
|
|
31
|
+
eq(commerceJobs.status, "pending"),
|
|
32
|
+
),
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Look up task definition for default retry config
|
|
37
|
+
const task = this.tasks.get(taskSlug);
|
|
38
|
+
const maxAttempts =
|
|
39
|
+
options?.maxAttempts ?? task?.retries?.attempts ?? 1;
|
|
40
|
+
|
|
41
|
+
const rows = await this.db
|
|
42
|
+
.insert(commerceJobs)
|
|
43
|
+
.values({
|
|
44
|
+
organizationId: options?.organizationId ?? DEFAULT_ORG_ID,
|
|
45
|
+
taskSlug,
|
|
46
|
+
input,
|
|
47
|
+
queue: options?.queue ?? "default",
|
|
48
|
+
maxAttempts,
|
|
49
|
+
waitUntil: options?.delayMs
|
|
50
|
+
? new Date(Date.now() + options.delayMs)
|
|
51
|
+
: null,
|
|
52
|
+
concurrencyKey: options?.concurrencyKey ?? null,
|
|
53
|
+
})
|
|
54
|
+
.returning({ id: commerceJobs.id });
|
|
55
|
+
|
|
56
|
+
return rows[0]!.id;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { eq, and, sql } from "drizzle-orm";
|
|
2
|
+
import type { DrizzleDatabase } from "../database/drizzle-db.js";
|
|
3
|
+
import type { Logger, ServiceContainer } from "../hooks/types.js";
|
|
4
|
+
import type { TaskDefinition } from "./types.js";
|
|
5
|
+
import { commerceJobs } from "./schema.js";
|
|
6
|
+
|
|
7
|
+
export interface RunPendingJobsArgs {
|
|
8
|
+
db: DrizzleDatabase;
|
|
9
|
+
tasks: Map<string, TaskDefinition>;
|
|
10
|
+
queue?: string;
|
|
11
|
+
limit?: number;
|
|
12
|
+
logger: Logger;
|
|
13
|
+
services: ServiceContainer;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Claims and processes pending jobs from the `commerce_jobs` table.
|
|
18
|
+
*
|
|
19
|
+
* Uses `FOR UPDATE SKIP LOCKED` to allow multiple runners to process
|
|
20
|
+
* jobs in parallel without conflicts. Each runner claims a batch,
|
|
21
|
+
* marks them as processing, then executes handlers outside the
|
|
22
|
+
* claim transaction.
|
|
23
|
+
*/
|
|
24
|
+
export async function runPendingJobs(
|
|
25
|
+
args: RunPendingJobsArgs,
|
|
26
|
+
): Promise<{ processed: number; failed: number }> {
|
|
27
|
+
const {
|
|
28
|
+
db,
|
|
29
|
+
tasks,
|
|
30
|
+
queue = "default",
|
|
31
|
+
limit = 10,
|
|
32
|
+
logger,
|
|
33
|
+
services,
|
|
34
|
+
} = args;
|
|
35
|
+
|
|
36
|
+
let processed = 0;
|
|
37
|
+
let failed = 0;
|
|
38
|
+
|
|
39
|
+
// Phase 1: Claim jobs atomically
|
|
40
|
+
const claimed = await db.transaction(async (tx) => {
|
|
41
|
+
const pending = await tx
|
|
42
|
+
.select()
|
|
43
|
+
.from(commerceJobs)
|
|
44
|
+
.where(
|
|
45
|
+
and(
|
|
46
|
+
eq(commerceJobs.status, "pending"),
|
|
47
|
+
eq(commerceJobs.queue, queue),
|
|
48
|
+
sql`(${commerceJobs.waitUntil} IS NULL OR ${commerceJobs.waitUntil} <= now())`,
|
|
49
|
+
),
|
|
50
|
+
)
|
|
51
|
+
.orderBy(commerceJobs.createdAt)
|
|
52
|
+
.limit(limit)
|
|
53
|
+
.for("update", { skipLocked: true });
|
|
54
|
+
|
|
55
|
+
for (const job of pending) {
|
|
56
|
+
await tx
|
|
57
|
+
.update(commerceJobs)
|
|
58
|
+
.set({
|
|
59
|
+
status: "processing",
|
|
60
|
+
processingStartedAt: new Date(),
|
|
61
|
+
updatedAt: new Date(),
|
|
62
|
+
})
|
|
63
|
+
.where(eq(commerceJobs.id, job.id));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return pending;
|
|
67
|
+
});
|
|
68
|
+
|
|
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;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const result = await task.handler({
|
|
93
|
+
input: job.input as Record<string, unknown>,
|
|
94
|
+
ctx: { logger, db, services },
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
await db
|
|
98
|
+
.update(commerceJobs)
|
|
99
|
+
.set({
|
|
100
|
+
status: "succeeded",
|
|
101
|
+
output: result.output,
|
|
102
|
+
attempts: job.attempts + 1,
|
|
103
|
+
updatedAt: new Date(),
|
|
104
|
+
completedAt: new Date(),
|
|
105
|
+
})
|
|
106
|
+
.where(eq(commerceJobs.id, job.id));
|
|
107
|
+
|
|
108
|
+
processed++;
|
|
109
|
+
} catch (err) {
|
|
110
|
+
logger.error("Job handler failed", {
|
|
111
|
+
taskSlug: job.taskSlug,
|
|
112
|
+
jobId: job.id,
|
|
113
|
+
error: err instanceof Error ? err.message : String(err),
|
|
114
|
+
});
|
|
115
|
+
const attempts = job.attempts + 1;
|
|
116
|
+
const maxAttempts = job.maxAttempts;
|
|
117
|
+
|
|
118
|
+
if (attempts >= maxAttempts) {
|
|
119
|
+
await db
|
|
120
|
+
.update(commerceJobs)
|
|
121
|
+
.set({
|
|
122
|
+
status: "failed",
|
|
123
|
+
error: err instanceof Error ? err.message : String(err),
|
|
124
|
+
attempts,
|
|
125
|
+
updatedAt: new Date(),
|
|
126
|
+
completedAt: new Date(),
|
|
127
|
+
})
|
|
128
|
+
.where(eq(commerceJobs.id, job.id));
|
|
129
|
+
failed++;
|
|
130
|
+
} else {
|
|
131
|
+
// Compute backoff delay
|
|
132
|
+
const retries = task.retries;
|
|
133
|
+
const delay =
|
|
134
|
+
retries?.backoff?.type === "exponential"
|
|
135
|
+
? retries.backoff.delay * Math.pow(2, attempts - 1)
|
|
136
|
+
: (retries?.backoff?.delay ?? 1000);
|
|
137
|
+
|
|
138
|
+
await db
|
|
139
|
+
.update(commerceJobs)
|
|
140
|
+
.set({
|
|
141
|
+
status: "pending",
|
|
142
|
+
error: err instanceof Error ? err.message : String(err),
|
|
143
|
+
attempts,
|
|
144
|
+
waitUntil: new Date(Date.now() + delay),
|
|
145
|
+
updatedAt: new Date(),
|
|
146
|
+
})
|
|
147
|
+
.where(eq(commerceJobs.id, job.id));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return { processed, failed };
|
|
153
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import {
|
|
2
|
+
pgTable,
|
|
3
|
+
uuid,
|
|
4
|
+
text,
|
|
5
|
+
jsonb,
|
|
6
|
+
integer,
|
|
7
|
+
timestamp,
|
|
8
|
+
index,
|
|
9
|
+
} from "drizzle-orm/pg-core";
|
|
10
|
+
import { organization } from "../../auth/auth-schema.js";
|
|
11
|
+
|
|
12
|
+
export const commerceJobs = pgTable("commerce_jobs", {
|
|
13
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
14
|
+
organizationId: text("organization_id")
|
|
15
|
+
.notNull()
|
|
16
|
+
.references(() => organization.id, { onDelete: "cascade" }),
|
|
17
|
+
queue: text("queue").notNull().default("default"),
|
|
18
|
+
taskSlug: text("task_slug").notNull(),
|
|
19
|
+
input: jsonb("input").notNull().default("{}"),
|
|
20
|
+
output: jsonb("output"),
|
|
21
|
+
status: text("status", {
|
|
22
|
+
enum: ["pending", "processing", "succeeded", "failed"],
|
|
23
|
+
})
|
|
24
|
+
.notNull()
|
|
25
|
+
.default("pending"),
|
|
26
|
+
attempts: integer("attempts").notNull().default(0),
|
|
27
|
+
maxAttempts: integer("max_attempts").notNull().default(1),
|
|
28
|
+
error: text("error"),
|
|
29
|
+
waitUntil: timestamp("wait_until", { withTimezone: true }),
|
|
30
|
+
concurrencyKey: text("concurrency_key"),
|
|
31
|
+
createdAt: timestamp("created_at", { withTimezone: true })
|
|
32
|
+
.notNull()
|
|
33
|
+
.defaultNow(),
|
|
34
|
+
updatedAt: timestamp("updated_at", { withTimezone: true })
|
|
35
|
+
.notNull()
|
|
36
|
+
.defaultNow(),
|
|
37
|
+
processingStartedAt: timestamp("processing_started_at", {
|
|
38
|
+
withTimezone: true,
|
|
39
|
+
}),
|
|
40
|
+
completedAt: timestamp("completed_at", { withTimezone: true }),
|
|
41
|
+
}, (table) => ({
|
|
42
|
+
statusQueueIdx: index("idx_jobs_status_queue").on(table.status, table.queue),
|
|
43
|
+
taskSlugIdx: index("idx_jobs_task_slug").on(table.taskSlug),
|
|
44
|
+
waitUntilIdx: index("idx_jobs_wait_until").on(table.waitUntil),
|
|
45
|
+
orgIdx: index("idx_jobs_org").on(table.organizationId),
|
|
46
|
+
}));
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Logger, ServiceContainer } from "../hooks/types.js";
|
|
2
|
+
import type { DrizzleDatabase } from "../database/drizzle-db.js";
|
|
3
|
+
|
|
4
|
+
export interface TaskContext {
|
|
5
|
+
logger: Logger;
|
|
6
|
+
db: DrizzleDatabase;
|
|
7
|
+
services: ServiceContainer;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface TaskRetryConfig {
|
|
11
|
+
attempts: number;
|
|
12
|
+
backoff?: { type: "fixed" | "exponential"; delay: number };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface TaskDefinition<
|
|
16
|
+
TInput extends Record<string, unknown> = Record<string, unknown>,
|
|
17
|
+
TOutput extends Record<string, unknown> = Record<string, unknown>,
|
|
18
|
+
> {
|
|
19
|
+
slug: string;
|
|
20
|
+
handler: (args: {
|
|
21
|
+
input: TInput;
|
|
22
|
+
ctx: TaskContext;
|
|
23
|
+
}) => Promise<{ output: TOutput }>;
|
|
24
|
+
retries?: TaskRetryConfig;
|
|
25
|
+
concurrency?: {
|
|
26
|
+
key: (input: TInput) => string;
|
|
27
|
+
exclusive?: boolean;
|
|
28
|
+
supersedes?: boolean;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import type { Actor } from "../auth/types.js";
|
|
2
|
+
import type { Kernel } from "../runtime/kernel.js";
|
|
3
|
+
import type { TxContext } from "./database/tx-context.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Proxy-based Local API — the Payload-style programmatic interface.
|
|
7
|
+
*
|
|
8
|
+
* Automatically exposes EVERY service on `kernel.services` (core + plugins)
|
|
9
|
+
* without hardcoding method signatures. Uses a Proxy to intercept property
|
|
10
|
+
* access and wrap each service method to auto-inject `actor` and `txCtx`.
|
|
11
|
+
*
|
|
12
|
+
* ## Usage in Next.js / TanStack Start / SvelteKit:
|
|
13
|
+
*
|
|
14
|
+
* ```typescript
|
|
15
|
+
* import { createCommerce } from "@unifiedcommerce/core";
|
|
16
|
+
* import config from "./commerce.config.js";
|
|
17
|
+
*
|
|
18
|
+
* const commerce = await createCommerce(config);
|
|
19
|
+
*
|
|
20
|
+
* // Server action / loader — no HTTP round-trip
|
|
21
|
+
* const products = await commerce.api.catalog.list({ limit: 10 });
|
|
22
|
+
* const order = await commerce.api.orders.getById("order-123");
|
|
23
|
+
*
|
|
24
|
+
* // Plugin services are automatically available
|
|
25
|
+
* const balance = await commerce.api.giftCards.checkBalance("CARD-CODE");
|
|
26
|
+
* const points = await commerce.api.loyalty.redeemPoints("org", "cust", 100);
|
|
27
|
+
* ```
|
|
28
|
+
*
|
|
29
|
+
* ## Usage from a hook:
|
|
30
|
+
*
|
|
31
|
+
* ```typescript
|
|
32
|
+
* const api = createLocalAPI(kernel, { actor, tx });
|
|
33
|
+
* const product = await api.catalog.getById("prod-123");
|
|
34
|
+
* ```
|
|
35
|
+
*
|
|
36
|
+
* ## How it works:
|
|
37
|
+
*
|
|
38
|
+
* 1. `api.catalog` → Proxy intercepts, finds `kernel.services.catalog`
|
|
39
|
+
* 2. `api.catalog.getById(id)` → Proxy intercepts method call
|
|
40
|
+
* 3. Wraps the call: `kernel.services.catalog.getById(id, actor, txCtx)`
|
|
41
|
+
* 4. Returns the result directly — no HTTP, no serialization
|
|
42
|
+
*
|
|
43
|
+
* The Proxy auto-appends `actor` and `txCtx` to every method call.
|
|
44
|
+
* Service methods that accept `(actor, ctx)` as the last two params
|
|
45
|
+
* get them injected automatically. Methods that don't accept them
|
|
46
|
+
* still work — extra args are harmlessly ignored by JavaScript.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
/** Options for creating a local API instance */
|
|
52
|
+
export interface LocalAPIOptions {
|
|
53
|
+
actor?: Actor | null;
|
|
54
|
+
tx?: unknown;
|
|
55
|
+
requestId?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Maps a service type to strip actor/txCtx params from each method.
|
|
60
|
+
* Plugin authors get clean autocomplete: `api.catalog.create(input)`
|
|
61
|
+
* instead of `api.catalog.create(input, actor, txCtx)`.
|
|
62
|
+
*/
|
|
63
|
+
type CleanService<T> = {
|
|
64
|
+
[K in keyof T]: T[K] extends (...args: infer _A) => infer R
|
|
65
|
+
? (...args: unknown[]) => R
|
|
66
|
+
: T[K];
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** The full local API type — all kernel services with cleaned signatures */
|
|
70
|
+
export type CommerceLocalAPI<
|
|
71
|
+
TServices extends Record<string, unknown> = Kernel["services"],
|
|
72
|
+
> = {
|
|
73
|
+
[K in keyof TServices]: TServices[K] extends Record<string, unknown>
|
|
74
|
+
? CleanService<TServices[K]>
|
|
75
|
+
: TServices[K];
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// ─── Implementation ─────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
const SERVICE_METHOD_CACHE = new WeakMap<object, Map<string, Function>>();
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Create a proxy-based local API over kernel services.
|
|
84
|
+
* Every service method gets `actor` and `txCtx` auto-injected.
|
|
85
|
+
*/
|
|
86
|
+
export function createLocalAPI(
|
|
87
|
+
kernel: Kernel,
|
|
88
|
+
options: LocalAPIOptions = {},
|
|
89
|
+
): CommerceLocalAPI {
|
|
90
|
+
const actor = options.actor ?? null;
|
|
91
|
+
const txCtx: TxContext | undefined =
|
|
92
|
+
options.tx != null
|
|
93
|
+
? ({
|
|
94
|
+
tx: options.tx,
|
|
95
|
+
actor,
|
|
96
|
+
requestId: options.requestId ?? crypto.randomUUID(),
|
|
97
|
+
} as TxContext)
|
|
98
|
+
: undefined;
|
|
99
|
+
|
|
100
|
+
// Top-level proxy: intercept service access (e.g., api.catalog, api.giftCards)
|
|
101
|
+
// The Proxy transforms method signatures at runtime (auto-injecting actor/txCtx),
|
|
102
|
+
// so the cast from Kernel["services"] to CommerceLocalAPI is safe.
|
|
103
|
+
return new Proxy(kernel.services as CommerceLocalAPI, {
|
|
104
|
+
get(target, serviceName: string) {
|
|
105
|
+
const service = (target as Record<string, unknown>)[serviceName];
|
|
106
|
+
|
|
107
|
+
// Non-object services (e.g., email config) — return as-is
|
|
108
|
+
if (service == null || typeof service !== "object") {
|
|
109
|
+
return service;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Method-level proxy: intercept method calls on the service
|
|
113
|
+
return new Proxy(service, {
|
|
114
|
+
get(svcTarget, methodName: string) {
|
|
115
|
+
const method = (svcTarget as Record<string, unknown>)[methodName];
|
|
116
|
+
|
|
117
|
+
if (typeof method !== "function") {
|
|
118
|
+
return method;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Check cache to avoid re-creating wrapper functions
|
|
122
|
+
let methodCache = SERVICE_METHOD_CACHE.get(svcTarget as object);
|
|
123
|
+
if (!methodCache) {
|
|
124
|
+
methodCache = new Map();
|
|
125
|
+
SERVICE_METHOD_CACHE.set(svcTarget as object, methodCache);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const cacheKey = `${methodName}:${actor?.userId ?? "null"}`;
|
|
129
|
+
let cached = methodCache.get(cacheKey);
|
|
130
|
+
if (cached) return cached;
|
|
131
|
+
|
|
132
|
+
// Create wrapped method that auto-injects actor + txCtx
|
|
133
|
+
cached = (...args: unknown[]) => {
|
|
134
|
+
return (method as Function).call(svcTarget, ...args, actor, txCtx);
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
methodCache.set(cacheKey, cached);
|
|
138
|
+
return cached;
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ─── Legacy Class API (backward compat) ─────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* @deprecated Use `createLocalAPI(kernel, { actor, tx })` instead.
|
|
149
|
+
* Kept for backward compatibility with existing hook code.
|
|
150
|
+
*/
|
|
151
|
+
export class LocalAPI {
|
|
152
|
+
private _proxy: CommerceLocalAPI;
|
|
153
|
+
|
|
154
|
+
constructor(
|
|
155
|
+
ctx: { actor: Actor | null; tx: unknown; requestId: string },
|
|
156
|
+
kernel: Kernel,
|
|
157
|
+
) {
|
|
158
|
+
this._proxy = createLocalAPI(kernel, {
|
|
159
|
+
actor: ctx.actor,
|
|
160
|
+
tx: ctx.tx,
|
|
161
|
+
requestId: ctx.requestId,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
get orders() { return this._proxy.orders; }
|
|
166
|
+
get catalog() { return this._proxy.catalog; }
|
|
167
|
+
get cart() { return this._proxy.cart; }
|
|
168
|
+
get inventory() { return this._proxy.inventory; }
|
|
169
|
+
get customers() { return this._proxy.customers; }
|
|
170
|
+
get pricing() { return this._proxy.pricing; }
|
|
171
|
+
get promotions() { return this._proxy.promotions; }
|
|
172
|
+
get media() { return this._proxy.media; }
|
|
173
|
+
get shipping() { return this._proxy.shipping; }
|
|
174
|
+
get search() { return this._proxy.search; }
|
|
175
|
+
get webhooks() { return this._proxy.webhooks; }
|
|
176
|
+
get fulfillment() { return this._proxy.fulfillment; }
|
|
177
|
+
get payments() { return this._proxy.payments; }
|
|
178
|
+
get analytics() { return this._proxy.analytics; }
|
|
179
|
+
get tax() { return this._proxy.tax; }
|
|
180
|
+
get audit() { return this._proxy.audit; }
|
|
181
|
+
get organization() { return this._proxy.organization; }
|
|
182
|
+
|
|
183
|
+
/** Access any service (including plugins) by name */
|
|
184
|
+
service(name: string) {
|
|
185
|
+
return (this._proxy as Record<string, unknown>)[name];
|
|
186
|
+
}
|
|
187
|
+
}
|