@classytic/arc 2.11.4 → 2.14.0

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 (167) hide show
  1. package/README.md +16 -12
  2. package/dist/{BaseController-swXruJ2_.mjs → BaseController-DX_T-bDB.mjs} +388 -423
  3. package/dist/EventTransport-CT_52aWU.d.mts +34 -0
  4. package/dist/EventTransport-DLWoUMHy.mjs +103 -0
  5. package/dist/{ResourceRegistry-DkAeAuTX.mjs → ResourceRegistry-CTERg_2x.mjs} +139 -66
  6. package/dist/audit/index.d.mts +2 -2
  7. package/dist/audit/index.mjs +1 -1
  8. package/dist/auth/audit.d.mts +199 -0
  9. package/dist/auth/audit.mjs +288 -0
  10. package/dist/auth/index.d.mts +3 -3
  11. package/dist/auth/index.mjs +117 -191
  12. package/dist/{betterAuthOpenApi-DwxtK3uG.mjs → betterAuthOpenApi--M_i87dQ.mjs} +1 -1
  13. package/dist/buildHandler-olo-gt94.mjs +610 -0
  14. package/dist/cache/index.mjs +3 -3
  15. package/dist/cli/commands/describe.d.mts +89 -13
  16. package/dist/cli/commands/describe.mjs +56 -2
  17. package/dist/cli/commands/docs.mjs +2 -2
  18. package/dist/cli/commands/generate.mjs +147 -48
  19. package/dist/cli/commands/init.d.mts +13 -0
  20. package/dist/cli/commands/init.mjs +130 -87
  21. package/dist/cli/commands/introspect.mjs +8 -1
  22. package/dist/context/index.mjs +1 -1
  23. package/dist/core/index.d.mts +3 -3
  24. package/dist/core/index.mjs +5 -5
  25. package/dist/core-DECn6zaU.mjs +1399 -0
  26. package/dist/{createActionRouter-CIKOcNA7.mjs → createActionRouter-CBxLLbn3.mjs} +7 -20
  27. package/dist/createAggregationRouter-CRIBv4sC.mjs +114 -0
  28. package/dist/{createApp-C9bRrqlX.mjs → createApp-XX2-N0Yd.mjs} +28 -22
  29. package/dist/{defineEvent-D1Ky9M1D.mjs → defineEvent-D5h7EvAx.mjs} +1 -1
  30. package/dist/docs/index.d.mts +24 -11
  31. package/dist/docs/index.mjs +2 -2
  32. package/dist/{elevation-DOFoxoDs.mjs → elevation-DgoeTyfX.mjs} +1 -1
  33. package/dist/errorHandler-Bk-AGhkU.mjs +174 -0
  34. package/dist/errorHandler-DFr45ZG4.d.mts +45 -0
  35. package/dist/errors-j4aJm1Wg.mjs +184 -0
  36. package/dist/{eventPlugin-Cts2-Tfj.mjs → eventPlugin-CaKTYkYM.mjs} +28 -4
  37. package/dist/{eventPlugin-DDJoNEPL.d.mts → eventPlugin-qXpqTebY.d.mts} +24 -1
  38. package/dist/events/index.d.mts +6 -6
  39. package/dist/events/index.mjs +11 -35
  40. package/dist/events/transports/redis-stream-entry.d.mts +1 -1
  41. package/dist/events/transports/redis.d.mts +1 -1
  42. package/dist/factory/index.d.mts +2 -2
  43. package/dist/factory/index.mjs +2 -2
  44. package/dist/{fields-BRjxOAFp.d.mts → fields-COhcH3fk.d.mts} +23 -2
  45. package/dist/hooks/index.d.mts +1 -1
  46. package/dist/hooks/index.mjs +1 -1
  47. package/dist/idempotency/index.d.mts +1 -1
  48. package/dist/idempotency/index.mjs +1 -20
  49. package/dist/idempotency/redis.mjs +1 -1
  50. package/dist/{index-rHjXmJar.d.mts → index-BTqLEvhu.d.mts} +163 -3
  51. package/dist/{index-CXXRbnf8.d.mts → index-BtW7qYwa.d.mts} +660 -326
  52. package/dist/{index-m8mOOlFW.d.mts → index-Ds61mrJE.d.mts} +50 -4
  53. package/dist/{index-D9t1KNaB.d.mts → index-Dz5IKsrE.d.mts} +360 -219
  54. package/dist/index.d.mts +6 -7
  55. package/dist/index.mjs +9 -10
  56. package/dist/integrations/event-gateway.d.mts +1 -1
  57. package/dist/integrations/event-gateway.mjs +1 -1
  58. package/dist/integrations/index.d.mts +1 -1
  59. package/dist/integrations/mcp/index.d.mts +2 -2
  60. package/dist/integrations/mcp/index.mjs +1 -1
  61. package/dist/integrations/mcp/testing.d.mts +1 -1
  62. package/dist/integrations/mcp/testing.mjs +1 -1
  63. package/dist/integrations/streamline.d.mts +60 -11
  64. package/dist/integrations/streamline.mjs +75 -85
  65. package/dist/integrations/websocket.mjs +2 -8
  66. package/dist/middleware/index.d.mts +1 -1
  67. package/dist/middleware/index.mjs +2 -2
  68. package/dist/migrations/index.d.mts +23 -3
  69. package/dist/migrations/index.mjs +0 -7
  70. package/dist/{multipartBody-CvTR1Un6.mjs → multipartBody-BOvVSVCD.mjs} +11 -8
  71. package/dist/openapi-noXno2CV.mjs +968 -0
  72. package/dist/org/index.d.mts +2 -2
  73. package/dist/org/index.mjs +1 -1
  74. package/dist/permissions/index.d.mts +3 -3
  75. package/dist/permissions/index.mjs +3 -3
  76. package/dist/{permissions-gd_aUWrR.mjs → permissions-ohQyv50e.mjs} +404 -176
  77. package/dist/{pipe-DVoIheVC.mjs → pipe-Zr0KXjQe.mjs} +1 -1
  78. package/dist/pipeline/index.d.mts +1 -1
  79. package/dist/pipeline/index.mjs +1 -1
  80. package/dist/plugins/index.d.mts +16 -31
  81. package/dist/plugins/index.mjs +33 -13
  82. package/dist/plugins/response-cache.mjs +1 -1
  83. package/dist/plugins/tracing-entry.mjs +1 -1
  84. package/dist/presets/filesUpload.d.mts +4 -4
  85. package/dist/presets/filesUpload.mjs +6 -9
  86. package/dist/presets/index.d.mts +1 -1
  87. package/dist/presets/index.mjs +1 -1
  88. package/dist/presets/multiTenant.d.mts +1 -1
  89. package/dist/presets/multiTenant.mjs +2 -2
  90. package/dist/presets/search.d.mts +2 -2
  91. package/dist/presets/search.mjs +6 -8
  92. package/dist/{presets-Z7P5w4gF.mjs → presets-BbkjdPeH.mjs} +6 -28
  93. package/dist/{queryCachePlugin-Bq6bO6vc.mjs → queryCachePlugin-m1XsgAIJ.mjs} +3 -3
  94. package/dist/{redis-stream-xTGxB2bm.d.mts → redis-stream-D6HzR1Z_.d.mts} +1 -1
  95. package/dist/registry/index.d.mts +1 -1
  96. package/dist/registry/index.mjs +2 -2
  97. package/dist/{replyHelpers-ByllIXXV.mjs → replyHelpers-CK-FNO8E.mjs} +3 -21
  98. package/dist/{resourceToTools-CxNmI6xF.mjs → resourceToTools-DLL32us3.mjs} +224 -71
  99. package/dist/{routerShared-BqLRb5l7.mjs → routerShared-DrOa-26E.mjs} +41 -36
  100. package/dist/{schemaIR-Dy2p4MxS.mjs → schemaIR-lYhC2gE5.mjs} +1 -1
  101. package/dist/schemas/index.d.mts +100 -30
  102. package/dist/schemas/index.mjs +86 -29
  103. package/dist/scim/index.d.mts +264 -0
  104. package/dist/scim/index.mjs +963 -0
  105. package/dist/scope/index.d.mts +3 -3
  106. package/dist/scope/index.mjs +4 -4
  107. package/dist/{sse-V7aXc3bW.mjs → sse-Bz-5ZeTt.mjs} +1 -1
  108. package/dist/{store-helpers-Cp4uKC1U.mjs → store-helpers-BkIN9-vu.mjs} +1 -1
  109. package/dist/testing/index.d.mts +2 -8
  110. package/dist/testing/index.mjs +16 -24
  111. package/dist/types/index.d.mts +4 -4
  112. package/dist/{types-D7KpfiL1.d.mts → types-BvqwCCSx.d.mts} +73 -25
  113. package/dist/{types-DDyTPc6y.d.mts → types-CTYvcwHe.d.mts} +195 -1
  114. package/dist/{types-AOD8fxIw.mjs → types-C_s5moIu.mjs} +117 -1
  115. package/dist/{types-BQ9TJQNy.d.mts → types-DQHFc8PM.d.mts} +1 -1
  116. package/dist/utils/index.d.mts +2 -2
  117. package/dist/utils/index.mjs +5 -5
  118. package/dist/{utils-CcYTj09l.mjs → utils-_h9B3c57.mjs} +1269 -1334
  119. package/dist/{versioning-DsglKfM_.d.mts → versioning-DTTvc80y.d.mts} +1 -1
  120. package/package.json +24 -34
  121. package/skills/arc/SKILL.md +147 -51
  122. package/skills/arc/references/agent-auth.md +238 -0
  123. package/skills/arc/references/api-reference.md +187 -0
  124. package/skills/arc/references/auth.md +354 -7
  125. package/skills/arc/references/enterprise-auth.md +94 -0
  126. package/skills/arc/references/events.md +8 -6
  127. package/skills/arc/references/mcp.md +2 -2
  128. package/skills/arc/references/multi-tenancy.md +11 -2
  129. package/skills/arc/references/production.md +10 -9
  130. package/skills/arc/references/scim.md +247 -0
  131. package/skills/arc/references/testing.md +1 -1
  132. package/skills/arc-code-review/SKILL.md +141 -0
  133. package/skills/arc-code-review/references/anti-patterns.md +911 -0
  134. package/skills/arc-code-review/references/arc-cheatsheet.md +380 -0
  135. package/skills/arc-code-review/references/migration-recipes.md +700 -0
  136. package/skills/arc-code-review/references/mongokit-migration.md +386 -0
  137. package/skills/arc-code-review/references/scaffolding.md +230 -0
  138. package/skills/arc-code-review/references/severity.md +127 -0
  139. package/dist/EventTransport-BFQjw9pB.mjs +0 -133
  140. package/dist/EventTransport-CYNUXdCJ.d.mts +0 -293
  141. package/dist/adapters/index.d.mts +0 -3
  142. package/dist/adapters/index.mjs +0 -2
  143. package/dist/adapters-DUUiiimH.mjs +0 -964
  144. package/dist/auth/mongoose.d.mts +0 -191
  145. package/dist/auth/mongoose.mjs +0 -73
  146. package/dist/core-CbcQRIch.mjs +0 -1054
  147. package/dist/errorHandler-BQm8ZxTK.mjs +0 -173
  148. package/dist/errorHandler-DEWmGWPz.d.mts +0 -114
  149. package/dist/errors-D5c-5BJL.mjs +0 -232
  150. package/dist/index-Rg8axYPz.d.mts +0 -370
  151. package/dist/openapi-D7G1V7ex.mjs +0 -557
  152. /package/dist/{HookSystem-CGsMd6oK.mjs → HookSystem-Iiebom92.mjs} +0 -0
  153. /package/dist/{actionPermissions-sUUKDhtP.mjs → actionPermissions-CyUkQu6O.mjs} +0 -0
  154. /package/dist/{caching-CheW3m-S.mjs → caching-SM8gghN6.mjs} +0 -0
  155. /package/dist/{constants-BhY1OHoH.mjs → constants-Cxde4rpC.mjs} +0 -0
  156. /package/dist/{elevation-BQQXZ_VR.d.mts → elevation-BXOWoGCF.d.mts} +0 -0
  157. /package/dist/{keys-CARyUjiR.mjs → keys-CGcCbNyu.mjs} +0 -0
  158. /package/dist/{loadResources-CPpkyKfM.mjs → loadResources-DBMQg_Aj.mjs} +0 -0
  159. /package/dist/{memory-DikHSvWa.mjs → memory-UBydS5ku.mjs} +0 -0
  160. /package/dist/{metrics-Csh4nsvv.mjs → metrics-Qnvwc-LQ.mjs} +0 -0
  161. /package/dist/{pluralize-CWP6MB39.mjs → pluralize-DQgqgifU.mjs} +0 -0
  162. /package/dist/{registry-D63ee7fl.mjs → registry-I-ogLgL9.mjs} +0 -0
  163. /package/dist/{requestContext-C5XeK3VA.mjs → requestContext-SSaaTgW8.mjs} +0 -0
  164. /package/dist/{schemaConverter-B0oKLuqI.mjs → schemaConverter-De34B1ZG.mjs} +0 -0
  165. /package/dist/{typeGuards-CcFZXgU7.mjs → typeGuards-BzkXkvVv.mjs} +0 -0
  166. /package/dist/{types-DV9WDfeg.mjs → types-D57iXYb8.mjs} +0 -0
  167. /package/dist/{versioning-CGPjkqAg.mjs → versioning-BUrT5aP4.mjs} +0 -0
