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