@unifiedcommerce/core 0.1.1 → 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/dist/auth/setup.d.ts.map +1 -1
- package/dist/auth/setup.js +8 -3
- package/dist/config/types.d.ts +3 -1
- package/dist/config/types.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/interfaces/mcp/server.d.ts +3 -5
- package/dist/interfaces/mcp/server.d.ts.map +1 -1
- package/dist/interfaces/mcp/server.js +25 -510
- package/dist/interfaces/mcp/tool-builder.d.ts +120 -0
- package/dist/interfaces/mcp/tool-builder.d.ts.map +1 -0
- package/dist/interfaces/mcp/tool-builder.js +224 -0
- package/dist/interfaces/mcp/tools/analytics.d.ts +42 -0
- package/dist/interfaces/mcp/tools/analytics.d.ts.map +1 -0
- package/dist/interfaces/mcp/tools/analytics.js +70 -0
- package/dist/interfaces/mcp/tools/cart.d.ts +14 -0
- package/dist/interfaces/mcp/tools/cart.d.ts.map +1 -0
- package/dist/interfaces/mcp/tools/cart.js +47 -0
- package/dist/interfaces/mcp/tools/catalog.d.ts +53 -0
- package/dist/interfaces/mcp/tools/catalog.d.ts.map +1 -0
- package/dist/interfaces/mcp/tools/catalog.js +284 -0
- package/dist/interfaces/mcp/tools/index.d.ts +3 -0
- package/dist/interfaces/mcp/tools/index.d.ts.map +1 -0
- package/dist/interfaces/mcp/tools/index.js +20 -0
- package/dist/interfaces/mcp/tools/inventory.d.ts +27 -0
- package/dist/interfaces/mcp/tools/inventory.d.ts.map +1 -0
- package/dist/interfaces/mcp/tools/inventory.js +143 -0
- package/dist/interfaces/mcp/tools/orders.d.ts +18 -0
- package/dist/interfaces/mcp/tools/orders.d.ts.map +1 -0
- package/dist/interfaces/mcp/tools/orders.js +82 -0
- package/dist/interfaces/mcp/tools/pricing.d.ts +29 -0
- package/dist/interfaces/mcp/tools/pricing.d.ts.map +1 -0
- package/dist/interfaces/mcp/tools/pricing.js +90 -0
- package/dist/interfaces/mcp/tools/promotions.d.ts +44 -0
- package/dist/interfaces/mcp/tools/promotions.d.ts.map +1 -0
- package/dist/interfaces/mcp/tools/promotions.js +109 -0
- package/dist/interfaces/mcp/tools/registry.d.ts +32 -0
- package/dist/interfaces/mcp/tools/registry.d.ts.map +1 -0
- package/dist/interfaces/mcp/tools/registry.js +55 -0
- package/dist/interfaces/mcp/tools/search.d.ts +14 -0
- package/dist/interfaces/mcp/tools/search.d.ts.map +1 -0
- package/dist/interfaces/mcp/tools/search.js +39 -0
- package/dist/interfaces/mcp/tools/webhooks.d.ts +15 -0
- package/dist/interfaces/mcp/tools/webhooks.d.ts.map +1 -0
- package/dist/interfaces/mcp/tools/webhooks.js +48 -0
- package/dist/interfaces/mcp/transport.d.ts +17 -2
- package/dist/interfaces/mcp/transport.d.ts.map +1 -1
- package/dist/interfaces/mcp/transport.js +91 -44
- package/dist/interfaces/rest/router.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/checkout.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/checkout.js +1 -1
- package/dist/interfaces/rest/routes/promotions.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/promotions.js +3 -2
- package/dist/kernel/database/adapter.d.ts +8 -0
- package/dist/kernel/database/adapter.d.ts.map +1 -1
- package/dist/kernel/factory/repository-factory.d.ts.map +1 -1
- package/dist/kernel/factory/repository-factory.js +3 -1
- package/dist/kernel/local-api.d.ts.map +1 -1
- package/dist/kernel/local-api.js +2 -0
- package/dist/kernel/plugin/manifest.d.ts +3 -3
- package/dist/kernel/plugin/manifest.d.ts.map +1 -1
- package/dist/kernel/plugin/manifest.js +36 -7
- package/dist/runtime/kernel.d.ts +1 -2
- package/dist/runtime/kernel.d.ts.map +1 -1
- package/dist/runtime/kernel.js +16 -8
- package/dist/runtime/server.d.ts.map +1 -1
- package/dist/runtime/server.js +8 -3
- package/dist/test-utils/create-pglite-adapter.d.ts.map +1 -1
- package/dist/test-utils/create-pglite-adapter.js +7 -6
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +3 -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,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Service method observability via ES Proxy.
|
|
3
|
+
*
|
|
4
|
+
* Wraps a service object so that every async method call is timed.
|
|
5
|
+
* Slow calls (above threshold) are logged with service name, method name,
|
|
6
|
+
* and duration. Failed calls always log with the error.
|
|
7
|
+
*
|
|
8
|
+
* Usage at kernel boot:
|
|
9
|
+
* services.inventory = withTiming(inventoryService, "inventory", logger);
|
|
10
|
+
* services.catalog = withTiming(catalogService, "catalog", logger);
|
|
11
|
+
*
|
|
12
|
+
* Produces log entries like:
|
|
13
|
+
* { service: "inventory", method: "adjust", durationMs: 245 } "slow service call"
|
|
14
|
+
* { service: "catalog", method: "create", durationMs: 12, err: ... } "service call failed"
|
|
15
|
+
*
|
|
16
|
+
* Only wraps own methods (not inherited). Synchronous property access
|
|
17
|
+
* (e.g., reading a field) passes through unchanged with zero overhead.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
interface TimingLogger {
|
|
21
|
+
info(obj: Record<string, unknown>, msg: string): void;
|
|
22
|
+
error(obj: Record<string, unknown>, msg: string): void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function withTiming<T extends object>(
|
|
26
|
+
service: T,
|
|
27
|
+
serviceName: string,
|
|
28
|
+
logger: TimingLogger,
|
|
29
|
+
slowThresholdMs = 100,
|
|
30
|
+
): T {
|
|
31
|
+
return new Proxy(service, {
|
|
32
|
+
get(target, prop, receiver) {
|
|
33
|
+
const value = Reflect.get(target, prop, receiver);
|
|
34
|
+
|
|
35
|
+
// Only wrap functions, skip symbols and non-function props
|
|
36
|
+
if (typeof value !== "function" || typeof prop === "symbol") {
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Skip internal/private-looking methods
|
|
41
|
+
const methodName = String(prop);
|
|
42
|
+
if (methodName.startsWith("_")) return value;
|
|
43
|
+
|
|
44
|
+
return function proxiedMethod(this: unknown, ...args: unknown[]) {
|
|
45
|
+
const start = performance.now();
|
|
46
|
+
|
|
47
|
+
// Call the original method
|
|
48
|
+
let result: unknown;
|
|
49
|
+
try {
|
|
50
|
+
result = value.apply(target, args);
|
|
51
|
+
} catch (err) {
|
|
52
|
+
// Synchronous throw
|
|
53
|
+
const durationMs = Math.round(performance.now() - start);
|
|
54
|
+
logger.error(
|
|
55
|
+
{ service: serviceName, method: methodName, durationMs, err },
|
|
56
|
+
`${serviceName}.${methodName} failed (${durationMs}ms)`,
|
|
57
|
+
);
|
|
58
|
+
throw err;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// If result is a promise, attach timing to its resolution
|
|
62
|
+
if (result && typeof (result as Promise<unknown>).then === "function") {
|
|
63
|
+
return (result as Promise<unknown>).then(
|
|
64
|
+
(resolved) => {
|
|
65
|
+
const durationMs = Math.round(performance.now() - start);
|
|
66
|
+
if (durationMs > slowThresholdMs) {
|
|
67
|
+
logger.info(
|
|
68
|
+
{ service: serviceName, method: methodName, durationMs },
|
|
69
|
+
`${serviceName}.${methodName} slow (${durationMs}ms)`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
return resolved;
|
|
73
|
+
},
|
|
74
|
+
(err) => {
|
|
75
|
+
const durationMs = Math.round(performance.now() - start);
|
|
76
|
+
logger.error(
|
|
77
|
+
{ service: serviceName, method: methodName, durationMs, err },
|
|
78
|
+
`${serviceName}.${methodName} failed (${durationMs}ms)`,
|
|
79
|
+
);
|
|
80
|
+
throw err;
|
|
81
|
+
},
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return result;
|
|
86
|
+
};
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { CommerceInvalidTransitionError } from "../errors.js";
|
|
2
|
+
|
|
3
|
+
export interface StateDefinition<TState extends string> {
|
|
4
|
+
states: readonly TState[];
|
|
5
|
+
initial: TState;
|
|
6
|
+
transitions: Record<TState, readonly TState[]>;
|
|
7
|
+
terminal: readonly TState[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type OrderState = string;
|
|
11
|
+
|
|
12
|
+
const DEFAULT_TRANSITIONS: Record<string, readonly string[]> = {
|
|
13
|
+
pending: ["confirmed", "cancelled"],
|
|
14
|
+
confirmed: ["processing", "cancelled"],
|
|
15
|
+
processing: ["partially_fulfilled", "fulfilled", "cancelled"],
|
|
16
|
+
partially_fulfilled: ["fulfilled", "cancelled"],
|
|
17
|
+
fulfilled: ["refunded"],
|
|
18
|
+
cancelled: [],
|
|
19
|
+
refunded: [],
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const DEFAULT_STATES: readonly string[] = [
|
|
23
|
+
"pending", "confirmed", "processing", "partially_fulfilled",
|
|
24
|
+
"fulfilled", "cancelled", "refunded",
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const DEFAULT_TERMINAL: readonly string[] = ["cancelled", "refunded"];
|
|
28
|
+
|
|
29
|
+
export const orderStateMachine: StateDefinition<string> = {
|
|
30
|
+
states: DEFAULT_STATES,
|
|
31
|
+
initial: "pending",
|
|
32
|
+
transitions: DEFAULT_TRANSITIONS,
|
|
33
|
+
terminal: DEFAULT_TERMINAL,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Extend the order state machine with custom transitions.
|
|
38
|
+
* New states are added automatically. Existing state transition arrays
|
|
39
|
+
* are merged (union, not replaced) with the custom ones.
|
|
40
|
+
*
|
|
41
|
+
* Usage:
|
|
42
|
+
* const extended = extendOrderStateMachine({
|
|
43
|
+
* pending: ["payment_initiated"],
|
|
44
|
+
* payment_initiated: ["payment_authorized", "payment_failed"],
|
|
45
|
+
* payment_authorized: ["processing"],
|
|
46
|
+
* });
|
|
47
|
+
*/
|
|
48
|
+
export function extendOrderStateMachine(
|
|
49
|
+
customTransitions: Record<string, string[]>,
|
|
50
|
+
): StateDefinition<string> {
|
|
51
|
+
const merged: Record<string, string[]> = {};
|
|
52
|
+
|
|
53
|
+
// Copy defaults
|
|
54
|
+
for (const [state, targets] of Object.entries(DEFAULT_TRANSITIONS)) {
|
|
55
|
+
merged[state] = [...targets];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Merge custom
|
|
59
|
+
for (const [state, targets] of Object.entries(customTransitions)) {
|
|
60
|
+
if (!merged[state]) merged[state] = [];
|
|
61
|
+
for (const t of targets) {
|
|
62
|
+
if (!merged[state].includes(t)) merged[state].push(t);
|
|
63
|
+
}
|
|
64
|
+
// Ensure target states also exist in the map
|
|
65
|
+
for (const t of targets) {
|
|
66
|
+
if (!merged[t]) merged[t] = [];
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const allStates = Object.keys(merged);
|
|
71
|
+
const terminal = allStates.filter((s) => merged[s]!.length === 0);
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
states: allStates,
|
|
75
|
+
initial: "pending",
|
|
76
|
+
transitions: merged,
|
|
77
|
+
terminal,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function canTransition<TState extends string>(
|
|
82
|
+
machine: StateDefinition<TState>,
|
|
83
|
+
from: TState,
|
|
84
|
+
to: TState,
|
|
85
|
+
): boolean {
|
|
86
|
+
return machine.transitions[from].includes(to);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function assertTransition<TState extends string>(
|
|
90
|
+
machine: StateDefinition<TState>,
|
|
91
|
+
from: TState,
|
|
92
|
+
to: TState,
|
|
93
|
+
): void {
|
|
94
|
+
if (!canTransition(machine, from, to)) {
|
|
95
|
+
throw new CommerceInvalidTransitionError(
|
|
96
|
+
`Cannot transition from "${from}" to "${to}". Allowed transitions from "${from}": [${machine.transitions[
|
|
97
|
+
from
|
|
98
|
+
].join(", ")}]`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
import { sql, type SQL } from "drizzle-orm";
|
|
2
|
+
import type { DrizzleDatabase } from "../../kernel/database/drizzle-db.js";
|
|
3
|
+
import { CommerceValidationError } from "../../kernel/errors.js";
|
|
4
|
+
import { Err, Ok, type Result } from "../../kernel/result.js";
|
|
5
|
+
import type {
|
|
6
|
+
AnalyticsAdapter,
|
|
7
|
+
AnalyticsFilter,
|
|
8
|
+
AnalyticsMeta,
|
|
9
|
+
AnalyticsModel,
|
|
10
|
+
AnalyticsModelDefinition,
|
|
11
|
+
AnalyticsQueryParams,
|
|
12
|
+
AnalyticsQueryResult,
|
|
13
|
+
AnalyticsScope,
|
|
14
|
+
AnalyticsTimeDimension,
|
|
15
|
+
} from "./types.js";
|
|
16
|
+
|
|
17
|
+
// ─── Date Range Parsing ──────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
function parseDateRange(range: AnalyticsTimeDimension["dateRange"]): [Date, Date] | null {
|
|
20
|
+
if (Array.isArray(range)) {
|
|
21
|
+
const start = new Date(range[0]!);
|
|
22
|
+
const end = new Date(range[1]!);
|
|
23
|
+
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) return null;
|
|
24
|
+
return [start, end];
|
|
25
|
+
}
|
|
26
|
+
if (typeof range !== "string") return null;
|
|
27
|
+
|
|
28
|
+
const now = new Date();
|
|
29
|
+
const lower = range.toLowerCase();
|
|
30
|
+
|
|
31
|
+
if (lower === "last month") {
|
|
32
|
+
return [
|
|
33
|
+
new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1)),
|
|
34
|
+
new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 0, 23, 59, 59, 999)),
|
|
35
|
+
];
|
|
36
|
+
}
|
|
37
|
+
if (lower === "this month") {
|
|
38
|
+
return [
|
|
39
|
+
new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)),
|
|
40
|
+
new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0, 23, 59, 59, 999)),
|
|
41
|
+
];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const qMatch = range.match(/^Q([1-4])\s+(\d{4})$/i);
|
|
45
|
+
if (qMatch) {
|
|
46
|
+
const quarter = Number(qMatch[1]);
|
|
47
|
+
const year = Number(qMatch[2]);
|
|
48
|
+
const monthStart = (quarter - 1) * 3;
|
|
49
|
+
return [
|
|
50
|
+
new Date(Date.UTC(year, monthStart, 1)),
|
|
51
|
+
new Date(Date.UTC(year, monthStart + 3, 0, 23, 59, 59, 999)),
|
|
52
|
+
];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function pickCube(params: AnalyticsQueryParams): string {
|
|
59
|
+
const first = [
|
|
60
|
+
...(params.measures ?? []),
|
|
61
|
+
...(params.dimensions ?? []),
|
|
62
|
+
...(params.timeDimensions ?? []).map((td) => td.dimension),
|
|
63
|
+
...(params.filters ?? []).map((f) => f.member),
|
|
64
|
+
][0];
|
|
65
|
+
if (!first) return "Orders";
|
|
66
|
+
return first.split(".")[0] ?? "Orders";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ─── Granularity to TO_CHAR format ───────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
const GRANULARITY_FORMAT: Record<string, string> = {
|
|
72
|
+
day: "YYYY-MM-DD",
|
|
73
|
+
week: "IYYY-\"W\"IW",
|
|
74
|
+
month: "YYYY-MM",
|
|
75
|
+
year: "YYYY",
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// ─── SQL Compilation Helpers ─────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
function compileMeasure(cube: AnalyticsModel, measureName: string): SQL {
|
|
81
|
+
const shortName = measureName.split(".")[1]!;
|
|
82
|
+
const def = cube.measures[shortName];
|
|
83
|
+
if (!def) return sql`0`;
|
|
84
|
+
|
|
85
|
+
switch (def.type) {
|
|
86
|
+
case "count":
|
|
87
|
+
if (def.filter) {
|
|
88
|
+
return sql.raw(`COUNT(CASE WHEN ${def.filter} THEN 1 END)`);
|
|
89
|
+
}
|
|
90
|
+
return sql`COUNT(*)`;
|
|
91
|
+
case "sum":
|
|
92
|
+
return sql.raw(`COALESCE(SUM(${def.sql!}), 0)`);
|
|
93
|
+
case "avg":
|
|
94
|
+
return sql.raw(`COALESCE(ROUND(AVG(${def.sql!})), 0)`);
|
|
95
|
+
case "min":
|
|
96
|
+
return sql.raw(`MIN(${def.sql!})`);
|
|
97
|
+
case "max":
|
|
98
|
+
return sql.raw(`MAX(${def.sql!})`);
|
|
99
|
+
case "countDistinct":
|
|
100
|
+
return sql.raw(`COUNT(DISTINCT ${def.sql!})`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function compileTimeDimensionSelect(cube: AnalyticsModel, td: AnalyticsTimeDimension): SQL {
|
|
105
|
+
const shortName = td.dimension.split(".")[1]!;
|
|
106
|
+
const dimDef = cube.dimensions[shortName];
|
|
107
|
+
if (!dimDef) return sql`NULL`;
|
|
108
|
+
|
|
109
|
+
const gran = td.granularity ?? "day";
|
|
110
|
+
// Validate granularity against whitelist to prevent SQL injection
|
|
111
|
+
if (!(gran in GRANULARITY_FORMAT)) {
|
|
112
|
+
return sql`NULL`;
|
|
113
|
+
}
|
|
114
|
+
const format = GRANULARITY_FORMAT[gran]!;
|
|
115
|
+
return sql.raw(`TO_CHAR(DATE_TRUNC('${gran}', ${dimDef.sql}), '${format}')`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function compileFilter(cube: AnalyticsModel, filter: AnalyticsFilter): SQL | null {
|
|
119
|
+
const shortName = filter.member.split(".")[1]!;
|
|
120
|
+
const dimDef = cube.dimensions[shortName];
|
|
121
|
+
if (!dimDef) return null;
|
|
122
|
+
|
|
123
|
+
const col = dimDef.sql;
|
|
124
|
+
const values = filter.values ?? [];
|
|
125
|
+
if (values.length === 0) return null;
|
|
126
|
+
|
|
127
|
+
switch (filter.operator) {
|
|
128
|
+
case "equals":
|
|
129
|
+
return sql`${sql.raw(col)} = ${values[0]!}`;
|
|
130
|
+
case "notEquals":
|
|
131
|
+
return sql`${sql.raw(col)} != ${values[0]!}`;
|
|
132
|
+
case "contains":
|
|
133
|
+
return sql`${sql.raw(col)} ILIKE ${"%" + values[0]! + "%"}`;
|
|
134
|
+
case "in":
|
|
135
|
+
return sql`${sql.raw(col)} IN (${sql.join(values.map((v) => sql`${v}`), sql`, `)})`;
|
|
136
|
+
case "notIn":
|
|
137
|
+
return sql`${sql.raw(col)} NOT IN (${sql.join(values.map((v) => sql`${v}`), sql`, `)})`;
|
|
138
|
+
case "gt":
|
|
139
|
+
return sql`${sql.raw(col)} > ${Number(values[0]!)}`;
|
|
140
|
+
case "gte":
|
|
141
|
+
return sql`${sql.raw(col)} >= ${Number(values[0]!)}`;
|
|
142
|
+
case "lt":
|
|
143
|
+
return sql`${sql.raw(col)} < ${Number(values[0]!)}`;
|
|
144
|
+
case "lte":
|
|
145
|
+
return sql`${sql.raw(col)} <= ${Number(values[0]!)}`;
|
|
146
|
+
case "beforeDate":
|
|
147
|
+
return sql`${sql.raw(col)} <= ${values[0]!}::timestamptz`;
|
|
148
|
+
case "afterDate":
|
|
149
|
+
return sql`${sql.raw(col)} >= ${values[0]!}::timestamptz`;
|
|
150
|
+
case "inDateRange":
|
|
151
|
+
if (values.length >= 2) {
|
|
152
|
+
return sql`${sql.raw(col)} BETWEEN ${values[0]!}::timestamptz AND ${values[1]!}::timestamptz`;
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ─── DrizzleAnalyticsAdapter ─────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
export class DrizzleAnalyticsAdapter implements AnalyticsAdapter {
|
|
161
|
+
private models = new Map<string, AnalyticsModel>();
|
|
162
|
+
|
|
163
|
+
constructor(private db: DrizzleDatabase) {}
|
|
164
|
+
|
|
165
|
+
registerModel(model: AnalyticsModel): void {
|
|
166
|
+
this.models.set(model.name, model);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async query(params: AnalyticsQueryParams, scope: AnalyticsScope): Promise<Result<AnalyticsQueryResult>> {
|
|
170
|
+
if (!params.measures || params.measures.length === 0) {
|
|
171
|
+
return Err(new CommerceValidationError("analytics.query requires at least one measure."));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const cubeName = pickCube(params);
|
|
175
|
+
|
|
176
|
+
// Validate all members are from the same cube
|
|
177
|
+
const allMembers = [
|
|
178
|
+
...params.measures,
|
|
179
|
+
...(params.dimensions ?? []),
|
|
180
|
+
...(params.timeDimensions ?? []).map((td) => td.dimension),
|
|
181
|
+
];
|
|
182
|
+
const wrongCube = allMembers.find((m) => m.split(".")[0] !== cubeName);
|
|
183
|
+
if (wrongCube) {
|
|
184
|
+
return Err(new CommerceValidationError(
|
|
185
|
+
`analytics.query currently requires measures from a single cube. Found "${wrongCube}" in "${cubeName}" query.`,
|
|
186
|
+
));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const cube = this.models.get(cubeName);
|
|
190
|
+
if (!cube) {
|
|
191
|
+
const available = [...this.models.keys()].join(", ");
|
|
192
|
+
return Err(new CommerceValidationError(
|
|
193
|
+
`Unknown analytics cube: "${cubeName}". Available cubes: ${available}`,
|
|
194
|
+
));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Validate that requested measures exist in the cube
|
|
198
|
+
for (const measure of params.measures) {
|
|
199
|
+
const shortName = measure.split(".")[1];
|
|
200
|
+
if (shortName && !cube.measures[shortName]) {
|
|
201
|
+
const available = Object.keys(cube.measures).map((m) => `${cubeName}.${m}`).join(", ");
|
|
202
|
+
return Err(new CommerceValidationError(
|
|
203
|
+
`Unknown measure: "${measure}". Available measures for ${cubeName}: ${available}`,
|
|
204
|
+
));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Validate that requested dimensions exist in the cube
|
|
209
|
+
for (const dim of params.dimensions ?? []) {
|
|
210
|
+
const shortName = dim.split(".")[1];
|
|
211
|
+
if (shortName && !cube.dimensions[shortName]) {
|
|
212
|
+
const available = Object.keys(cube.dimensions).map((d) => `${cubeName}.${d}`).join(", ");
|
|
213
|
+
return Err(new CommerceValidationError(
|
|
214
|
+
`Unknown dimension: "${dim}". Available dimensions for ${cubeName}: ${available}`,
|
|
215
|
+
));
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
const rows = await this.executeQuery(cube, params, scope);
|
|
221
|
+
return Ok({
|
|
222
|
+
query: params,
|
|
223
|
+
rows,
|
|
224
|
+
source: cubeName,
|
|
225
|
+
});
|
|
226
|
+
} catch (error) {
|
|
227
|
+
return Err(new CommerceValidationError(
|
|
228
|
+
`Analytics query failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
229
|
+
));
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async getMeta(_scope: AnalyticsScope): Promise<Result<AnalyticsMeta>> {
|
|
234
|
+
const models: AnalyticsModelDefinition[] = [];
|
|
235
|
+
|
|
236
|
+
for (const cube of this.models.values()) {
|
|
237
|
+
models.push({
|
|
238
|
+
name: cube.name,
|
|
239
|
+
source: "builtin",
|
|
240
|
+
measures: Object.keys(cube.measures).map((m) => `${cube.name}.${m}`),
|
|
241
|
+
dimensions: Object.keys(cube.dimensions).map((d) => `${cube.name}.${d}`),
|
|
242
|
+
segments: cube.segments
|
|
243
|
+
? Object.keys(cube.segments).map((s) => `${cube.name}.${s}`)
|
|
244
|
+
: [],
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return Ok({
|
|
249
|
+
models,
|
|
250
|
+
measures: models.flatMap((m) => m.measures),
|
|
251
|
+
dimensions: models.flatMap((m) => m.dimensions),
|
|
252
|
+
segments: models.flatMap((m) => m.segments ?? []),
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ─── SQL Query Builder ───────────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
private async executeQuery(
|
|
259
|
+
cube: AnalyticsModel,
|
|
260
|
+
params: AnalyticsQueryParams,
|
|
261
|
+
scope: AnalyticsScope,
|
|
262
|
+
): Promise<Record<string, unknown>[]> {
|
|
263
|
+
const selectParts: SQL[] = [];
|
|
264
|
+
const groupByParts: SQL[] = [];
|
|
265
|
+
const whereParts: SQL[] = [];
|
|
266
|
+
|
|
267
|
+
// ── Scope-based filtering (always applied) ───────────────────────
|
|
268
|
+
//
|
|
269
|
+
// Security model (hardened by default):
|
|
270
|
+
// admin/staff → no filter (full access)
|
|
271
|
+
// vendor/customer → MUST have a matching scopeRule, or blocked
|
|
272
|
+
// public → always blocked
|
|
273
|
+
//
|
|
274
|
+
// Scope is REQUIRED — there is no unscoped code path.
|
|
275
|
+
{
|
|
276
|
+
if (scope.role === "public") {
|
|
277
|
+
whereParts.push(sql.raw("1 = 0"));
|
|
278
|
+
} else if (scope.role !== "admin" && scope.role !== "staff") {
|
|
279
|
+
// Vendor or customer: look for a matching scope rule
|
|
280
|
+
let scopeApplied = false;
|
|
281
|
+
|
|
282
|
+
if (cube.scopeRules) {
|
|
283
|
+
for (const rule of cube.scopeRules) {
|
|
284
|
+
if (rule.role === scope.role) {
|
|
285
|
+
// Use parameterized SQL for scope values to prevent injection.
|
|
286
|
+
// The filter template uses :vendorId / :customerId placeholders.
|
|
287
|
+
// We split the filter on placeholders and build a parameterized
|
|
288
|
+
// sql`` template instead of using sql.raw() with string interpolation.
|
|
289
|
+
let filterSql = rule.filter;
|
|
290
|
+
if (scope.vendorId && filterSql.includes(":vendorId")) {
|
|
291
|
+
// Replace placeholder with parameterized value
|
|
292
|
+
const parts = filterSql.split(":vendorId");
|
|
293
|
+
const fragments = parts.map((part, i) =>
|
|
294
|
+
i < parts.length - 1
|
|
295
|
+
? sql`${sql.raw(part)}${scope.vendorId!}`
|
|
296
|
+
: sql.raw(part),
|
|
297
|
+
);
|
|
298
|
+
whereParts.push(sql.join(fragments, sql``));
|
|
299
|
+
scopeApplied = true;
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
if (scope.customerId && filterSql.includes(":customerId")) {
|
|
303
|
+
const parts = filterSql.split(":customerId");
|
|
304
|
+
const fragments = parts.map((part, i) =>
|
|
305
|
+
i < parts.length - 1
|
|
306
|
+
? sql`${sql.raw(part)}${scope.customerId!}`
|
|
307
|
+
: sql.raw(part),
|
|
308
|
+
);
|
|
309
|
+
whereParts.push(sql.join(fragments, sql``));
|
|
310
|
+
scopeApplied = true;
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
// No placeholder found — use raw (trusted scope rule SQL)
|
|
314
|
+
whereParts.push(sql.raw(filterSql));
|
|
315
|
+
scopeApplied = true;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Deny-by-default: if no scope rule matched, block access
|
|
321
|
+
if (!scopeApplied) {
|
|
322
|
+
whereParts.push(sql.raw("1 = 0"));
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
// admin/staff: no filter applied — full access
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Dimensions → SELECT + GROUP BY
|
|
329
|
+
for (const dim of params.dimensions ?? []) {
|
|
330
|
+
const shortName = dim.split(".")[1]!;
|
|
331
|
+
const dimDef = cube.dimensions[shortName];
|
|
332
|
+
if (!dimDef) continue;
|
|
333
|
+
selectParts.push(sql.raw(`${dimDef.sql} AS "${dim}"`));
|
|
334
|
+
groupByParts.push(sql.raw(dimDef.sql));
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Time dimensions → SELECT + GROUP BY + WHERE (dateRange)
|
|
338
|
+
for (const td of params.timeDimensions ?? []) {
|
|
339
|
+
const selectExpr = compileTimeDimensionSelect(cube, td);
|
|
340
|
+
selectParts.push(sql`${selectExpr} AS ${sql.raw(`"${td.dimension}"`)}`);
|
|
341
|
+
groupByParts.push(selectExpr);
|
|
342
|
+
|
|
343
|
+
// Date range filter
|
|
344
|
+
if (td.dateRange) {
|
|
345
|
+
const range = parseDateRange(td.dateRange);
|
|
346
|
+
if (range) {
|
|
347
|
+
const shortName = td.dimension.split(".")[1]!;
|
|
348
|
+
const dimDef = cube.dimensions[shortName];
|
|
349
|
+
if (dimDef) {
|
|
350
|
+
whereParts.push(
|
|
351
|
+
sql`${sql.raw(dimDef.sql)} >= ${range[0].toISOString()}::timestamptz AND ${sql.raw(dimDef.sql)} < ${range[1].toISOString()}::timestamptz`,
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Measures → SELECT
|
|
359
|
+
for (const measure of params.measures) {
|
|
360
|
+
selectParts.push(sql`${compileMeasure(cube, measure)} AS ${sql.raw(`"${measure}"`)}`);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// If no select parts (shouldn't happen with validation), bail
|
|
364
|
+
if (selectParts.length === 0) {
|
|
365
|
+
return [];
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// FROM + JOINs
|
|
369
|
+
let fromFragment = sql.raw(cube.table);
|
|
370
|
+
for (const join of cube.joins ?? []) {
|
|
371
|
+
const joinType = join.type === "inner" ? "INNER JOIN" : "LEFT JOIN";
|
|
372
|
+
fromFragment = sql`${fromFragment} ${sql.raw(joinType)} ${sql.raw(join.table)} ON ${sql.raw(join.on)}`;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Filters → WHERE
|
|
376
|
+
for (const filter of params.filters ?? []) {
|
|
377
|
+
const compiled = compileFilter(cube, filter);
|
|
378
|
+
if (compiled) whereParts.push(compiled);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// ORDER BY — validate member names against the cube's registered measures/dimensions
|
|
382
|
+
const validMeasures = new Set(Object.keys(cube.measures).map((m) => `${cube.name}.${m}`));
|
|
383
|
+
const validDimensions = new Set(Object.keys(cube.dimensions).map((d) => `${cube.name}.${d}`));
|
|
384
|
+
const orderParts: SQL[] = [];
|
|
385
|
+
for (const [member, dir] of Object.entries(params.order ?? {})) {
|
|
386
|
+
if (!validMeasures.has(member) && !validDimensions.has(member)) {
|
|
387
|
+
continue; // skip unknown members to prevent SQL injection via sql.raw()
|
|
388
|
+
}
|
|
389
|
+
const normalizedDir = dir.toUpperCase() === "DESC" ? "DESC" : "ASC";
|
|
390
|
+
orderParts.push(sql.raw(`"${member}" ${normalizedDir}`));
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// LIMIT
|
|
394
|
+
const limit = Math.max(1, Math.min(params.limit ?? 100, 1000));
|
|
395
|
+
|
|
396
|
+
// Assemble the full query
|
|
397
|
+
const selectClause = sql.join(selectParts, sql`, `);
|
|
398
|
+
const whereClause = whereParts.length > 0
|
|
399
|
+
? sql`WHERE ${sql.join(whereParts, sql` AND `)}`
|
|
400
|
+
: sql``;
|
|
401
|
+
const groupByClause = groupByParts.length > 0
|
|
402
|
+
? sql`GROUP BY ${sql.join(groupByParts, sql`, `)}`
|
|
403
|
+
: sql``;
|
|
404
|
+
const orderClause = orderParts.length > 0
|
|
405
|
+
? sql`ORDER BY ${sql.join(orderParts, sql`, `)}`
|
|
406
|
+
: sql``;
|
|
407
|
+
|
|
408
|
+
const fullQuery = sql`SELECT ${selectClause} FROM ${fromFragment} ${whereClause} ${groupByClause} ${orderClause} LIMIT ${limit}`;
|
|
409
|
+
|
|
410
|
+
const result = await this.db.execute(fullQuery);
|
|
411
|
+
|
|
412
|
+
// Map results: convert bigint to number, preserve column aliases
|
|
413
|
+
// db.execute() returns different shapes per driver — normalize to array of rows
|
|
414
|
+
const rawRows: Record<string, unknown>[] = Array.isArray(result)
|
|
415
|
+
? result
|
|
416
|
+
: (result as { rows?: Record<string, unknown>[] }).rows ?? [];
|
|
417
|
+
|
|
418
|
+
return rawRows.map((row: Record<string, unknown>) => {
|
|
419
|
+
const mapped: Record<string, unknown> = {};
|
|
420
|
+
for (const [key, value] of Object.entries(row)) {
|
|
421
|
+
mapped[key] = typeof value === "bigint" ? Number(value) : value;
|
|
422
|
+
}
|
|
423
|
+
return mapped;
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Analytics hooks — RFC-006
|
|
3
|
+
*
|
|
4
|
+
* The in-memory event recording hooks (recordOrderAnalyticsEvent,
|
|
5
|
+
* recordInventoryAnalyticsEvent) have been removed. The source tables
|
|
6
|
+
* (orders, inventory_levels, customers) ARE the source of truth.
|
|
7
|
+
* The DrizzleAnalyticsAdapter queries them directly via SQL.
|
|
8
|
+
*
|
|
9
|
+
* These empty exports preserve backwards compatibility for any code
|
|
10
|
+
* that imports from this module.
|
|
11
|
+
*/
|