@unifiedcommerce/core 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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,271 @@
|
|
|
1
|
+
import type { Hono } from "hono";
|
|
2
|
+
import type { OpenAPIHono, RouteConfig } from "@hono/zod-openapi";
|
|
3
|
+
import type { PluginDb } from "../database/plugin-types.js";
|
|
4
|
+
import type { DatabaseAdapter } from "../database/adapter.js";
|
|
5
|
+
import type {
|
|
6
|
+
CommerceConfig,
|
|
7
|
+
CommercePlugin,
|
|
8
|
+
MCPTool,
|
|
9
|
+
} from "../../config/types.js";
|
|
10
|
+
|
|
11
|
+
// ─── Plugin Logger ────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
export interface PluginLogger {
|
|
14
|
+
info(message: string, data?: unknown): void;
|
|
15
|
+
warn(message: string, data?: unknown): void;
|
|
16
|
+
error(message: string, data?: unknown): void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ─── Plugin Registration Types ────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Plugin route registration. Supports two modes:
|
|
23
|
+
*
|
|
24
|
+
* 1. Legacy: { method, path, handler } — works, but invisible to OpenAPI spec.
|
|
25
|
+
* 2. OpenAPI: { openapi, handler } — validated by Zod, appears in /api/doc.
|
|
26
|
+
*
|
|
27
|
+
* Use mode 2 for any route that accepts a request body or returns structured data.
|
|
28
|
+
* Mode 1 is acceptable for simple routes (health checks, redirects, file serving).
|
|
29
|
+
*/
|
|
30
|
+
export type PluginRouteRegistration =
|
|
31
|
+
| { method: string; path: string; handler: (...args: unknown[]) => unknown }
|
|
32
|
+
| { openapi: RouteConfig; handler: (...args: unknown[]) => unknown };
|
|
33
|
+
|
|
34
|
+
export interface PluginHookRegistration {
|
|
35
|
+
key: string;
|
|
36
|
+
handler: (...args: unknown[]) => unknown;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ─── Plugin Context (available to routes/mcpTools at boot time) ───────
|
|
40
|
+
|
|
41
|
+
export interface PluginContext {
|
|
42
|
+
config: CommerceConfig;
|
|
43
|
+
services: Record<string, unknown>;
|
|
44
|
+
database: {
|
|
45
|
+
/** Drizzle database instance — use for queries, inserts, updates, deletes */
|
|
46
|
+
db: PluginDb;
|
|
47
|
+
transaction<T>(fn: (tx: PluginDb) => Promise<T>): Promise<T>;
|
|
48
|
+
};
|
|
49
|
+
logger: PluginLogger;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ─── Plugin Manifest (input to defineCommercePlugin) ──────────────────
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Permission scope declared by a plugin.
|
|
56
|
+
* Collected at boot time and available via GET /api/admin/permissions.
|
|
57
|
+
*/
|
|
58
|
+
export interface PluginPermission {
|
|
59
|
+
/** Permission scope string, e.g., "wishlist:write", "marketplace:admin" */
|
|
60
|
+
scope: string;
|
|
61
|
+
/** Human-readable description for admin UIs */
|
|
62
|
+
description: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface CommercePluginManifest {
|
|
66
|
+
id: string;
|
|
67
|
+
version: string;
|
|
68
|
+
/**
|
|
69
|
+
* IDs of plugins that must be registered before this one.
|
|
70
|
+
* If any required plugin is missing at registration time,
|
|
71
|
+
* defineCommercePlugin will throw with a clear message.
|
|
72
|
+
*/
|
|
73
|
+
requires?: string[];
|
|
74
|
+
/**
|
|
75
|
+
* Permission scopes this plugin introduces.
|
|
76
|
+
* Used by admin UIs to build role editors, and validated at boot time
|
|
77
|
+
* against .permission() calls in routes.
|
|
78
|
+
*/
|
|
79
|
+
permissions?: PluginPermission[];
|
|
80
|
+
/**
|
|
81
|
+
* Returns Drizzle `pgTable` objects that this plugin needs.
|
|
82
|
+
* These are collected into `config.customSchemas[]` and merged with core
|
|
83
|
+
* schema by `buildSchema(config)`. Each key becomes a named export in the
|
|
84
|
+
* merged schema; names must not collide with core table exports.
|
|
85
|
+
*/
|
|
86
|
+
schema?: () => Record<string, unknown>;
|
|
87
|
+
hooks?: () => PluginHookRegistration[];
|
|
88
|
+
routes?: (ctx: PluginContext) => PluginRouteRegistration[];
|
|
89
|
+
mcpTools?: (ctx: PluginContext) => MCPTool[];
|
|
90
|
+
analyticsModels?: () => unknown[];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ─── Plugin Dependency Tracking ──────────────────────────────────────
|
|
94
|
+
// Accumulates plugin IDs as they register during defineConfig().
|
|
95
|
+
// Reset at the start of each defineConfig() call.
|
|
96
|
+
export const _registeredPlugins = new Set<string>();
|
|
97
|
+
|
|
98
|
+
/** @internal — called by defineConfig() before applying plugins */
|
|
99
|
+
export function _resetRegisteredPlugins(): void {
|
|
100
|
+
_registeredPlugins.clear();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Converts a plugin manifest into a config transform function.
|
|
105
|
+
*
|
|
106
|
+
* - `schema` → pushed into `config.customSchemas`
|
|
107
|
+
* - `hooks` → merged into `config.hooks` flat map
|
|
108
|
+
* - `routes` → chained onto `config.routes` (evaluated at boot with kernel)
|
|
109
|
+
* - `mcpTools` → chained onto `config.mcpTools` (evaluated at boot with kernel)
|
|
110
|
+
* - `analyticsModels` → pushed into `config.analytics.models`
|
|
111
|
+
*/
|
|
112
|
+
export function defineCommercePlugin(
|
|
113
|
+
manifest: CommercePluginManifest,
|
|
114
|
+
): CommercePlugin {
|
|
115
|
+
return (config: CommerceConfig): CommerceConfig => {
|
|
116
|
+
// ── Dependency check ───────────────────────────────────────────
|
|
117
|
+
// In test/development, warn instead of throwing so plugins can be
|
|
118
|
+
// tested in isolation via createPluginTestApp() without installing
|
|
119
|
+
// all dependencies. In production, this is a hard error.
|
|
120
|
+
if (manifest.requires) {
|
|
121
|
+
for (const dep of manifest.requires) {
|
|
122
|
+
if (!_registeredPlugins.has(dep)) {
|
|
123
|
+
const msg = `Plugin "${manifest.id}" requires "${dep}" to be installed before it. Add ${dep}Plugin() before ${manifest.id}Plugin() in your config.plugins array.`;
|
|
124
|
+
if (process.env.NODE_ENV === "production") {
|
|
125
|
+
throw new Error(msg);
|
|
126
|
+
}
|
|
127
|
+
// Non-production: log warning but continue (allows isolated testing)
|
|
128
|
+
console.warn(`[plugin:${manifest.id}] WARNING: ${msg}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
_registeredPlugins.add(manifest.id);
|
|
133
|
+
let result = { ...config };
|
|
134
|
+
|
|
135
|
+
// 1. Schema — push into customSchemas for kernel to register
|
|
136
|
+
if (manifest.schema) {
|
|
137
|
+
const schemas = manifest.schema();
|
|
138
|
+
result = {
|
|
139
|
+
...result,
|
|
140
|
+
customSchemas: [...(result.customSchemas ?? []), schemas],
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 2. Hooks — merge into flat hooks map (kernel registers at boot)
|
|
145
|
+
if (manifest.hooks) {
|
|
146
|
+
const registrations = manifest.hooks();
|
|
147
|
+
const hookMap: Record<string, Array<(...args: unknown[]) => unknown>> = {
|
|
148
|
+
...(result.hooks ?? {}),
|
|
149
|
+
};
|
|
150
|
+
for (const reg of registrations) {
|
|
151
|
+
hookMap[reg.key] = [...(hookMap[reg.key] ?? []), reg.handler];
|
|
152
|
+
}
|
|
153
|
+
result = { ...result, hooks: hookMap };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// 3. Routes — chain onto config.routes (deferred: needs kernel at boot)
|
|
157
|
+
if (manifest.routes) {
|
|
158
|
+
const existingRoutes = result.routes;
|
|
159
|
+
const pluginRoutes = manifest.routes;
|
|
160
|
+
result = {
|
|
161
|
+
...result,
|
|
162
|
+
routes: (app: Hono, kernel: unknown) => {
|
|
163
|
+
existingRoutes?.(app, kernel);
|
|
164
|
+
const k = kernel as {
|
|
165
|
+
config: CommerceConfig;
|
|
166
|
+
services: Record<string, unknown>;
|
|
167
|
+
database: DatabaseAdapter;
|
|
168
|
+
logger: PluginLogger;
|
|
169
|
+
};
|
|
170
|
+
// Narrow DatabaseAdapter<unknown> → PluginContext.database once here.
|
|
171
|
+
// All plugin code receives typed PluginDb — no casts downstream.
|
|
172
|
+
const regs = pluginRoutes({
|
|
173
|
+
config: k.config,
|
|
174
|
+
services: k.services,
|
|
175
|
+
database: {
|
|
176
|
+
db: k.database.db as PluginDb,
|
|
177
|
+
transaction: k.database.transaction as PluginContext["database"]["transaction"],
|
|
178
|
+
},
|
|
179
|
+
logger: k.logger,
|
|
180
|
+
});
|
|
181
|
+
for (const route of regs) {
|
|
182
|
+
// ── Error boundary: wrap handler with plugin context ──
|
|
183
|
+
const originalHandler = route.handler;
|
|
184
|
+
const wrappedHandler = async (...args: unknown[]) => {
|
|
185
|
+
try {
|
|
186
|
+
return await originalHandler(...args);
|
|
187
|
+
} catch (err) {
|
|
188
|
+
// Try to extract logger from Hono context
|
|
189
|
+
const c = args[0] as Record<string, unknown>;
|
|
190
|
+
const logger = (c?.get as Function)?.("logger") as { error: Function } | undefined;
|
|
191
|
+
logger?.error?.(
|
|
192
|
+
{ err, plugin: manifest.id },
|
|
193
|
+
`[plugin:${manifest.id}] route handler error`,
|
|
194
|
+
);
|
|
195
|
+
throw err; // re-throw so global handler still catches
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
if ("openapi" in route) {
|
|
200
|
+
// OpenAPI route: validated by Zod, appears in /api/doc
|
|
201
|
+
// Hono type interop — OpenAPIHono.openapi() expects strict RouteConfig+Handler
|
|
202
|
+
// generics that can't be statically resolved for dynamic plugin routes.
|
|
203
|
+
// @ts-expect-error -- dynamic plugin routes cannot satisfy Hono's strict handler generics
|
|
204
|
+
(app as OpenAPIHono).openapi(route.openapi, wrappedHandler);
|
|
205
|
+
} else {
|
|
206
|
+
// Legacy route: raw handler, invisible to OpenAPI spec.
|
|
207
|
+
// Explicit dispatch avoids casting Hono to a Record.
|
|
208
|
+
// Handler cast is a single `as any` (Hono's overloaded method
|
|
209
|
+
// signatures can't unify with our generic handler shape).
|
|
210
|
+
const h = wrappedHandler as any;
|
|
211
|
+
switch (route.method.toLowerCase()) {
|
|
212
|
+
case "get": app.get(route.path, h); break;
|
|
213
|
+
case "post": app.post(route.path, h); break;
|
|
214
|
+
case "put": app.put(route.path, h); break;
|
|
215
|
+
case "patch": app.patch(route.path, h); break;
|
|
216
|
+
case "delete": app.delete(route.path, h); break;
|
|
217
|
+
case "options": app.options(route.path, h); break;
|
|
218
|
+
default:
|
|
219
|
+
console.warn(`[plugin:${manifest.id}] unsupported HTTP method "${route.method}" for ${route.path}`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// 4. MCP Tools — chain onto config.mcpTools (deferred: needs kernel)
|
|
228
|
+
if (manifest.mcpTools) {
|
|
229
|
+
const existingMcpTools = result.mcpTools;
|
|
230
|
+
const pluginMcpTools = manifest.mcpTools;
|
|
231
|
+
result = {
|
|
232
|
+
...result,
|
|
233
|
+
mcpTools: (kernel: unknown) => {
|
|
234
|
+
const existing = existingMcpTools?.(kernel) ?? [];
|
|
235
|
+
const k = kernel as {
|
|
236
|
+
config: CommerceConfig;
|
|
237
|
+
services: Record<string, unknown>;
|
|
238
|
+
database: DatabaseAdapter;
|
|
239
|
+
logger: PluginLogger;
|
|
240
|
+
};
|
|
241
|
+
return [
|
|
242
|
+
...existing,
|
|
243
|
+
...pluginMcpTools({
|
|
244
|
+
config: k.config,
|
|
245
|
+
services: k.services,
|
|
246
|
+
database: {
|
|
247
|
+
db: k.database.db as PluginDb,
|
|
248
|
+
transaction: k.database.transaction as PluginContext["database"]["transaction"],
|
|
249
|
+
},
|
|
250
|
+
logger: k.logger,
|
|
251
|
+
}),
|
|
252
|
+
];
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// 5. Analytics models — push into config.analytics.models
|
|
258
|
+
if (manifest.analyticsModels) {
|
|
259
|
+
const models = manifest.analyticsModels();
|
|
260
|
+
result = {
|
|
261
|
+
...result,
|
|
262
|
+
analytics: {
|
|
263
|
+
...result.analytics,
|
|
264
|
+
models: [...(result.analytics?.models ?? []), ...models],
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return result;
|
|
270
|
+
};
|
|
271
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { CommerceNotFoundError } from "../errors.js";
|
|
2
|
+
import type {
|
|
3
|
+
QueryRegistry,
|
|
4
|
+
EntityDefinition,
|
|
5
|
+
RelationDefinition,
|
|
6
|
+
} from "./registry.js";
|
|
7
|
+
|
|
8
|
+
export interface QueryInput {
|
|
9
|
+
entity: string;
|
|
10
|
+
id?: string;
|
|
11
|
+
filters?: Record<string, unknown>;
|
|
12
|
+
include?: string[];
|
|
13
|
+
pagination?: { limit?: number; offset?: number };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface QueryResult<T = Record<string, unknown>> {
|
|
17
|
+
data: T[];
|
|
18
|
+
total?: number | undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Executes a query against the registry, resolving includes via
|
|
23
|
+
* batched dataloader-style fetches (one WHERE IN per relation).
|
|
24
|
+
*/
|
|
25
|
+
export async function executeQuery<T = Record<string, unknown>>(
|
|
26
|
+
registry: QueryRegistry,
|
|
27
|
+
services: Record<string, unknown>,
|
|
28
|
+
input: QueryInput,
|
|
29
|
+
): Promise<QueryResult<T>> {
|
|
30
|
+
const definition = registry.get(input.entity);
|
|
31
|
+
if (!definition) {
|
|
32
|
+
throw new CommerceNotFoundError(
|
|
33
|
+
`No entity registered with name "${input.entity}".`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const service = services[definition.service] as Record<
|
|
38
|
+
string,
|
|
39
|
+
(...args: unknown[]) => Promise<unknown>
|
|
40
|
+
>;
|
|
41
|
+
|
|
42
|
+
// 1. Fetch primary records
|
|
43
|
+
let rows: Record<string, unknown>[];
|
|
44
|
+
let total: number | undefined;
|
|
45
|
+
|
|
46
|
+
if (input.id) {
|
|
47
|
+
const result = (await service[definition.getByIdMethod]!(
|
|
48
|
+
input.id,
|
|
49
|
+
)) as { ok?: boolean; value?: unknown };
|
|
50
|
+
const value = result?.value ?? result;
|
|
51
|
+
rows = value != null ? [value as Record<string, unknown>] : [];
|
|
52
|
+
} else {
|
|
53
|
+
const result = (await service[definition.listMethod]!(
|
|
54
|
+
input.filters ?? {},
|
|
55
|
+
input.pagination,
|
|
56
|
+
)) as {
|
|
57
|
+
ok?: boolean;
|
|
58
|
+
value?: { items?: unknown[]; total?: number };
|
|
59
|
+
};
|
|
60
|
+
const resolved = result?.value ?? result;
|
|
61
|
+
if (resolved && typeof resolved === "object" && "items" in resolved) {
|
|
62
|
+
rows = (resolved.items ?? []) as Record<string, unknown>[];
|
|
63
|
+
total = resolved.total;
|
|
64
|
+
} else if (Array.isArray(resolved)) {
|
|
65
|
+
rows = resolved as Record<string, unknown>[];
|
|
66
|
+
} else {
|
|
67
|
+
rows = [];
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 2. Resolve includes
|
|
72
|
+
if (input.include?.length) {
|
|
73
|
+
await resolveIncludes(rows, input.include, definition, services, registry);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return { data: rows as T[], total };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function resolveIncludes(
|
|
80
|
+
rows: Record<string, unknown>[],
|
|
81
|
+
includes: string[],
|
|
82
|
+
definition: EntityDefinition,
|
|
83
|
+
services: Record<string, unknown>,
|
|
84
|
+
registry: QueryRegistry,
|
|
85
|
+
): Promise<void> {
|
|
86
|
+
// Group includes by top-level segment
|
|
87
|
+
const topLevel = new Map<string, string[]>();
|
|
88
|
+
for (const path of includes) {
|
|
89
|
+
const dot = path.indexOf(".");
|
|
90
|
+
if (dot === -1) {
|
|
91
|
+
if (!topLevel.has(path)) topLevel.set(path, []);
|
|
92
|
+
} else {
|
|
93
|
+
const parent = path.substring(0, dot);
|
|
94
|
+
const child = path.substring(dot + 1);
|
|
95
|
+
const existing = topLevel.get(parent) ?? [];
|
|
96
|
+
existing.push(child);
|
|
97
|
+
topLevel.set(parent, existing);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
for (const [relationName, nestedIncludes] of topLevel) {
|
|
102
|
+
const relation = definition.relations[relationName];
|
|
103
|
+
if (!relation) continue;
|
|
104
|
+
|
|
105
|
+
const targetService = services[relation.targetService] as
|
|
106
|
+
| Record<string, (...args: unknown[]) => Promise<unknown>>
|
|
107
|
+
| undefined;
|
|
108
|
+
if (!targetService) continue;
|
|
109
|
+
|
|
110
|
+
// Collect foreign key values (deduplicated)
|
|
111
|
+
const ids = [
|
|
112
|
+
...new Set(
|
|
113
|
+
rows
|
|
114
|
+
.map((r) => r[relation.foreignKey])
|
|
115
|
+
.filter((v): v is string => v != null && typeof v === "string"),
|
|
116
|
+
),
|
|
117
|
+
];
|
|
118
|
+
if (ids.length === 0) continue;
|
|
119
|
+
|
|
120
|
+
// One batched query
|
|
121
|
+
const batchFn = targetService[relation.batchMethod];
|
|
122
|
+
if (!batchFn) continue;
|
|
123
|
+
|
|
124
|
+
const relatedResult = await batchFn(ids);
|
|
125
|
+
const relatedRows = extractRows(relatedResult);
|
|
126
|
+
|
|
127
|
+
// Build lookup
|
|
128
|
+
const map = new Map<string, unknown>();
|
|
129
|
+
for (const related of relatedRows) {
|
|
130
|
+
const rec = related as Record<string, unknown>;
|
|
131
|
+
if (relation.isList) {
|
|
132
|
+
const key = rec[relation.foreignKey] as string;
|
|
133
|
+
if (!map.has(key)) map.set(key, []);
|
|
134
|
+
(map.get(key) as unknown[]).push(rec);
|
|
135
|
+
} else {
|
|
136
|
+
map.set(rec["id"] as string, rec);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Attach to parent rows
|
|
141
|
+
for (const row of rows) {
|
|
142
|
+
const fkValue = row[relation.foreignKey] as string | undefined;
|
|
143
|
+
if (!fkValue) continue;
|
|
144
|
+
row[relation.attachAs] =
|
|
145
|
+
map.get(fkValue) ?? (relation.isList ? [] : null);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Resolve nested includes
|
|
149
|
+
if (nestedIncludes.length > 0) {
|
|
150
|
+
const targetDef = registry.get(relation.targetService);
|
|
151
|
+
if (targetDef) {
|
|
152
|
+
const nestedRows = relation.isList
|
|
153
|
+
? rows.flatMap(
|
|
154
|
+
(r) =>
|
|
155
|
+
(r[relation.attachAs] as Record<string, unknown>[]) ?? [],
|
|
156
|
+
)
|
|
157
|
+
: rows
|
|
158
|
+
.map((r) => r[relation.attachAs] as Record<string, unknown>)
|
|
159
|
+
.filter(Boolean);
|
|
160
|
+
await resolveIncludes(
|
|
161
|
+
nestedRows,
|
|
162
|
+
nestedIncludes,
|
|
163
|
+
targetDef,
|
|
164
|
+
services,
|
|
165
|
+
registry,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function extractRows(result: unknown): unknown[] {
|
|
173
|
+
if (Array.isArray(result)) return result;
|
|
174
|
+
if (result && typeof result === "object") {
|
|
175
|
+
const r = result as { ok?: boolean; value?: unknown };
|
|
176
|
+
if (r.value != null) {
|
|
177
|
+
if (Array.isArray(r.value)) return r.value;
|
|
178
|
+
if (typeof r.value === "object" && "items" in r.value) {
|
|
179
|
+
return (r.value as { items: unknown[] }).items;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return [];
|
|
184
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Defines a relation from a parent entity to a related entity.
|
|
3
|
+
* Used by the query executor to batch-load related records.
|
|
4
|
+
*/
|
|
5
|
+
export interface RelationDefinition {
|
|
6
|
+
foreignKey: string;
|
|
7
|
+
targetService: string;
|
|
8
|
+
batchMethod: string;
|
|
9
|
+
attachAs: string;
|
|
10
|
+
isList?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Defines an entity that can be queried via kernel.query().
|
|
15
|
+
*/
|
|
16
|
+
export interface EntityDefinition {
|
|
17
|
+
service: string;
|
|
18
|
+
getByIdMethod: string;
|
|
19
|
+
listMethod: string;
|
|
20
|
+
relations: Record<string, RelationDefinition>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Registry of queryable entities and their relations.
|
|
25
|
+
* Modules register their entities at kernel boot.
|
|
26
|
+
* Plugins can register additional entities.
|
|
27
|
+
*/
|
|
28
|
+
export class QueryRegistry {
|
|
29
|
+
private entities = new Map<string, EntityDefinition>();
|
|
30
|
+
|
|
31
|
+
register(name: string, definition: EntityDefinition): void {
|
|
32
|
+
this.entities.set(name, definition);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
get(name: string): EntityDefinition | undefined {
|
|
36
|
+
return this.entities.get(name);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
has(name: string): boolean {
|
|
40
|
+
return this.entities.has(name);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
listEntities(): string[] {
|
|
44
|
+
return [...this.entities.keys()];
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { CommerceError } from "./errors.js";
|
|
2
|
+
|
|
3
|
+
export type Result<T, E = CommerceError> =
|
|
4
|
+
| { ok: true; value: T; meta?: Record<string, unknown> }
|
|
5
|
+
| { ok: false; error: E };
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Simplified Result types for plugin services.
|
|
9
|
+
* Plugin services return `PluginResult<T>` instead of `Result<T, CommerceError>`.
|
|
10
|
+
* This matches the `{ ok: true; value: T } | { ok: false; error: string }` pattern
|
|
11
|
+
* that every plugin has been copy-pasting.
|
|
12
|
+
*/
|
|
13
|
+
export type PluginResult<T> = { ok: true; value: T } | { ok: false; error: string; code?: string };
|
|
14
|
+
export type PluginResultErr = { ok: false; error: string; code?: string };
|
|
15
|
+
|
|
16
|
+
export function Ok<T>(value: T, meta?: Record<string, unknown>): Result<T, never> {
|
|
17
|
+
if (meta !== undefined) {
|
|
18
|
+
return { ok: true, value, meta };
|
|
19
|
+
}
|
|
20
|
+
return { ok: true, value };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function Err<E>(error: E): Result<never, E> {
|
|
24
|
+
return { ok: false, error };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* String-based Err for plugin services.
|
|
29
|
+
* Usage: `return PluginErr("Not found", "NOT_FOUND")`
|
|
30
|
+
*/
|
|
31
|
+
export function PluginErr(error: string, code?: string): PluginResultErr {
|
|
32
|
+
return code ? { ok: false, error, code } : { ok: false, error };
|
|
33
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { PgColumnBuilderBase } from "drizzle-orm/pg-core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Options for modules that support schema extension via extra columns.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* ```typescript
|
|
8
|
+
* const catalogModule = createCatalogModule({
|
|
9
|
+
* extraColumns: (base) => ({
|
|
10
|
+
* supplierCode: text("supplier_code"),
|
|
11
|
+
* gtin: text("gtin").unique(),
|
|
12
|
+
* }),
|
|
13
|
+
* });
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
export interface ExtraColumnsOption<TBaseColumns extends Record<string, unknown>> {
|
|
17
|
+
extraColumns?: (
|
|
18
|
+
baseColumns: TBaseColumns,
|
|
19
|
+
) => Record<string, PgColumnBuilderBase>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Merges base columns with optional extra columns from configuration.
|
|
24
|
+
* Returns the combined column definitions for use in `pgTable()`.
|
|
25
|
+
*/
|
|
26
|
+
export function mergeExtraColumns<
|
|
27
|
+
TBase extends Record<string, unknown>,
|
|
28
|
+
>(
|
|
29
|
+
baseColumns: TBase,
|
|
30
|
+
extraColumnsFn?: (base: TBase) => Record<string, PgColumnBuilderBase>,
|
|
31
|
+
): TBase & Record<string, PgColumnBuilderBase> {
|
|
32
|
+
if (!extraColumnsFn) return baseColumns as TBase & Record<string, PgColumnBuilderBase>;
|
|
33
|
+
const extra = extraColumnsFn(baseColumns);
|
|
34
|
+
return { ...baseColumns, ...extra };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ServiceRegistry -- typed surface of the kernel service container
|
|
3
|
+
* for plugin-to-plugin communication.
|
|
4
|
+
*
|
|
5
|
+
* Plugin services accept this as an optional constructor parameter
|
|
6
|
+
* to call core services without resorting to raw SQL.
|
|
7
|
+
*
|
|
8
|
+
* Core services are loosely typed here (method signatures use `unknown`)
|
|
9
|
+
* to avoid plugin packages depending on core's internal service types.
|
|
10
|
+
* Plugin authors cast the return values at the call site.
|
|
11
|
+
*
|
|
12
|
+
* Usage in a plugin service:
|
|
13
|
+
*
|
|
14
|
+
* import type { ServiceRegistry } from "@unifiedcommerce/core";
|
|
15
|
+
*
|
|
16
|
+
* class MyService {
|
|
17
|
+
* constructor(private db: PluginDb, private services?: ServiceRegistry) {}
|
|
18
|
+
*
|
|
19
|
+
* async doWork() {
|
|
20
|
+
* const result = await this.services?.inventory.adjust({
|
|
21
|
+
* entityId: "...", adjustment: -5, reason: "recipe deduction"
|
|
22
|
+
* }, actor);
|
|
23
|
+
* }
|
|
24
|
+
* }
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export interface ServiceRegistry {
|
|
28
|
+
inventory: {
|
|
29
|
+
adjust(
|
|
30
|
+
input: {
|
|
31
|
+
entityId: string;
|
|
32
|
+
variantId?: string;
|
|
33
|
+
warehouseId?: string;
|
|
34
|
+
adjustment: number;
|
|
35
|
+
reason?: string;
|
|
36
|
+
},
|
|
37
|
+
actor?: unknown,
|
|
38
|
+
): Promise<{ ok: boolean; value?: unknown; error?: unknown }>;
|
|
39
|
+
createWarehouse(
|
|
40
|
+
input: { name: string; code: string },
|
|
41
|
+
actor?: unknown,
|
|
42
|
+
): Promise<{ ok: boolean; value?: unknown; error?: unknown }>;
|
|
43
|
+
reserve(input: unknown, actor?: unknown): Promise<unknown>;
|
|
44
|
+
release(input: unknown, actor?: unknown): Promise<unknown>;
|
|
45
|
+
getAvailable(input: unknown, actor?: unknown): Promise<unknown>;
|
|
46
|
+
[method: string]: unknown;
|
|
47
|
+
};
|
|
48
|
+
catalog: {
|
|
49
|
+
create(input: unknown, actor?: unknown): Promise<{ ok: boolean; value?: unknown; error?: unknown }>;
|
|
50
|
+
getById(id: string, options?: unknown): Promise<{ ok: boolean; value?: unknown; error?: unknown }>;
|
|
51
|
+
publish(id: string, actor?: unknown): Promise<unknown>;
|
|
52
|
+
list(input?: unknown): Promise<unknown>;
|
|
53
|
+
[method: string]: unknown;
|
|
54
|
+
};
|
|
55
|
+
customers: {
|
|
56
|
+
getByUserId(userId: string, actor?: unknown): Promise<{ ok: boolean; value?: unknown; error?: unknown }>;
|
|
57
|
+
getById(id: string, actor?: unknown): Promise<{ ok: boolean; value?: unknown; error?: unknown }>;
|
|
58
|
+
[method: string]: unknown;
|
|
59
|
+
};
|
|
60
|
+
orders: {
|
|
61
|
+
create(input: unknown, actor?: unknown): Promise<{ ok: boolean; value?: unknown; error?: unknown }>;
|
|
62
|
+
changeStatus(input: unknown, actor?: unknown): Promise<unknown>;
|
|
63
|
+
[method: string]: unknown;
|
|
64
|
+
};
|
|
65
|
+
cart: {
|
|
66
|
+
create(input: unknown, actor?: unknown): Promise<{ ok: boolean; value?: unknown; error?: unknown }>;
|
|
67
|
+
[method: string]: unknown;
|
|
68
|
+
};
|
|
69
|
+
organization: {
|
|
70
|
+
create(input: unknown): Promise<{ ok: boolean; value?: unknown; error?: unknown }>;
|
|
71
|
+
getById(id: string): Promise<unknown>;
|
|
72
|
+
[method: string]: unknown;
|
|
73
|
+
};
|
|
74
|
+
/** Access plugin-registered services by plugin ID or service name */
|
|
75
|
+
[key: string]: unknown;
|
|
76
|
+
}
|