@@ -0,0 +1,610 @@
1
+ //#region src/core/aggregation/validate.ts
2
+ /** Thrown on aggregation misconfig at boot time. */
3
+ var ArcAggregationConfigError = class extends Error {
4
+ name = "ArcAggregationConfigError";
5
+ };
6
+ /**
7
+ * Validate + normalize all aggregations on a resource. Throws on first
8
+ * misconfig with the offending aggregation name in the message — hosts
9
+ * see exactly which entry needs fixing.
10
+ *
11
+ * Adapter feature-detection runs only when an `adapter` is present;
12
+ * boot order means the controller's `repository` may be the
13
+ * `RepositoryLike` shape. Best-effort `'aggregate' in repo` check covers
14
+ * mongokit / sqlitekit; missing `aggregate()` deferred to request time
15
+ * with a clear 501 (handled in the request handler).
16
+ */
17
+ function validateAggregations(resourceName, aggregations, schemaOptions) {
18
+ const out = [];
19
+ const blockedFields = collectBlockedFields(schemaOptions);
20
+ for (const [name, config] of Object.entries(aggregations)) {
21
+ if (!isValidAggregationName(name)) throw new ArcAggregationConfigError(`Resource "${resourceName}" aggregation key "${name}" is invalid — keys map to URL segments and must be alphanumeric or underscore/hyphen.`);
22
+ if (typeof config.permissions !== "function") throw new ArcAggregationConfigError(`Resource "${resourceName}" aggregation "${name}" is missing a "permissions" check. Aggregations must declare permissions explicitly — no default-allow. Use a permission helper from @classytic/arc/permissions.`);
23
+ if (!config.measures || Object.keys(config.measures).length === 0) throw new ArcAggregationConfigError(`Resource "${resourceName}" aggregation "${name}" has no measures. An empty "measures" map is a wiring bug — at least one measure is required.`);
24
+ const lookupAliases = collectLookupAliases(config.lookups);
25
+ const measures = compileMeasures(resourceName, name, config.measures);
26
+ const groupBy = normalizeGroupBy(config.groupBy);
27
+ const bucketAliases = config.dateBuckets ? Object.keys(config.dateBuckets) : [];
28
+ if (config.dateBuckets) validateDateBuckets({
29
+ resourceName,
30
+ aggregationName: name,
31
+ dateBuckets: config.dateBuckets,
32
+ groupBy,
33
+ measures,
34
+ lookupAliases,
35
+ blockedFields
36
+ });
37
+ validateFieldReferences({
38
+ resourceName,
39
+ aggregationName: name,
40
+ groupBy,
41
+ measures,
42
+ sort: config.sort,
43
+ having: config.having,
44
+ lookupAliases,
45
+ blockedFields,
46
+ bucketAliases
47
+ });
48
+ if (config.topN) validateTopNConfig(resourceName, name, config.topN, groupBy, measures, bucketAliases);
49
+ out.push({
50
+ name,
51
+ base: config,
52
+ compiled: {
53
+ filter: config.filter,
54
+ lookups: config.lookups,
55
+ groupBy: groupBy.length > 0 ? groupBy : void 0,
56
+ dateBuckets: config.dateBuckets,
57
+ measures,
58
+ having: config.having,
59
+ sort: config.sort,
60
+ limit: config.limit,
61
+ topN: config.topN
62
+ }
63
+ });
64
+ }
65
+ return out;
66
+ }
67
+ /**
68
+ * Adapter feature-detect for `aggregate()`. Called at boot when the
69
+ * repository instance is available. Returns `true` when the kit ships
70
+ * `aggregate`; `false` when missing.
71
+ *
72
+ * `materialized`-only aggregations bypass this check at the request
73
+ * handler — they never call `repo.aggregate`.
74
+ */
75
+ function adapterSupportsAggregate(repo) {
76
+ if (!repo || typeof repo !== "object") return false;
77
+ return typeof repo.aggregate === "function";
78
+ }
79
+ /** Compile to canonical `AggRequest` for `repo.aggregate()` at request time. */
80
+ function compileAggRequest(normalized, callerFilter, tenantOptions) {
81
+ const baseFilter = normalized.compiled.filter ?? {};
82
+ const filter = {
83
+ ...extractTenantFilter(tenantOptions),
84
+ ...baseFilter,
85
+ ...callerFilter
86
+ };
87
+ const executionHints = buildExecutionHints(normalized.base);
88
+ const cache = buildCacheOptions(normalized.base);
89
+ return {
90
+ measures: normalized.compiled.measures,
91
+ ...Object.keys(filter).length > 0 ? { filter } : {},
92
+ ...normalized.compiled.lookups ? { lookups: normalized.compiled.lookups } : {},
93
+ ...normalized.compiled.groupBy ? { groupBy: normalized.compiled.groupBy } : {},
94
+ ...normalized.compiled.dateBuckets ? { dateBuckets: normalized.compiled.dateBuckets } : {},
95
+ ...normalized.compiled.having ? { having: normalized.compiled.having } : {},
96
+ ...normalized.compiled.sort ? { sort: normalized.compiled.sort } : {},
97
+ ...normalized.compiled.limit !== void 0 ? { limit: normalized.compiled.limit } : {},
98
+ ...normalized.compiled.topN ? { topN: normalized.compiled.topN } : {},
99
+ ...executionHints ? { executionHints } : {},
100
+ ...cache ? { cache } : {}
101
+ };
102
+ }
103
+ /**
104
+ * Translate the host's declarative `cache:` config into the TanStack-
105
+ * shaped `CacheOptions` repo-core's unified cache plugin reads from
106
+ * `req.cache`. The plugin handles SWR semantics, version-bump
107
+ * invalidation, and tag side-index — arc just declares the policy.
108
+ *
109
+ * No translation needed when the host disabled cache (returns
110
+ * undefined, kit falls through to a non-cached call).
111
+ */
112
+ function buildCacheOptions(config) {
113
+ const c = config.cache;
114
+ if (!c) return void 0;
115
+ return {
116
+ ...c.staleTime !== void 0 ? { staleTime: c.staleTime } : {},
117
+ ...c.gcTime !== void 0 ? { gcTime: c.gcTime } : {},
118
+ ...c.tags ? { tags: c.tags } : {},
119
+ swr: c.swr ?? true
120
+ };
121
+ }
122
+ /**
123
+ * Boot validation for `topN`. Mirrors the contract mongokit + sqlitekit
124
+ * enforce at request time — both kits check the same three rules and
125
+ * throw with kit-prefixed messages. Running them at boot gives hosts
126
+ * the misconfig surface BEFORE the first dashboard request, with the
127
+ * offending aggregation name included for debugging. Same logic /
128
+ * messages stay aligned across the kits and arc.
129
+ */
130
+ function validateTopNConfig(resource, aggregation, topN, groupBy, measures, bucketAliases) {
131
+ if (!Number.isInteger(topN.limit) || topN.limit <= 0) throw new ArcAggregationConfigError(`Resource "${resource}" aggregation "${aggregation}" topN.limit must be a positive integer — got ${String(topN.limit)}.`);
132
+ if (!topN.sortBy || Object.keys(topN.sortBy).length === 0) throw new ArcAggregationConfigError(`Resource "${resource}" aggregation "${aggregation}" topN.sortBy must declare at least one ranking field.`);
133
+ const partitionList = Array.isArray(topN.partitionBy) ? topN.partitionBy : [topN.partitionBy];
134
+ if (partitionList.length === 0) throw new ArcAggregationConfigError(`Resource "${resource}" aggregation "${aggregation}" topN.partitionBy must declare at least one partition column.`);
135
+ const validKeys = new Set([
136
+ ...groupBy,
137
+ ...bucketAliases,
138
+ ...Object.keys(measures)
139
+ ]);
140
+ for (const key of partitionList) if (!validKeys.has(key)) throw new ArcAggregationConfigError(`Resource "${resource}" aggregation "${aggregation}" topN.partitionBy "${key}" is not a groupBy field, dateBucket alias, or measure alias. Available: ${[...validKeys].join(", ") || "(none — declare groupBy, dateBuckets, or measures)"}.`);
141
+ }
142
+ const VALID_BUCKET_UNITS = new Set([
143
+ "minute",
144
+ "hour",
145
+ "day",
146
+ "week",
147
+ "month",
148
+ "quarter",
149
+ "year"
150
+ ]);
151
+ const CUSTOM_BIN_UNIT_BLOCKLIST = new Set(["quarter", "year"]);
152
+ /**
153
+ * Validate `dateBuckets`. Catches the two classes of misconfig kits
154
+ * already throw on at runtime — alias collisions and field-rule
155
+ * violations — at boot, with the offending aggregation name in the
156
+ * message.
157
+ *
158
+ * Rules (parity with mongokit's `validateBucketAliases` + sqlitekit's
159
+ * `compileDateBucket` field-rule pass):
160
+ * 1. Bucket alias MUST NOT collide with a groupBy field or measure
161
+ * alias — output row would have an ambiguous key.
162
+ * 2. Bucket `field` (resolves to a base column or joined-alias path)
163
+ * must NOT be hidden / systemManaged.
164
+ * 3. Custom-bin form (`{ every, unit }`): `every` is a positive
165
+ * integer; `unit` is in the supported set (minute/hour/day/week/
166
+ * month — quarter and year aren't valid in custom-bin form).
167
+ */
168
+ function validateDateBuckets(input) {
169
+ const { resourceName, aggregationName, dateBuckets, groupBy, measures, lookupAliases, blockedFields } = input;
170
+ const groupBySet = new Set(groupBy);
171
+ const measureAliases = new Set(Object.keys(measures));
172
+ for (const [alias, bucket] of Object.entries(dateBuckets)) {
173
+ if (groupBySet.has(alias)) throw new ArcAggregationConfigError(`Resource "${resourceName}" aggregation "${aggregationName}" dateBucket alias "${alias}" collides with a groupBy field. Pick a unique alias.`);
174
+ if (measureAliases.has(alias)) throw new ArcAggregationConfigError(`Resource "${resourceName}" aggregation "${aggregationName}" dateBucket alias "${alias}" collides with a measure alias. Pick a unique alias.`);
175
+ assertBucketFieldAllowed({
176
+ resourceName,
177
+ aggregationName,
178
+ alias,
179
+ field: bucket.field,
180
+ lookupAliases,
181
+ blockedFields
182
+ });
183
+ if (typeof bucket.interval === "string") {
184
+ if (!VALID_BUCKET_UNITS.has(bucket.interval)) throw new ArcAggregationConfigError(`Resource "${resourceName}" aggregation "${aggregationName}" dateBucket "${alias}" interval "${bucket.interval}" is not a recognized unit. Use one of: ${[...VALID_BUCKET_UNITS].join(", ")}.`);
185
+ continue;
186
+ }
187
+ const { every, unit } = bucket.interval;
188
+ if (!Number.isInteger(every) || every <= 0) throw new ArcAggregationConfigError(`Resource "${resourceName}" aggregation "${aggregationName}" dateBucket "${alias}" interval.every must be a positive integer — got ${String(every)}.`);
189
+ if (!VALID_BUCKET_UNITS.has(unit) || CUSTOM_BIN_UNIT_BLOCKLIST.has(unit)) throw new ArcAggregationConfigError(`Resource "${resourceName}" aggregation "${aggregationName}" dateBucket "${alias}" interval.unit "${unit}" is not valid in custom-bin form. Use minute / hour / day / week / month (quarter and year aren't supported as custom bins).`);
190
+ }
191
+ }
192
+ function assertBucketFieldAllowed(input) {
193
+ const { resourceName, aggregationName, alias, field, lookupAliases, blockedFields } = input;
194
+ const dot = field.indexOf(".");
195
+ if (dot > 0) {
196
+ const a = field.slice(0, dot);
197
+ if (lookupAliases.has(a)) return;
198
+ if (blockedFields.has(a)) throw new ArcAggregationConfigError(`Resource "${resourceName}" aggregation "${aggregationName}" dateBucket "${alias}" references field "${field}" whose root "${a}" is marked hidden or systemManaged in schemaOptions.fieldRules. Bucketing on hidden fields would leak temporal info.`);
199
+ return;
200
+ }
201
+ if (blockedFields.has(field)) throw new ArcAggregationConfigError(`Resource "${resourceName}" aggregation "${aggregationName}" dateBucket "${alias}" references field "${field}", but the field is marked hidden or systemManaged in schemaOptions.fieldRules. Bucketing on hidden fields would leak temporal info.`);
202
+ }
203
+ /**
204
+ * Map arc's declarative knobs onto repo-core's portable `AggExecutionHints`.
205
+ * Kits that don't honor a given hint silently ignore it (per IR contract);
206
+ * mongokit threads `maxTimeMs` → `maxTimeMS` and `indexHint` → `aggregate.option({ hint })`.
207
+ */
208
+ function buildExecutionHints(config) {
209
+ const hints = {};
210
+ if (typeof config.timeout === "number" && config.timeout > 0) hints.maxTimeMs = config.timeout;
211
+ if (config.indexHint && config.indexHint.leadingKeys.length > 0) {
212
+ const hintObj = {};
213
+ for (const key of config.indexHint.leadingKeys) hintObj[key] = 1;
214
+ hints.indexHint = hintObj;
215
+ }
216
+ return Object.keys(hints).length > 0 ? hints : void 0;
217
+ }
218
+ const NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/;
219
+ function isValidAggregationName(name) {
220
+ return NAME_PATTERN.test(name);
221
+ }
222
+ function compileMeasures(resource, aggregation, measures) {
223
+ const out = {};
224
+ for (const [alias, input] of Object.entries(measures)) out[alias] = expandMeasure(input, resource, aggregation, alias);
225
+ return out;
226
+ }
227
+ function expandMeasure(input, resource, aggregation, alias) {
228
+ let measure;
229
+ if (typeof input === "object" && input !== null) measure = input;
230
+ else if (typeof input === "string") {
231
+ const expanded = parseMeasureShorthand(input);
232
+ if (!expanded) throw new ArcAggregationConfigError(`Resource "${resource}" aggregation "${aggregation}" measure "${alias}" has invalid shorthand "${input}". Use 'count', 'count:field', 'sum:field', 'avg:field', 'min:field', 'max:field', 'countDistinct:field', or 'percentile:field:p' (p ∈ [0, 1]).`);
233
+ measure = expanded;
234
+ } else throw new ArcAggregationConfigError(`Resource "${resource}" aggregation "${aggregation}" measure "${alias}" is not a string or object: got ${typeof input}.`);
235
+ validateMeasure(measure, resource, aggregation, alias);
236
+ return measure;
237
+ }
238
+ function parseMeasureShorthand(s) {
239
+ if (s === "count") return { op: "count" };
240
+ const colon = s.indexOf(":");
241
+ if (colon < 0) return null;
242
+ const op = s.slice(0, colon);
243
+ const rest = s.slice(colon + 1);
244
+ if (!rest) return null;
245
+ if (op === "percentile") {
246
+ const lastColon = rest.lastIndexOf(":");
247
+ if (lastColon < 0) return null;
248
+ const field = rest.slice(0, lastColon);
249
+ const pStr = rest.slice(lastColon + 1);
250
+ if (!field || !pStr) return null;
251
+ const p = Number(pStr);
252
+ if (!Number.isFinite(p)) return null;
253
+ return {
254
+ op: "percentile",
255
+ field,
256
+ p
257
+ };
258
+ }
259
+ switch (op) {
260
+ case "count": return {
261
+ op: "count",
262
+ field: rest
263
+ };
264
+ case "countDistinct": return {
265
+ op: "countDistinct",
266
+ field: rest
267
+ };
268
+ case "sum": return {
269
+ op: "sum",
270
+ field: rest
271
+ };
272
+ case "avg": return {
273
+ op: "avg",
274
+ field: rest
275
+ };
276
+ case "min": return {
277
+ op: "min",
278
+ field: rest
279
+ };
280
+ case "max": return {
281
+ op: "max",
282
+ field: rest
283
+ };
284
+ default: return null;
285
+ }
286
+ }
287
+ /**
288
+ * Per-measure boot validation. Currently only `percentile` carries a
289
+ * numeric constraint — `p ∈ [0, 1]` matches mongokit's request-time
290
+ * check (and SQL's `PERCENTILE_CONT` semantics). Running it at boot
291
+ * surfaces the misconfig with the offending aggregation + measure
292
+ * alias instead of a kit-side error at first traffic.
293
+ */
294
+ function validateMeasure(measure, resource, aggregation, alias) {
295
+ if (measure.op === "percentile") {
296
+ if (!Number.isFinite(measure.p) || measure.p < 0 || measure.p > 1) throw new ArcAggregationConfigError(`Resource "${resource}" aggregation "${aggregation}" measure "${alias}" has invalid percentile p=${String(measure.p)} — must be a finite number in [0, 1] (e.g. 0.5 for median, 0.95 for P95).`);
297
+ }
298
+ }
299
+ function normalizeGroupBy(groupBy) {
300
+ if (!groupBy) return [];
301
+ if (typeof groupBy === "string") return [groupBy];
302
+ return [...groupBy];
303
+ }
304
+ function collectLookupAliases(lookups) {
305
+ const aliases = /* @__PURE__ */ new Set();
306
+ if (!lookups) return aliases;
307
+ for (const lookup of lookups) aliases.add(lookup.as ?? lookup.from);
308
+ return aliases;
309
+ }
310
+ function collectBlockedFields(schemaOptions) {
311
+ const blocked = /* @__PURE__ */ new Set();
312
+ const fieldRules = schemaOptions?.fieldRules;
313
+ if (!fieldRules) return blocked;
314
+ for (const [field, rules] of Object.entries(fieldRules)) {
315
+ if (!rules) continue;
316
+ if (rules.hidden || rules.systemManaged) blocked.add(field);
317
+ }
318
+ return blocked;
319
+ }
320
+ /**
321
+ * Reject:
322
+ * - groupBy / measure.field / sort key referencing a hidden /
323
+ * systemManaged field
324
+ * - dotted-path references (`alias.field`) where `alias` doesn't
325
+ * match a `LookupSpec.as` (or `from` default)
326
+ *
327
+ * Sort keys may also reference measure aliases, groupBy fields, or
328
+ * dateBucket aliases (all auto-valid — already validated upstream) —
329
+ * those branches accept without further checks.
330
+ */
331
+ function validateFieldReferences(input) {
332
+ const { groupBy, measures, sort, bucketAliases } = input;
333
+ for (const key of groupBy) assertFieldAllowed("groupBy", key, input);
334
+ for (const [alias, measure] of Object.entries(measures)) if ("field" in measure && measure.field) assertFieldAllowed(`measures.${alias}`, measure.field, input);
335
+ if (sort) {
336
+ const measureAliases = new Set(Object.keys(measures));
337
+ const groupBySet = new Set(groupBy);
338
+ const bucketSet = new Set(bucketAliases);
339
+ for (const key of Object.keys(sort)) {
340
+ if (measureAliases.has(key) || groupBySet.has(key) || bucketSet.has(key)) continue;
341
+ assertFieldAllowed(`sort.${key}`, key, input);
342
+ }
343
+ }
344
+ }
345
+ function assertFieldAllowed(context, ref, input) {
346
+ const { resourceName, aggregationName, lookupAliases, blockedFields } = input;
347
+ const dot = ref.indexOf(".");
348
+ if (dot > 0) {
349
+ const alias = ref.slice(0, dot);
350
+ if (lookupAliases.has(alias)) return;
351
+ if (blockedFields.has(alias)) throw new ArcAggregationConfigError(`Resource "${resourceName}" aggregation "${aggregationName}" references field "${ref}" in ${context} whose root "${alias}" is marked hidden or systemManaged in schemaOptions.fieldRules. Aggregating hidden fields would leak cardinality information.`);
352
+ return;
353
+ }
354
+ if (blockedFields.has(ref)) throw new ArcAggregationConfigError(`Resource "${resourceName}" aggregation "${aggregationName}" references field "${ref}" in ${context}, but the field is marked hidden or systemManaged in schemaOptions.fieldRules. Aggregating hidden fields would leak cardinality information.`);
355
+ }
356
+ function extractTenantFilter(tenantOptions) {
357
+ const out = {};
358
+ const optionOnlyKeys = new Set([
359
+ "userId",
360
+ "user",
361
+ "session",
362
+ "requestId"
363
+ ]);
364
+ for (const [key, value] of Object.entries(tenantOptions)) {
365
+ if (optionOnlyKeys.has(key)) continue;
366
+ if (value === void 0 || value === null) continue;
367
+ out[key] = value;
368
+ }
369
+ return out;
370
+ }
371
+ //#endregion
372
+ //#region src/core/aggregation/buildHandler.ts
373
+ /**
374
+ * Framework-agnostic aggregation execution. Runs safety guards,
375
+ * compiles the AggRequest, dispatches to the materialized hook or
376
+ * `repo.aggregate()`, and applies the post-execution `maxGroups` cap.
377
+ *
378
+ * Returns an envelope describing the response — Fastify wrappers
379
+ * apply it to a reply, MCP wrappers convert it to a tool-call result.
380
+ *
381
+ * **Does NOT run the per-aggregation permission check.** Auth runs
382
+ * upstream (Fastify preHandler chain or MCP `evaluatePermission`)
383
+ * because the permission shape differs by surface (FastifyRequest vs
384
+ * MCP session). Both surfaces fail-closed BEFORE reaching this
385
+ * function; this is purely the runtime executor.
386
+ */
387
+ async function executeAggregation(normalized, deps, ctx) {
388
+ const { repo } = deps;
389
+ const config = normalized.base;
390
+ const aggregationName = normalized.name;
391
+ const { query, tenantOptions } = ctx;
392
+ const guardError = checkRequestGuards(query, config);
393
+ if (guardError) return {
394
+ status: 400,
395
+ body: guardError
396
+ };
397
+ const aggReq = compileAggRequest(normalized, extractCallerFilter(query), tenantOptions);
398
+ if (config.materialized) {
399
+ const matCtx = {
400
+ filter: aggReq.filter,
401
+ orgId: pickString(tenantOptions.organizationId),
402
+ userId: pickString(tenantOptions.userId),
403
+ requestId: pickString(tenantOptions.requestId),
404
+ query
405
+ };
406
+ return {
407
+ status: 200,
408
+ headers: { "x-aggregation-source": "materialized" },
409
+ body: { rows: (await config.materialized(matCtx)).rows }
410
+ };
411
+ }
412
+ if (!adapterSupportsAggregate(repo)) return {
413
+ status: 501,
414
+ body: {
415
+ code: "arc.adapter.capability_required",
416
+ message: `Aggregation "${aggregationName}" is not supported: the resource's storage adapter does not implement repo.aggregate(). Use a kit that ships StandardRepo.aggregate (mongokit / sqlitekit), or remove the aggregations entry.`,
417
+ status: 501,
418
+ meta: {
419
+ capability: "aggregate",
420
+ aggregation: aggregationName
421
+ }
422
+ }
423
+ };
424
+ let result;
425
+ try {
426
+ result = await repo.aggregate(aggReq);
427
+ } catch (err) {
428
+ return mapAggregateError(err, aggregationName);
429
+ }
430
+ if (config.maxGroups !== void 0 && result.rows.length > config.maxGroups) return {
431
+ status: 422,
432
+ body: {
433
+ code: "arc.aggregation.max_groups_exceeded",
434
+ message: `Aggregation "${aggregationName}" produced ${result.rows.length} groups, exceeding maxGroups (${config.maxGroups}). Narrow the filter or raise the cap.`,
435
+ status: 422,
436
+ meta: {
437
+ aggregation: aggregationName,
438
+ produced: result.rows.length,
439
+ maxGroups: config.maxGroups
440
+ }
441
+ }
442
+ };
443
+ return {
444
+ status: 200,
445
+ body: { rows: result.rows }
446
+ };
447
+ }
448
+ /**
449
+ * Build the Fastify handler for a single aggregation.
450
+ *
451
+ * The returned function calls the repo (or materialized hook), shapes
452
+ * the response envelope, and writes status/headers via Fastify's
453
+ * `reply` API. Errors throw — the router's error handler converts to
454
+ * the standard arc response shape.
455
+ */
456
+ /**
457
+ * Build the Fastify handler for a single aggregation.
458
+ *
459
+ * Caching lives in the kit's repo-core `cachePlugin` — when the host
460
+ * declares `cache:` on the aggregation, `compileAggRequest` translates
461
+ * to `aggReq.cache: CacheOptions` and the kit handles SWR + tag
462
+ * invalidation + version-bump on writes. Arc passes the request
463
+ * through; no duplicate cache layer at the HTTP handler.
464
+ */
465
+ function buildAggregationHandler(normalized, deps) {
466
+ const { buildOptions } = deps;
467
+ return async (request, reply) => {
468
+ const result = await executeAggregation(normalized, deps, {
469
+ query: request.query ?? {},
470
+ tenantOptions: buildOptions(request)
471
+ });
472
+ reply.status(result.status);
473
+ if (result.headers) for (const [k, v] of Object.entries(result.headers)) reply.header(k, v);
474
+ return result.body;
475
+ };
476
+ }
477
+ function pickString(value) {
478
+ return typeof value === "string" ? value : void 0;
479
+ }
480
+ function checkRequestGuards(query, config) {
481
+ if (config.requireFilters) {
482
+ for (const field of config.requireFilters) if (!hasFilterOnField(query, field)) return {
483
+ code: "arc.aggregation.required_filter_missing",
484
+ message: `Aggregation requires filter on "${field}" — supply ?${field}=... or ?${field}[op]=... in the query string.`,
485
+ status: 400,
486
+ meta: { field }
487
+ };
488
+ }
489
+ if (config.requireDateRange) {
490
+ const { field, maxRangeDays } = config.requireDateRange;
491
+ const range = parseDateRange(query, field);
492
+ if (!range) return {
493
+ code: "arc.aggregation.required_date_range_missing",
494
+ message: `Aggregation requires a bounded date range on "${field}" — supply ?${field}[gte]=... and ?${field}[lt]=... (or ?${field}[lte]=...).`,
495
+ status: 400,
496
+ meta: { field }
497
+ };
498
+ if (maxRangeDays !== void 0) {
499
+ const days = (range.upper.getTime() - range.lower.getTime()) / 864e5;
500
+ if (days > maxRangeDays) return {
501
+ code: "arc.aggregation.date_range_exceeded",
502
+ message: `Aggregation date range on "${field}" exceeds the cap (${maxRangeDays} days). Requested range: ${days.toFixed(1)} days. Narrow the range and retry.`,
503
+ status: 400,
504
+ meta: {
505
+ field,
506
+ maxRangeDays,
507
+ requestedDays: days
508
+ }
509
+ };
510
+ }
511
+ }
512
+ return null;
513
+ }
514
+ function hasFilterOnField(query, field) {
515
+ const direct = query[field];
516
+ if (direct !== void 0 && direct !== "") return true;
517
+ for (const key of Object.keys(query)) if (key.startsWith(`${field}[`)) return true;
518
+ return false;
519
+ }
520
+ function parseDateRange(query, field) {
521
+ let gte;
522
+ let lte;
523
+ const nested = query[field];
524
+ if (nested && typeof nested === "object" && !Array.isArray(nested)) {
525
+ const ops = nested;
526
+ gte = pickString(ops.gte) ?? pickString(ops.gt);
527
+ lte = pickString(ops.lte) ?? pickString(ops.lt);
528
+ }
529
+ if (!gte) gte = pickString(query[`${field}[gte]`]) ?? pickString(query[`${field}[gt]`]);
530
+ if (!lte) lte = pickString(query[`${field}[lte]`]) ?? pickString(query[`${field}[lt]`]);
531
+ if (!gte || !lte) return null;
532
+ const lower = new Date(gte);
533
+ const upper = new Date(lte);
534
+ if (Number.isNaN(lower.getTime()) || Number.isNaN(upper.getTime())) return null;
535
+ if (upper <= lower) return null;
536
+ return {
537
+ lower,
538
+ upper
539
+ };
540
+ }
541
+ /**
542
+ * Strip control params (page/limit/sort/select/...) and the resource-
543
+ * dispatch verbs from the query, leaving only filter predicates the
544
+ * caller used to narrow the aggregation.
545
+ *
546
+ * The resulting record is shallow-merged into the AggRequest filter
547
+ * via `compileAggRequest`. Bracket-syntax keys (`createdAt[gte]`) are
548
+ * preserved — the kit's filter compiler handles them.
549
+ */
550
+ function extractCallerFilter(query) {
551
+ const out = {};
552
+ const reserved = new Set([
553
+ "page",
554
+ "limit",
555
+ "after",
556
+ "sort",
557
+ "select",
558
+ "populate",
559
+ "search",
560
+ "_count",
561
+ "_distinct",
562
+ "_exists"
563
+ ]);
564
+ for (const [key, value] of Object.entries(query)) {
565
+ if (reserved.has(key)) continue;
566
+ if (value === void 0 || value === "") continue;
567
+ out[key] = value;
568
+ }
569
+ return out;
570
+ }
571
+ /**
572
+ * Map a kit-thrown error to the framework-agnostic execute response.
573
+ * Detects two well-known signals:
574
+ * - "unsupported" / "not implemented" → 501 with upgrade hint
575
+ * - timeout markers → 504
576
+ * - everything else → 500
577
+ */
578
+ function mapAggregateError(err, aggregationName) {
579
+ const message = err instanceof Error ? err.message : String(err);
580
+ const lower = message.toLowerCase();
581
+ if (lower.includes("unsupported") || lower.includes("not implemented")) return {
582
+ status: 501,
583
+ body: {
584
+ code: "arc.adapter.capability_required",
585
+ message: `Aggregation "${aggregationName}" failed: ${message}. The kit may not yet support this feature (e.g. lookups in aggregate). Upgrade the kit or remove the unsupported field.`,
586
+ status: 501,
587
+ meta: { aggregation: aggregationName }
588
+ }
589
+ };
590
+ if (lower.includes("maxtimems") || lower.includes("timeout") || lower.includes("timed out")) return {
591
+ status: 504,
592
+ body: {
593
+ code: "arc.gateway_timeout",
594
+ message: `Aggregation "${aggregationName}" timed out: ${message}. Narrow the filter or raise the timeout.`,
595
+ status: 504,
596
+ meta: { aggregation: aggregationName }
597
+ }
598
+ };
599
+ return {
600
+ status: 500,
601
+ body: {
602
+ code: "arc.internal_error",
603
+ message: `Aggregation "${aggregationName}" failed: ${message}`,
604
+ status: 500,
605
+ meta: { aggregation: aggregationName }
606
+ }
607
+ };
608
+ }
609
+ //#endregion
610
+ export { executeAggregation as n, validateAggregations as r, buildAggregationHandler as t };
@@ -1,6 +1,6 @@
1
- import { i as versionKey, n as hashParams, r as tagVersionKey, t as buildQueryKey } from "../keys-CARyUjiR.mjs";
2
- import { t as MemoryCacheStore } from "../memory-DikHSvWa.mjs";
3
- import { r as QueryCache, t as queryCachePlugin } from "../queryCachePlugin-Bq6bO6vc.mjs";
1
+ import { t as MemoryCacheStore } from "../memory-UBydS5ku.mjs";
2
+ import { i as versionKey, n as hashParams, r as tagVersionKey, t as buildQueryKey } from "../keys-CGcCbNyu.mjs";
3
+ import { r as QueryCache, t as queryCachePlugin } from "../queryCachePlugin-m1XsgAIJ.mjs";
4
4
  //#region src/cache/redis.ts
5
5
  /**
6
6
  * Redis-backed cache store.