@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.
Files changed (186) hide show
  1. package/package.json +2 -1
  2. package/src/adapters/console-email.ts +43 -0
  3. package/src/auth/access.ts +187 -0
  4. package/src/auth/auth-schema.ts +139 -0
  5. package/src/auth/middleware.ts +161 -0
  6. package/src/auth/org.ts +41 -0
  7. package/src/auth/permissions.ts +28 -0
  8. package/src/auth/setup.ts +171 -0
  9. package/src/auth/system-actor.ts +19 -0
  10. package/src/auth/types.ts +10 -0
  11. package/src/config/defaults.ts +82 -0
  12. package/src/config/define-config.ts +53 -0
  13. package/src/config/types.ts +301 -0
  14. package/src/generated/plugin-capabilities.d.ts +20 -0
  15. package/src/generated/plugin-manifest.ts +23 -0
  16. package/src/generated/plugin-repositories.d.ts +20 -0
  17. package/src/hooks/checkout-completion.ts +262 -0
  18. package/src/hooks/checkout.ts +677 -0
  19. package/src/hooks/order-emails.ts +62 -0
  20. package/src/index.ts +215 -0
  21. package/src/interfaces/mcp/agent-prompt.ts +174 -0
  22. package/src/interfaces/mcp/context-enrichment.ts +177 -0
  23. package/src/interfaces/mcp/server.ts +47 -0
  24. package/src/interfaces/mcp/tool-builder.ts +261 -0
  25. package/src/interfaces/mcp/tools/analytics.ts +76 -0
  26. package/src/interfaces/mcp/tools/cart.ts +57 -0
  27. package/src/interfaces/mcp/tools/catalog.ts +299 -0
  28. package/src/interfaces/mcp/tools/index.ts +22 -0
  29. package/src/interfaces/mcp/tools/inventory.ts +161 -0
  30. package/src/interfaces/mcp/tools/orders.ts +104 -0
  31. package/src/interfaces/mcp/tools/pricing.ts +94 -0
  32. package/src/interfaces/mcp/tools/promotions.ts +106 -0
  33. package/src/interfaces/mcp/tools/registry.ts +101 -0
  34. package/src/interfaces/mcp/tools/search.ts +42 -0
  35. package/src/interfaces/mcp/tools/webhooks.ts +48 -0
  36. package/src/interfaces/mcp/transport.ts +128 -0
  37. package/src/interfaces/rest/customer-portal.ts +299 -0
  38. package/src/interfaces/rest/index.ts +74 -0
  39. package/src/interfaces/rest/router.ts +333 -0
  40. package/src/interfaces/rest/routes/admin-jobs.ts +58 -0
  41. package/src/interfaces/rest/routes/audit.ts +50 -0
  42. package/src/interfaces/rest/routes/carts.ts +89 -0
  43. package/src/interfaces/rest/routes/catalog.ts +493 -0
  44. package/src/interfaces/rest/routes/checkout.ts +284 -0
  45. package/src/interfaces/rest/routes/inventory.ts +70 -0
  46. package/src/interfaces/rest/routes/media.ts +86 -0
  47. package/src/interfaces/rest/routes/orders.ts +78 -0
  48. package/src/interfaces/rest/routes/payments.ts +60 -0
  49. package/src/interfaces/rest/routes/pricing.ts +57 -0
  50. package/src/interfaces/rest/routes/promotions.ts +93 -0
  51. package/src/interfaces/rest/routes/search.ts +71 -0
  52. package/src/interfaces/rest/routes/webhooks.ts +46 -0
  53. package/src/interfaces/rest/schemas/admin-jobs.ts +40 -0
  54. package/src/interfaces/rest/schemas/audit.ts +46 -0
  55. package/src/interfaces/rest/schemas/carts.ts +125 -0
  56. package/src/interfaces/rest/schemas/catalog.ts +450 -0
  57. package/src/interfaces/rest/schemas/checkout.ts +66 -0
  58. package/src/interfaces/rest/schemas/customer-portal.ts +195 -0
  59. package/src/interfaces/rest/schemas/inventory.ts +138 -0
  60. package/src/interfaces/rest/schemas/media.ts +75 -0
  61. package/src/interfaces/rest/schemas/orders.ts +104 -0
  62. package/src/interfaces/rest/schemas/pricing.ts +80 -0
  63. package/src/interfaces/rest/schemas/promotions.ts +110 -0
  64. package/src/interfaces/rest/schemas/responses.ts +85 -0
  65. package/src/interfaces/rest/schemas/search.ts +58 -0
  66. package/src/interfaces/rest/schemas/shared.ts +62 -0
  67. package/src/interfaces/rest/schemas/webhooks.ts +68 -0
  68. package/src/interfaces/rest/utils.ts +104 -0
  69. package/src/interfaces/rest/webhook-router.ts +50 -0
  70. package/src/kernel/compensation/executor.ts +61 -0
  71. package/src/kernel/compensation/types.ts +26 -0
  72. package/src/kernel/database/adapter.ts +21 -0
  73. package/src/kernel/database/drizzle-db.ts +56 -0
  74. package/src/kernel/database/migrate.ts +76 -0
  75. package/src/kernel/database/plugin-types.ts +34 -0
  76. package/src/kernel/database/schema.ts +49 -0
  77. package/src/kernel/database/scoped-db.ts +68 -0
  78. package/src/kernel/database/tx-context.ts +46 -0
  79. package/src/kernel/error-mapper.ts +15 -0
  80. package/src/kernel/errors.ts +89 -0
  81. package/src/kernel/factory/repository-factory.ts +244 -0
  82. package/src/kernel/hooks/create-context.ts +43 -0
  83. package/src/kernel/hooks/executor.ts +88 -0
  84. package/src/kernel/hooks/registry.ts +74 -0
  85. package/src/kernel/hooks/types.ts +52 -0
  86. package/src/kernel/http-error.ts +44 -0
  87. package/src/kernel/jobs/adapter.ts +36 -0
  88. package/src/kernel/jobs/drizzle-adapter.ts +58 -0
  89. package/src/kernel/jobs/runner.ts +153 -0
  90. package/src/kernel/jobs/schema.ts +46 -0
  91. package/src/kernel/jobs/types.ts +30 -0
  92. package/src/kernel/local-api.ts +187 -0
  93. package/src/kernel/plugin/manifest.ts +271 -0
  94. package/src/kernel/query/executor.ts +184 -0
  95. package/src/kernel/query/registry.ts +46 -0
  96. package/src/kernel/result.ts +33 -0
  97. package/src/kernel/schema/extra-columns.ts +37 -0
  98. package/src/kernel/service-registry.ts +76 -0
  99. package/src/kernel/service-timing.ts +89 -0
  100. package/src/kernel/state-machine/machine.ts +101 -0
  101. package/src/modules/analytics/drizzle-adapter.ts +426 -0
  102. package/src/modules/analytics/hooks.ts +11 -0
  103. package/src/modules/analytics/models.ts +125 -0
  104. package/src/modules/analytics/repository/index.ts +6 -0
  105. package/src/modules/analytics/service.ts +245 -0
  106. package/src/modules/analytics/types.ts +180 -0
  107. package/src/modules/audit/hooks.ts +78 -0
  108. package/src/modules/audit/schema.ts +33 -0
  109. package/src/modules/audit/service.ts +151 -0
  110. package/src/modules/cart/access.ts +27 -0
  111. package/src/modules/cart/matcher.ts +26 -0
  112. package/src/modules/cart/repository/index.ts +234 -0
  113. package/src/modules/cart/schema.ts +42 -0
  114. package/src/modules/cart/schemas.ts +38 -0
  115. package/src/modules/cart/service.ts +541 -0
  116. package/src/modules/catalog/repository/index.ts +772 -0
  117. package/src/modules/catalog/schema.ts +203 -0
  118. package/src/modules/catalog/schemas.ts +104 -0
  119. package/src/modules/catalog/service.ts +1544 -0
  120. package/src/modules/customers/repository/index.ts +327 -0
  121. package/src/modules/customers/schema.ts +64 -0
  122. package/src/modules/customers/service.ts +171 -0
  123. package/src/modules/fulfillment/repository/index.ts +426 -0
  124. package/src/modules/fulfillment/schema.ts +101 -0
  125. package/src/modules/fulfillment/service.ts +555 -0
  126. package/src/modules/fulfillment/types.ts +59 -0
  127. package/src/modules/inventory/repository/index.ts +509 -0
  128. package/src/modules/inventory/schema.ts +94 -0
  129. package/src/modules/inventory/schemas.ts +38 -0
  130. package/src/modules/inventory/service.ts +490 -0
  131. package/src/modules/media/adapter.ts +17 -0
  132. package/src/modules/media/repository/index.ts +274 -0
  133. package/src/modules/media/schema.ts +41 -0
  134. package/src/modules/media/service.ts +151 -0
  135. package/src/modules/orders/repository/index.ts +287 -0
  136. package/src/modules/orders/schema.ts +66 -0
  137. package/src/modules/orders/service.ts +619 -0
  138. package/src/modules/orders/stale-order-cleanup.ts +76 -0
  139. package/src/modules/organization/service.ts +191 -0
  140. package/src/modules/payments/adapter.ts +47 -0
  141. package/src/modules/payments/repository/index.ts +6 -0
  142. package/src/modules/payments/service.ts +107 -0
  143. package/src/modules/pricing/repository/index.ts +291 -0
  144. package/src/modules/pricing/schema.ts +71 -0
  145. package/src/modules/pricing/schemas.ts +38 -0
  146. package/src/modules/pricing/service.ts +494 -0
  147. package/src/modules/promotions/repository/index.ts +325 -0
  148. package/src/modules/promotions/schema.ts +62 -0
  149. package/src/modules/promotions/schemas.ts +38 -0
  150. package/src/modules/promotions/service.ts +598 -0
  151. package/src/modules/search/adapter.ts +57 -0
  152. package/src/modules/search/hooks.ts +12 -0
  153. package/src/modules/search/repository/index.ts +6 -0
  154. package/src/modules/search/service.ts +315 -0
  155. package/src/modules/shipping/calculator.ts +188 -0
  156. package/src/modules/shipping/repository/index.ts +6 -0
  157. package/src/modules/shipping/service.ts +51 -0
  158. package/src/modules/tax/adapter.ts +60 -0
  159. package/src/modules/tax/repository/index.ts +6 -0
  160. package/src/modules/tax/service.ts +53 -0
  161. package/src/modules/webhooks/hook.ts +34 -0
  162. package/src/modules/webhooks/repository/index.ts +278 -0
  163. package/src/modules/webhooks/schema.ts +56 -0
  164. package/src/modules/webhooks/service.ts +117 -0
  165. package/src/modules/webhooks/signing.ts +6 -0
  166. package/src/modules/webhooks/ssrf-guard.ts +71 -0
  167. package/src/modules/webhooks/tasks.ts +52 -0
  168. package/src/modules/webhooks/worker.ts +134 -0
  169. package/src/runtime/commerce.ts +145 -0
  170. package/src/runtime/kernel.ts +426 -0
  171. package/src/runtime/logger.ts +36 -0
  172. package/src/runtime/server.ts +355 -0
  173. package/src/runtime/shutdown.ts +43 -0
  174. package/src/test-utils/create-pglite-adapter.ts +129 -0
  175. package/src/test-utils/create-plugin-test-app.ts +128 -0
  176. package/src/test-utils/create-repository-test-harness.ts +16 -0
  177. package/src/test-utils/create-test-config.ts +190 -0
  178. package/src/test-utils/create-test-kernel.ts +7 -0
  179. package/src/test-utils/create-test-plugin-context.ts +75 -0
  180. package/src/test-utils/rest-api-test-utils.ts +265 -0
  181. package/src/test-utils/test-actors.ts +62 -0
  182. package/src/test-utils/typed-hooks.ts +54 -0
  183. package/src/types/commerce-types.ts +34 -0
  184. package/src/utils/id.ts +3 -0
  185. package/src/utils/logger.ts +18 -0
  186. 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
+ }