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