@classytic/arc 2.22.0 → 2.25.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 (147) hide show
  1. package/dist/{BaseController-C6FlRWxF.mjs → BaseController--M8EMzt0.mjs} +92 -22
  2. package/dist/QueryCache-DOMT_iq7.d.mts +72 -0
  3. package/dist/{ResourceRegistry-7RnrEWNP.mjs → ResourceRegistry-y9Hnypus.mjs} +1 -0
  4. package/dist/actionPermissions-CAphcmUG.mjs +24 -0
  5. package/dist/applyPermissionResult-Tw4iXtlf.mjs +264 -0
  6. package/dist/audit/index.d.mts +32 -3
  7. package/dist/audit/index.mjs +7 -4
  8. package/dist/auth/index.d.mts +2 -2
  9. package/dist/auth/index.mjs +264 -260
  10. package/dist/{buildHandler-D_2ULWxZ.mjs → buildHandler-Dtwd8fdz.mjs} +1 -1
  11. package/dist/cache/index.d.mts +41 -3
  12. package/dist/cache/index.mjs +33 -4
  13. package/dist/cli/commands/describe.d.mts +1 -1
  14. package/dist/cli/commands/describe.mjs +1 -1
  15. package/dist/cli/commands/docs.mjs +2 -2
  16. package/dist/cli/commands/generate.mjs +1 -1
  17. package/dist/cli/commands/init.mjs +1 -1
  18. package/dist/cli/commands/introspect.mjs +1 -1
  19. package/dist/cli/index.mjs +2 -2
  20. package/dist/context/index.mjs +2 -64
  21. package/dist/core/index.d.mts +3 -3
  22. package/dist/core/index.mjs +4 -4
  23. package/dist/{core-b6fgFgnd.mjs → core-EUNu0u07.mjs} +298 -12
  24. package/dist/{createActionRouter-h1cEEaEX.mjs → createActionRouter-B6GvAuAX.mjs} +4 -3
  25. package/dist/{createAggregationRouter-B1C0kvTL.mjs → createAggregationRouter-wzwFa_dW.mjs} +3 -3
  26. package/dist/docs/index.d.mts +1 -1
  27. package/dist/docs/index.mjs +2 -2
  28. package/dist/{elevation-COpYKt2h.mjs → elevation-CIz1GjGm.mjs} +4 -3
  29. package/dist/{errorHandler-BTzXSwIg.mjs → errorHandler-Bq2OeEQL.mjs} +10 -7
  30. package/dist/{eventPlugin-A6EbthXR.d.mts → eventPlugin-Bg3cP2_u.d.mts} +38 -3
  31. package/dist/{eventPlugin-DJG2D7sy.mjs → eventPlugin-DrQ_FesK.mjs} +22 -4
  32. package/dist/events/index.d.mts +57 -314
  33. package/dist/events/index.mjs +168 -93
  34. package/dist/events/transports/redis-stream-entry.d.mts +1 -1
  35. package/dist/events/transports/redis-stream-entry.mjs +24 -2
  36. package/dist/factory/index.d.mts +21 -6
  37. package/dist/factory/index.mjs +58 -18
  38. package/dist/{fastify-augmentation-9_fuetB4.d.mts → fastify-augmentation-dPNBVeIj.d.mts} +25 -2
  39. package/dist/{generate-usRSdQmP.mjs → generate-D0wZupBg.mjs} +1 -1
  40. package/dist/headers-BvlQSDMB.mjs +45 -0
  41. package/dist/health-CB_m4S32.mjs +377 -0
  42. package/dist/hooks/index.d.mts +1 -1
  43. package/dist/idempotency/index.d.mts +7 -0
  44. package/dist/idempotency/index.mjs +32 -20
  45. package/dist/{index-ogIHvOjo.d.mts → index-BMnbzvtO.d.mts} +70 -4
  46. package/dist/{integrations/jobs.d.mts → index-CG4yqMDB.d.mts} +82 -2
  47. package/dist/{index-DbtgCOCj.d.mts → index-DPRH0Cme.d.mts} +46 -94
  48. package/dist/{module-B4u10iOJ.d.mts → index-KUoxjIq1.d.mts} +263 -23
  49. package/dist/{index-D8MKYGDa.d.mts → index-f4PTrk3B.d.mts} +37 -5
  50. package/dist/index.d.mts +6 -6
  51. package/dist/index.mjs +6 -5
  52. package/dist/{init-DleK3p9f.mjs → init-tlRSt667.mjs} +206 -205
  53. package/dist/integrations/event-gateway.d.mts +1 -1
  54. package/dist/integrations/event-gateway.mjs +1 -1
  55. package/dist/integrations/index.d.mts +3 -3
  56. package/dist/integrations/jobs/index.d.mts +2 -0
  57. package/dist/integrations/{jobs.mjs → jobs/index.mjs} +110 -67
  58. package/dist/integrations/mcp/index.d.mts +68 -5
  59. package/dist/integrations/mcp/index.mjs +2 -2
  60. package/dist/integrations/mcp/testing.d.mts +1 -1
  61. package/dist/integrations/mcp/testing.mjs +1 -1
  62. package/dist/integrations/webhooks.d.mts +149 -22
  63. package/dist/integrations/webhooks.mjs +186 -42
  64. package/dist/integrations/websocket-redis.d.mts +1 -1
  65. package/dist/integrations/websocket.d.mts +1 -1
  66. package/dist/integrations/websocket.mjs +41 -14
  67. package/dist/{interface-C1_WLTMK.d.mts → interface-BGZCzOGL.d.mts} +24 -31
  68. package/dist/interface-n5hpKg1d.mjs +16 -0
  69. package/dist/{loadResourcesFromEntry-OMCvIEE7.mjs → loadResourcesFromEntry-kvGT6EcO.mjs} +1 -1
  70. package/dist/{mcpPlugin-ByA2tf6D.mjs → mcpPlugin-Cb7olw0j.mjs} +316 -45
  71. package/dist/{memory-CLXA2VeJ.mjs → memory-CmnzXE5Z.mjs} +29 -0
  72. package/dist/middleware/index.d.mts +17 -1
  73. package/dist/middleware/index.mjs +1 -1
  74. package/dist/migrations/index.d.mts +94 -44
  75. package/dist/migrations/index.mjs +196 -44
  76. package/dist/{multipartBody-CHEQSZ6G.mjs → multipartBody-D0eBvkeq.mjs} +17 -0
  77. package/dist/{openapi-BRpMZD7C.mjs → openapi-DrLtog_B.mjs} +4 -4
  78. package/dist/permissions/index.d.mts +3 -3
  79. package/dist/permissions/index.mjs +4 -3
  80. package/dist/{permissions-BJMuKMl7.mjs → permissions-BrtiBKGs.mjs} +305 -492
  81. package/dist/pipeline/index.d.mts +1 -1
  82. package/dist/{actionPermissions-ze70xXJi.mjs → pipeline-BN5G05rd.mjs} +4 -27
  83. package/dist/plugins/index.d.mts +7 -78
  84. package/dist/plugins/index.mjs +14 -97
  85. package/dist/plugins/response-cache.mjs +1 -0
  86. package/dist/plugins/tracing-entry.mjs +1 -1
  87. package/dist/{index-BEu4wjbL.d.mts → preloadResources-0oe-JSSS.d.mts} +59 -50
  88. package/dist/{createApp-Ci7yG5gv.mjs → preloadResources-DfyngIN9.mjs} +240 -68
  89. package/dist/presets/filesUpload.d.mts +3 -3
  90. package/dist/presets/filesUpload.mjs +3 -3
  91. package/dist/presets/index.d.mts +2 -1
  92. package/dist/presets/index.mjs +1 -1
  93. package/dist/presets/multiTenant.d.mts +1 -1
  94. package/dist/presets/multiTenant.mjs +1 -1
  95. package/dist/presets/search.d.mts +2 -2
  96. package/dist/presets/search.mjs +1 -1
  97. package/dist/{presets-gw0ciIUD.mjs → presets-DwFDVKP0.mjs} +2 -2
  98. package/dist/{queryCachePlugin-DzqLN904.mjs → queryCachePlugin-DSGuSVLW.mjs} +39 -8
  99. package/dist/{queryCachePlugin-CfYWq-Ak.d.mts → queryCachePlugin-br4otiqF.d.mts} +2 -2
  100. package/dist/{registerRoute-xHc42fwB.mjs → rateLimit-DrED-C4v.mjs} +2 -109
  101. package/dist/realtime-Cuw_deA-.mjs +375 -0
  102. package/dist/{redis-stream-iVR2l7aS.d.mts → redis-stream-uIaauNpZ.d.mts} +22 -0
  103. package/dist/registerRoute-v0Wb6Plt.mjs +108 -0
  104. package/dist/registry/index.d.mts +2 -2
  105. package/dist/registry/index.mjs +1 -1
  106. package/dist/renewingLease-3cuQH7h8.mjs +44 -0
  107. package/dist/{requestContext-BZKOr13H.mjs → requestContext-MrlJwECf.mjs} +2 -2
  108. package/dist/schedules-CjOd2MG9.mjs +113 -0
  109. package/dist/{schemaConverter-Ds5reSGs.mjs → schemaConverter-DJvodiHj.mjs} +4 -1
  110. package/dist/{schemaIR-B-6SPe7g.mjs → schemaIR-Ca7ySNPx.mjs} +1 -1
  111. package/dist/schemas/index.d.mts +67 -67
  112. package/dist/schemas/index.mjs +2 -3
  113. package/dist/scim/index.mjs +1 -2
  114. package/dist/scope/index.d.mts +2 -2
  115. package/dist/scope/index.mjs +4 -4
  116. package/dist/scope-DfaPntpo.mjs +36 -0
  117. package/dist/simpleEqualityMatcher-Cg05HPsn.mjs +68 -0
  118. package/dist/{sse-CfUjwjDk.mjs → sse-BQ1w0dfl.mjs} +8 -31
  119. package/dist/sseStream-BQ7Qaynp.mjs +57 -0
  120. package/dist/testing/index.d.mts +4 -3
  121. package/dist/testing/index.mjs +2 -2
  122. package/dist/transactionContext-PNdoyLLm.mjs +64 -0
  123. package/dist/types/index.d.mts +4 -4
  124. package/dist/{types-bHblZ9YN.mjs → types-CSyCxl2V.mjs} +38 -7
  125. package/dist/{types-DH0GupI7.d.mts → types-DjC-KUCz.d.mts} +118 -20
  126. package/dist/{types-Csxga7d3.d.mts → types-Dmw-6Qeo.d.mts} +96 -4
  127. package/dist/{types-Bc42CUyk.d.mts → types-QFGc3Cjv.d.mts} +37 -2
  128. package/dist/usage/index.d.mts +3 -52
  129. package/dist/usage/index.mjs +0 -0
  130. package/dist/userHelpers-Cg_y3fBz.mjs +29 -0
  131. package/dist/utils/index.d.mts +90 -57
  132. package/dist/utils/index.mjs +7 -3
  133. package/dist/{utils-FhJ88TJE.mjs → utils-taUVD4p3.mjs} +47 -385
  134. package/dist/{validate-C2mGgSXg.mjs → validate-Bk44F2yT.mjs} +1 -1
  135. package/dist/{versioning-DLwklPlG.d.mts → versioning-DXgY5BUm.d.mts} +52 -31
  136. package/dist/{versioning-Dk-71iIz.mjs → versioning-gKWyryT3.mjs} +8 -1
  137. package/dist/{websocket-uuWqM6UY.d.mts → websocket-_Pd0CgR0.d.mts} +15 -1
  138. package/package.json +526 -453
  139. package/skills/arc/references/api-reference.md +3 -1
  140. package/skills/arc/references/integrations.md +20 -11
  141. package/skills/arc/references/production.md +734 -696
  142. package/dist/QueryCache-Z1B38koh.d.mts +0 -40
  143. package/dist/betterAuthOpenApi-CV1lxpRX.mjs +0 -2
  144. package/dist/health-ScyjIVo5.mjs +0 -202
  145. package/dist/requestId-5BhTVzBv.mjs +0 -113
  146. package/dist/scope-k15RXfcq.mjs +0 -64
  147. package/dist/types-XTApa8b6.mjs +0 -24
@@ -1,358 +1,8 @@
1
- import { m as RESERVED_QUERY_PARAMS, t as CRUD_OPERATIONS } from "./constants-DNre0wRg.mjs";
1
+ import { m as RESERVED_QUERY_PARAMS } from "./constants-DNre0wRg.mjs";
2
2
  import { arcLog } from "./logger/index.mjs";
3
3
  import { t as ArcError } from "./errors-DkO8Nk6-.mjs";
4
- import { r as getAvailablePresets } from "./presets-gw0ciIUD.mjs";
5
4
  import { errorContractSchema as errorContractSchema$1, errorDetailSchema as errorDetailSchema$1 } from "@classytic/repo-core/errors";
6
- //#region src/utils/simpleEqualityMatcher.ts
7
- /**
8
- * `simpleEqualityMatcher` — a minimal, dialect-agnostic flat-key equality
9
- * matcher for `DataAdapter.matchesFilter` / `BaseController({ matchesFilter })`.
10
- *
11
- * **What it does:** for each `[key, expected]` in the filter, compares
12
- * `item[key]` to `expected` via string coercion (so Mongo `ObjectId` values
13
- * match their string representation) and returns `true` only if every
14
- * filter entry matches. Array item values are matched implicitly (contains).
15
- *
16
- * **What it does NOT do:**
17
- * - No `$eq` / `$ne` / `$in` / `$nin` / `$gt` / `$lt` / `$regex` / `$exists`
18
- * - No `$and` / `$or`
19
- * - No dot-path traversal (`"owner.id"`)
20
- * - No schema-specific coercion
21
- *
22
- * **Why it exists:** 95%+ of arc's `_policyFilters` are produced by built-in
23
- * permission helpers and are shaped like `{ ownerId: "u1" }` or
24
- * `{ organizationId: "org_x" }` — flat equality. For that common shape,
25
- * this helper is a safe, tested, 15-line defense-in-depth matcher that
26
- * hosts using minimal repos (no `getOne(compoundFilter)` DB path) can opt
27
- * into without arc shipping a full Mongo-syntax engine.
28
- *
29
- * **When to use:**
30
- * - Your adapter/repo doesn't natively filter on `getOne(compoundFilter)`
31
- * - Your `_policyFilters` are flat equality (from arc's built-in permission helpers)
32
- * - You want defense-in-depth on `validateItemAccess` / `fetchDetailed`'s `getById` fallback
33
- *
34
- * **When NOT to use:**
35
- * - Your `_policyFilters` use operators (`$in`, `$ne`, etc.) — supply a
36
- * native matcher (mongokit's repo does the filter at the DB layer; for
37
- * custom repos, wrap the kit's own predicate engine).
38
- * - You're a mongokit / sqlitekit / Prisma user — the DB-level filter
39
- * applied by `getOne(compoundFilter)` already covers this.
40
- *
41
- * @example
42
- * ```ts
43
- * import { simpleEqualityMatcher } from '@classytic/arc/utils';
44
- *
45
- * // On a custom adapter
46
- * const adapter: DataAdapter = {
47
- * repository,
48
- * type: 'custom',
49
- * name: 'in-memory',
50
- * matchesFilter: simpleEqualityMatcher,
51
- * };
52
- *
53
- * // Or directly on BaseController for ad-hoc controllers
54
- * new BaseController(repo, { matchesFilter: simpleEqualityMatcher });
55
- * ```
56
- */
57
- function simpleEqualityMatcher(item, filters) {
58
- if (!item || typeof item !== "object") return false;
59
- const obj = item;
60
- for (const [key, expected] of Object.entries(filters)) {
61
- if (expected && typeof expected === "object" && !Array.isArray(expected) && Object.getPrototypeOf(expected) === Object.prototype && Object.keys(expected).some((k) => k.startsWith("$"))) return false;
62
- const actual = obj[key];
63
- if (Array.isArray(actual)) {
64
- const expectedStr = String(expected);
65
- if (!actual.some((v) => String(v) === expectedStr)) return false;
66
- continue;
67
- }
68
- if (String(actual) !== String(expected)) return false;
69
- }
70
- return true;
71
- }
72
- //#endregion
73
- //#region src/core/validateResourceConfig.ts
74
- /**
75
- * Resource Configuration Validator
76
- *
77
- * Fail-fast validation at definition time.
78
- * Invalid configs throw immediately with clear, actionable errors.
79
- *
80
- * @example
81
- * const result = validateResourceConfig(config);
82
- * if (!result.valid) {
83
- * console.error(formatValidationErrors(result.errors));
84
- * }
85
- */
86
- /**
87
- * Validate a resource configuration
88
- */
89
- function validateResourceConfig(config, options = {}) {
90
- const errors = [];
91
- const warnings = [];
92
- if (!config.name) errors.push({
93
- field: "name",
94
- message: "Resource name is required",
95
- suggestion: "Add a unique resource name (e.g., \"product\", \"user\")"
96
- });
97
- else if (!/^[a-z][a-z0-9-]*$/i.test(config.name)) errors.push({
98
- field: "name",
99
- message: `Invalid resource name "${config.name}"`,
100
- suggestion: "Use alphanumeric characters and hyphens, starting with a letter"
101
- });
102
- const crudRoutes = CRUD_OPERATIONS;
103
- const disabledRoutes = new Set(config.disabledRoutes ?? []);
104
- const enabledCrudRoutes = crudRoutes.filter((route) => !disabledRoutes.has(route));
105
- if (!config.disableDefaultRoutes && enabledCrudRoutes.length > 0) {
106
- if (!config.adapter) errors.push({
107
- field: "adapter",
108
- message: "Data adapter is required when CRUD routes are enabled",
109
- suggestion: "Provide an adapter: createMongooseAdapter({ model, repository })"
110
- });
111
- else if (!config.adapter.repository) errors.push({
112
- field: "adapter.repository",
113
- message: "Adapter must provide a repository",
114
- suggestion: "Ensure your adapter returns a valid StandardRepo (see @classytic/repo-core)"
115
- });
116
- } else if (!config.adapter && !config.routes?.length) warnings.push({
117
- field: "config",
118
- message: "Resource has no adapter and no routes",
119
- suggestion: "Provide either adapter for CRUD or routes for custom logic"
120
- });
121
- if (config.controller && !options.skipControllerCheck && !config.disableDefaultRoutes) {
122
- const ctrl = config.controller;
123
- const requiredMethods = CRUD_OPERATIONS;
124
- for (const method of requiredMethods) if (typeof ctrl[method] !== "function") errors.push({
125
- field: `controller.${method}`,
126
- message: `Missing required CRUD method "${method}"`,
127
- suggestion: "Extend BaseController which implements IController interface"
128
- });
129
- }
130
- if (config.controller && config.routes) validateRouteHandlers(config.controller, config.routes, errors);
131
- if (config.permissions) validatePermissionKeys(config, options, errors, warnings);
132
- if (config.presets && !options.allowUnknownPresets) validatePresets(config.presets, errors, warnings);
133
- if (config.prefix) {
134
- if (!config.prefix.startsWith("/")) errors.push({
135
- field: "prefix",
136
- message: `Prefix must start with "/" (got "${config.prefix}")`,
137
- suggestion: `Change to "/${config.prefix}"`
138
- });
139
- if (config.prefix.endsWith("/") && config.prefix !== "/") warnings.push({
140
- field: "prefix",
141
- message: `Prefix should not end with "/" (got "${config.prefix}")`,
142
- suggestion: `Change to "${config.prefix.slice(0, -1)}"`
143
- });
144
- }
145
- if (config.routes) {
146
- validateRoutes(config.routes, errors);
147
- validateRouteCrudCollisions(config, errors);
148
- }
149
- return {
150
- valid: errors.length === 0,
151
- errors,
152
- warnings
153
- };
154
- }
155
- function validateRouteHandlers(controller, routes, errors) {
156
- const ctrl = controller;
157
- for (const route of routes) if (typeof route.handler === "string") {
158
- if (typeof ctrl[route.handler] !== "function") errors.push({
159
- field: `routes[${route.method} ${route.path}]`,
160
- message: `Handler "${route.handler}" not found on controller`,
161
- suggestion: `Add method "${route.handler}" to controller or use a function handler`
162
- });
163
- }
164
- }
165
- function validatePermissionKeys(config, options, _errors, warnings) {
166
- const validKeys = /* @__PURE__ */ new Set([...CRUD_OPERATIONS, ...options.additionalPermissionKeys ?? []]);
167
- for (const route of config.routes ?? []) if (typeof route.handler === "string") validKeys.add(route.handler);
168
- for (const preset of config.presets ?? []) {
169
- const presetName = typeof preset === "string" ? preset : preset.name;
170
- if (presetName === "softDelete") {
171
- validKeys.add("deleted");
172
- validKeys.add("restore");
173
- }
174
- if (presetName === "slugLookup") validKeys.add("getBySlug");
175
- if (presetName === "tree") {
176
- validKeys.add("tree");
177
- validKeys.add("children");
178
- validKeys.add("getTree");
179
- validKeys.add("getChildren");
180
- }
181
- }
182
- for (const key of Object.keys(config.permissions ?? {})) if (!validKeys.has(key)) warnings.push({
183
- field: `permissions.${key}`,
184
- message: `Unknown permission key "${key}"`,
185
- suggestion: `Valid keys: ${Array.from(validKeys).join(", ")}`
186
- });
187
- }
188
- function validatePresets(presets, errors, warnings) {
189
- const availablePresets = getAvailablePresets();
190
- for (const preset of presets) {
191
- if (typeof preset === "object" && ("middlewares" in preset || "routes" in preset)) continue;
192
- const presetName = typeof preset === "string" ? preset : preset.name;
193
- if (!availablePresets.includes(presetName)) errors.push({
194
- field: "presets",
195
- message: `Unknown preset "${presetName}"`,
196
- suggestion: `Available presets: ${availablePresets.join(", ")}`
197
- });
198
- if (typeof preset === "object") validatePresetOptions(preset, warnings);
199
- }
200
- }
201
- function validatePresetOptions(preset, warnings) {
202
- const validOptions = {
203
- slugLookup: ["slugField"],
204
- tree: ["parentField"],
205
- softDelete: ["deletedField"],
206
- ownedByUser: ["ownerField"],
207
- multiTenant: ["tenantField", "allowPublic"]
208
- }[preset.name] ?? [];
209
- const providedOptions = Object.keys(preset).filter((k) => k !== "name");
210
- for (const opt of providedOptions) if (!validOptions.includes(opt)) warnings.push({
211
- field: `presets[${preset.name}].${opt}`,
212
- message: `Unknown option "${opt}" for preset "${preset.name}"`,
213
- suggestion: validOptions.length > 0 ? `Valid options: ${validOptions.join(", ")}` : `Preset "${preset.name}" has no configurable options`
214
- });
215
- }
216
- /**
217
- * Detect collisions between user-declared `routes:` entries and the
218
- * auto-CRUD route table that `createCrudRouter` will register.
219
- *
220
- * Without this check, a custom `POST /` on the same prefix as auto-CRUD's
221
- * `create` boots fine through `defineResource()` and only blows up at
222
- * `app.register()` time with Fastify's opaque `FST_ERR_DUPLICATED_ROUTE`.
223
- * That error doesn't mention `disabledRoutes` and doesn't distinguish
224
- * "you collided with auto-CRUD" from "you collided with yourself" — so
225
- * the consumer has to grep arc's source to discover the fix.
226
- *
227
- * We surface the fix in the error message, naming the conflicting op
228
- * and the literal line to add.
229
- *
230
- * Honored config:
231
- * - `disableDefaultRoutes: true` → no auto-CRUD, no collisions possible
232
- * - `disabledRoutes: ['create']` → that op's path is free for custom use
233
- * - `updateMethod: 'PUT' | 'both'` → update slot covers PUT/PATCH accordingly
234
- */
235
- function validateRouteCrudCollisions(config, errors) {
236
- if (config.disableDefaultRoutes) return;
237
- if (!config.routes || config.routes.length === 0) return;
238
- const updateMethod = config.updateMethod ?? "PATCH";
239
- const updateMethods = updateMethod === "both" ? ["PUT", "PATCH"] : [updateMethod];
240
- const disabled = new Set(config.disabledRoutes ?? []);
241
- const crudTable = [
242
- {
243
- op: "list",
244
- method: "GET",
245
- path: "/"
246
- },
247
- {
248
- op: "get",
249
- method: "GET",
250
- path: "/:id"
251
- },
252
- {
253
- op: "create",
254
- method: "POST",
255
- path: "/"
256
- },
257
- ...updateMethods.map((m) => ({
258
- op: "update",
259
- method: m,
260
- path: "/:id"
261
- })),
262
- {
263
- op: "delete",
264
- method: "DELETE",
265
- path: "/:id"
266
- }
267
- ];
268
- for (const [i, route] of config.routes.entries()) {
269
- if (!route.method || !route.path) continue;
270
- for (const crud of crudTable) {
271
- if (disabled.has(crud.op)) continue;
272
- if (route.method !== crud.method || route.path !== crud.path) continue;
273
- errors.push({
274
- field: `routes[${i}]`,
275
- message: `Route ${route.method} ${route.path} collides with auto-CRUD "${crud.op}"`,
276
- suggestion: `Add \`disabledRoutes: ['${crud.op}']\` to suppress the auto-CRUD route, or move your custom handler to a different path. Set \`disableDefaultRoutes: true\` to disable all auto-CRUD on this resource.`
277
- });
278
- }
279
- }
280
- }
281
- function validateRoutes(routes, errors) {
282
- const validMethods = [
283
- "GET",
284
- "POST",
285
- "PUT",
286
- "PATCH",
287
- "DELETE",
288
- "OPTIONS",
289
- "HEAD"
290
- ];
291
- const seenRoutes = /* @__PURE__ */ new Set();
292
- for (const [i, route] of routes.entries()) {
293
- if (!validMethods.includes(route.method)) errors.push({
294
- field: `routes[${i}].method`,
295
- message: `Invalid HTTP method "${route.method}"`,
296
- suggestion: `Valid methods: ${validMethods.join(", ")}`
297
- });
298
- if (!route.path) errors.push({
299
- field: `routes[${i}].path`,
300
- message: "Route path is required"
301
- });
302
- else if (!route.path.startsWith("/")) errors.push({
303
- field: `routes[${i}].path`,
304
- message: `Route path must start with "/" (got "${route.path}")`,
305
- suggestion: `Change to "/${route.path}"`
306
- });
307
- const routeWithRefs = route;
308
- if (!route.handler && typeof routeWithRefs.controllerMethod !== "function") errors.push({
309
- field: `routes[${i}].handler`,
310
- message: "Route must declare either `handler` (string / function) or `controllerMethod`",
311
- suggestion: "Prefer `controllerMethod: (c: MyController) => c.method` for typed handler refs."
312
- });
313
- const routeKey = `${route.method} ${route.path}`;
314
- if (seenRoutes.has(routeKey)) errors.push({
315
- field: `routes[${i}]`,
316
- message: `Duplicate route "${routeKey}"`
317
- });
318
- seenRoutes.add(routeKey);
319
- }
320
- }
321
- /**
322
- * Format validation errors for display
323
- */
324
- function formatValidationErrors(resourceName, result) {
325
- const lines = [];
326
- if (result.errors.length > 0) {
327
- lines.push(`Resource "${resourceName}" validation failed:`);
328
- lines.push("");
329
- lines.push("ERRORS:");
330
- for (const err of result.errors) {
331
- lines.push(` ✗ ${err.field}: ${err.message}`);
332
- if (err.suggestion) lines.push(` → ${err.suggestion}`);
333
- }
334
- }
335
- if (result.warnings.length > 0) {
336
- if (lines.length > 0) lines.push("");
337
- lines.push("WARNINGS:");
338
- for (const warn of result.warnings) {
339
- lines.push(` ⚠ ${warn.field}: ${warn.message}`);
340
- if (warn.suggestion) lines.push(` → ${warn.suggestion}`);
341
- }
342
- }
343
- return lines.join("\n");
344
- }
345
- /**
346
- * Validate and throw if invalid
347
- */
348
- function assertValidConfig(config, options) {
349
- const result = validateResourceConfig(config, options);
350
- if (!result.valid) {
351
- const errorMsg = formatValidationErrors(config.name ?? "unknown", result);
352
- throw new Error(errorMsg);
353
- }
354
- }
355
- //#endregion
5
+ import { scheduleBackground } from "@classytic/repo-core/cache";
356
6
  //#region src/utils/circuitBreaker.ts
357
7
  /**
358
8
  * Circuit Breaker Pattern
@@ -404,10 +54,13 @@ var CircuitBreaker = class {
404
54
  nextAttempt = 0;
405
55
  lastCallAt = null;
406
56
  openedAt = null;
57
+ halfOpenInFlight = 0;
407
58
  failureThreshold;
408
59
  resetTimeout;
409
60
  timeout;
410
61
  successThreshold;
62
+ halfOpenMaxProbes;
63
+ propagateAbort;
411
64
  fallback;
412
65
  onStateChange;
413
66
  onError;
@@ -419,6 +72,8 @@ var CircuitBreaker = class {
419
72
  this.resetTimeout = options.resetTimeout ?? 6e4;
420
73
  this.timeout = options.timeout ?? 1e4;
421
74
  this.successThreshold = options.successThreshold ?? 1;
75
+ this.halfOpenMaxProbes = options.halfOpenMaxProbes ?? 1;
76
+ this.propagateAbort = options.propagateAbort ?? false;
422
77
  this.fallback = options.fallback;
423
78
  this.onStateChange = options.onStateChange;
424
79
  this.onError = options.onError;
@@ -438,6 +93,15 @@ var CircuitBreaker = class {
438
93
  }
439
94
  this.setState(CircuitState.HALF_OPEN);
440
95
  }
96
+ let isProbe = false;
97
+ if (this.state === CircuitState.HALF_OPEN) {
98
+ if (this.halfOpenInFlight >= this.halfOpenMaxProbes) {
99
+ if (this.fallback) return this.fallback(...args);
100
+ throw new CircuitBreakerError(`Circuit breaker is HALF_OPEN for ${this.name} — probe slots full`, CircuitState.HALF_OPEN);
101
+ }
102
+ this.halfOpenInFlight++;
103
+ isProbe = true;
104
+ }
441
105
  try {
442
106
  const result = await this.executeWithTimeout(args);
443
107
  this.onSuccess();
@@ -445,17 +109,24 @@ var CircuitBreaker = class {
445
109
  } catch (err) {
446
110
  this.onFailure(err instanceof Error ? err : new Error(String(err)));
447
111
  throw err;
112
+ } finally {
113
+ if (isProbe) this.halfOpenInFlight--;
448
114
  }
449
115
  }
450
116
  /**
451
- * Execute function with timeout
117
+ * Execute function with timeout. With `propagateAbort`, the wrapped fn
118
+ * receives a trailing AbortSignal that fires on timeout — the breaker's
119
+ * timeout otherwise only rejects the CALLER while the underlying work
120
+ * keeps consuming sockets/pool slots to completion.
452
121
  */
453
122
  async executeWithTimeout(args) {
123
+ const controller = this.propagateAbort ? new AbortController() : void 0;
454
124
  return new Promise((resolve, reject) => {
455
125
  const timeoutId = setTimeout(() => {
126
+ controller?.abort(/* @__PURE__ */ new Error(`Request timeout after ${this.timeout}ms`));
456
127
  reject(/* @__PURE__ */ new Error(`Request timeout after ${this.timeout}ms`));
457
128
  }, this.timeout);
458
- this.fn(...args).then((result) => {
129
+ (controller ? this.fn(...args, controller.signal) : this.fn(...args)).then((result) => {
459
130
  clearTimeout(timeoutId);
460
131
  resolve(result);
461
132
  }).catch((error) => {
@@ -638,6 +309,21 @@ function createCircuitBreakerRegistry() {
638
309
  //#endregion
639
310
  //#region src/utils/compensation.ts
640
311
  /**
312
+ * Observability hooks must never alter the transaction: a throwing metrics
313
+ * sink must not fail a step that succeeded, trigger compensation, or become
314
+ * an unhandled rejection from a detached fire-and-forget chain. Containment
315
+ * is reported via `hooks.onHookError` so broken sinks stay visible.
316
+ */
317
+ function safeHook(name, invoke, hooks) {
318
+ try {
319
+ invoke();
320
+ } catch (err) {
321
+ try {
322
+ hooks?.onHookError?.(name, err instanceof Error ? err : new Error(String(err)));
323
+ } catch {}
324
+ }
325
+ }
326
+ /**
641
327
  * Run steps in order with automatic compensation on failure.
642
328
  *
643
329
  * @typeParam TCtx - Context type shared across steps (defaults to Record<string, unknown>)
@@ -649,8 +335,7 @@ async function withCompensation(_name, steps, initialContext, hooks) {
649
335
  const completed = [];
650
336
  for (const step of steps) {
651
337
  if (step.fireAndForget) {
652
- completedSteps.push(step.name);
653
- step.execute(ctx).then((result) => hooks?.onStepComplete?.(step.name, result), () => {});
338
+ step.execute(ctx).then((result) => safeHook("onStepComplete", () => hooks?.onStepComplete?.(step.name, result), hooks), (err) => safeHook("onStepFailed", () => hooks?.onStepFailed?.(step.name, err instanceof Error ? err : new Error(String(err))), hooks)).catch(() => {});
654
339
  continue;
655
340
  }
656
341
  try {
@@ -661,10 +346,10 @@ async function withCompensation(_name, steps, initialContext, hooks) {
661
346
  step,
662
347
  result
663
348
  });
664
- hooks?.onStepComplete?.(step.name, result);
349
+ safeHook("onStepComplete", () => hooks?.onStepComplete?.(step.name, result), hooks);
665
350
  } catch (err) {
666
351
  const error = err instanceof Error ? err : new Error(String(err));
667
- hooks?.onStepFailed?.(step.name, error);
352
+ safeHook("onStepFailed", () => hooks?.onStepFailed?.(step.name, error), hooks);
668
353
  const compensationErrors = await rollback(ctx, completed, hooks);
669
354
  return {
670
355
  success: false,
@@ -690,7 +375,7 @@ async function rollback(ctx, completed, hooks) {
690
375
  const compensateFn = entry.step.compensate;
691
376
  try {
692
377
  await compensateFn(ctx, entry.result);
693
- hooks?.onCompensate?.(entry.step.name);
378
+ safeHook("onCompensate", () => hooks?.onCompensate?.(entry.step.name), hooks);
694
379
  } catch (err) {
695
380
  errors.push({
696
381
  step: entry.step.name,
@@ -850,6 +535,8 @@ function handleRaw(handler, statusCode = 200) {
850
535
  * defineResource({ name: 'product', adapter: ... });
851
536
  *
852
537
  * // Use MongoKit's QueryParser (recommended for MongoDB - has $lookup, aggregations, etc.)
538
+ * // Fail-closed by default (mongokit >=3.25): invalid/blocked query input
539
+ * // becomes HTTP 400 instead of a silently broadened result set.
853
540
  * import { QueryParser } from '@classytic/mongokit';
854
541
  * defineResource({
855
542
  * name: 'product',
@@ -1652,13 +1339,6 @@ function getDefaultCrudSchemas() {
1652
1339
  });
1653
1340
  }
1654
1341
  //#endregion
1655
- //#region src/utils/runtime.ts
1656
- /**
1657
- * Portable "run on next tick" scheduler. `setImmediate` is Node-only — not
1658
- * available in Bun workers, Deno, Cloudflare Workers, or edge runtimes.
1659
- */
1660
- const scheduleBackground = typeof setImmediate === "function" ? (cb) => void setImmediate(cb) : (cb) => queueMicrotask(cb);
1661
- //#endregion
1662
1342
  //#region src/utils/stateMachine.ts
1663
1343
  /**
1664
1344
  * Create a state machine for validating transitions
@@ -1760,22 +1440,4 @@ function createStateMachine(name, transitions = {}, options = {}) {
1760
1440
  };
1761
1441
  }
1762
1442
  //#endregion
1763
- //#region src/utils/userHelpers.ts
1764
- /**
1765
- * Extract a user ID from a user object. Accepts `id` or `_id` — returns
1766
- * `undefined` when neither is present. Used by arc's controllers to
1767
- * populate `createdBy` / `updatedBy` fields and for cache scoping.
1768
- *
1769
- * @example
1770
- * ```ts
1771
- * import { getUserId } from '@classytic/arc/utils';
1772
- * const uid = getUserId(request.user);
1773
- * ```
1774
- */
1775
- function getUserId(user) {
1776
- if (!user) return void 0;
1777
- const id = user.id ?? user._id;
1778
- return id ? String(id) : void 0;
1779
- }
1780
- //#endregion
1781
- export { createCircuitBreaker as A, defineErrorMapper as C, CircuitBreakerError as D, CircuitBreaker as E, simpleEqualityMatcher as F, assertValidConfig as M, formatValidationErrors as N, CircuitBreakerRegistry as O, validateResourceConfig as P, defineGuard as S, withCompensation as T, ArcQueryParser as _, bareListResponse as a, MONGO_DIALECT as b, errorDetailSchema$1 as c, keysetListResponse as d, listResponse as f, responses as g, queryParams as h, aggregateListResponse as i, createCircuitBreakerRegistry as j, CircuitState as k, getDefaultCrudSchemas as l, paginationSchema as m, createStateMachine as n, deleteResponse as o, offsetListResponse as p, scheduleBackground as r, errorContractSchema$1 as s, getUserId as t, getListQueryParams as u, createQueryParser as v, defineCompensation as w, NEUTRAL_DIALECT as x, handleRaw as y };
1443
+ export { createCircuitBreakerRegistry as A, defineCompensation as C, CircuitBreakerRegistry as D, CircuitBreakerError as E, CircuitState as O, defineErrorMapper as S, CircuitBreaker as T, createQueryParser as _, deleteResponse as a, NEUTRAL_DIALECT as b, getDefaultCrudSchemas as c, listResponse as d, offsetListResponse as f, ArcQueryParser as g, responses as h, bareListResponse as i, createCircuitBreaker as k, getListQueryParams as l, queryParams as m, scheduleBackground as n, errorContractSchema$1 as o, paginationSchema as p, aggregateListResponse as r, errorDetailSchema$1 as s, createStateMachine as t, keysetListResponse as u, handleRaw as v, withCompensation as w, defineGuard as x, MONGO_DIALECT as y };
@@ -123,7 +123,7 @@ function resolveLimitPolicy(config) {
123
123
  function resolveAggRequestLimit(normalized, query) {
124
124
  const policy = normalized.compiled.limitPolicy;
125
125
  if (!policy) return normalized.compiled.limit;
126
- const raw = query["limit"];
126
+ const raw = query.limit;
127
127
  if (raw === void 0 || raw === "") return policy.defaultLimit;
128
128
  const parsed = typeof raw === "number" ? raw : Number.parseInt(String(raw), 10);
129
129
  if (!Number.isFinite(parsed) || parsed <= 0) return policy.defaultLimit;
@@ -1,33 +1,5 @@
1
1
  import { n as DomainEvent } from "./EventTransport-QmY7A258.mjs";
2
2
  import { FastifyPluginAsync, FastifyRequest } from "fastify";
3
- //#region src/plugins/health.d.ts
4
- interface HealthCheck {
5
- /** Name of the dependency */
6
- name: string;
7
- /** Function that returns true if healthy, false otherwise */
8
- check: () => Promise<boolean> | boolean;
9
- /** Optional timeout in ms (default: 5000) */
10
- timeout?: number;
11
- /** Whether this check is critical for readiness (default: true) */
12
- critical?: boolean;
13
- }
14
- interface HealthOptions {
15
- /** Route prefix (default: '/_health') */
16
- prefix?: string;
17
- /** Health check dependencies */
18
- checks?: HealthCheck[];
19
- /** Enable metrics endpoint (default: false) */
20
- metrics?: boolean;
21
- /** Custom metrics collector function */
22
- metricsCollector?: () => Promise<string> | string;
23
- /** Version info to include in responses */
24
- version?: string;
25
- /** Collect HTTP request metrics (default: true if metrics enabled) */
26
- collectHttpMetrics?: boolean;
27
- }
28
- declare const healthPlugin: FastifyPluginAsync<HealthOptions>;
29
- declare const _default$4: FastifyPluginAsync<HealthOptions>;
30
- //#endregion
31
3
  //#region src/plugins/caching.d.ts
32
4
  interface CachingRule {
33
5
  /** Path prefix to match (e.g., '/api/products') */
@@ -54,7 +26,7 @@ interface CachingOptions {
54
26
  rules?: CachingRule[];
55
27
  }
56
28
  declare const cachingPlugin: FastifyPluginAsync<CachingOptions>;
57
- declare const _default$3: FastifyPluginAsync<CachingOptions>;
29
+ declare const _default$4: FastifyPluginAsync<CachingOptions>;
58
30
  //#endregion
59
31
  //#region src/plugins/sse.d.ts
60
32
  interface SSEOptions {
@@ -79,7 +51,46 @@ interface SSEOptions {
79
51
  tokenQueryParam?: string | null;
80
52
  }
81
53
  declare const ssePlugin: FastifyPluginAsync<SSEOptions>;
82
- declare const _default$2: FastifyPluginAsync<SSEOptions>;
54
+ declare const _default$3: FastifyPluginAsync<SSEOptions>;
55
+ //#endregion
56
+ //#region src/plugins/realtime.d.ts
57
+ declare const CHANGE_OPERATIONS: readonly ["created", "updated", "deleted"];
58
+ type ChangeOperation = (typeof CHANGE_OPERATIONS)[number];
59
+ interface RealtimeOptions {
60
+ /**
61
+ * Route path — must contain `:resource` (default: `/realtime/:resource`).
62
+ */
63
+ path?: string;
64
+ /** Heartbeat interval in ms (default: 30000). */
65
+ heartbeat?: number;
66
+ /**
67
+ * Subscribable resources. Default: every registered resource — safe
68
+ * because each subscription runs that resource's own `list` permission;
69
+ * narrow it when only some resources should have a change feed at all.
70
+ */
71
+ resources?: readonly string[];
72
+ /** Operations streamed (default: created + updated + deleted). */
73
+ operations?: readonly ChangeOperation[];
74
+ /**
75
+ * Query parameter carrying the bearer token for browser `EventSource`
76
+ * clients (default `'token'`, matching the sse plugin / arc-next).
77
+ * Set `null` to disable promotion.
78
+ */
79
+ tokenQueryParam?: string | null;
80
+ /**
81
+ * Hard ceiling (ms) on any single connection's lifetime. Bounds
82
+ * permission STALENESS: the row filters are snapshotted at connect, so a
83
+ * caller whose permissions are revoked mid-connection keeps receiving
84
+ * their old view until they reconnect. Independently, arc ALWAYS closes
85
+ * the feed when the auth token's `exp` passes (forcing reconnect +
86
+ * re-authorization with fresh filters) — this option adds a cap for
87
+ * session/cookie auth that carries no `exp`, or to tighten below the
88
+ * token TTL. Omit for token-exp-only bounding.
89
+ */
90
+ maxConnectionMs?: number;
91
+ }
92
+ declare const realtimePlugin: FastifyPluginAsync<RealtimeOptions>;
93
+ declare const _default$2: FastifyPluginAsync<RealtimeOptions>;
83
94
  //#endregion
84
95
  //#region src/plugins/metrics.d.ts
85
96
  interface MetricsOptions {
@@ -135,6 +146,16 @@ interface VersioningOptions {
135
146
  headerName?: string;
136
147
  /** Response header name (default: 'x-api-version') */
137
148
  responseHeader?: string;
149
+ /**
150
+ * Supported versions. When set, a request for any OTHER version is
151
+ * rejected with `400 arc.unsupported_api_version` (details list the
152
+ * supported set). When omitted, every requested version is accepted
153
+ * and merely annotated — detection-only mode.
154
+ *
155
+ * The `defaultVersion` must be in this list (boot-time check) so a
156
+ * versionless request can never be rejected by its own default.
157
+ */
158
+ versions?: string[];
138
159
  /** Deprecated versions — adds Deprecation + Sunset headers */
139
160
  deprecated?: string[];
140
161
  /** Sunset date for deprecated versions (ISO 8601) */
@@ -148,4 +169,4 @@ declare module "fastify" {
148
169
  declare const versioningPlugin: FastifyPluginAsync<VersioningOptions>;
149
170
  declare const _default: FastifyPluginAsync<VersioningOptions>;
150
171
  //#endregion
151
- export { HealthOptions as _, MetricsCollector as a, metricsPlugin as c, ssePlugin as d, CachingOptions as f, HealthCheck as g, cachingPlugin as h, MetricEntry as i, SSEOptions as l, _default$3 as m, _default as n, MetricsOptions as o, CachingRule as p, versioningPlugin as r, _default$1 as s, VersioningOptions as t, _default$2 as u, _default$4 as v, healthPlugin as y };
172
+ export { CachingRule as _, MetricsCollector as a, metricsPlugin as c, _default$2 as d, realtimePlugin as f, CachingOptions as g, ssePlugin as h, MetricEntry as i, ChangeOperation as l, _default$3 as m, _default as n, MetricsOptions as o, SSEOptions as p, versioningPlugin as r, _default$1 as s, VersioningOptions as t, RealtimeOptions as u, _default$4 as v, cachingPlugin as y };
@@ -1,4 +1,5 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-8H4AJuhK.mjs";
2
+ import { d as createDomainError } from "./errors-DkO8Nk6-.mjs";
2
3
  import fp from "fastify-plugin";
3
4
  //#region src/plugins/versioning.ts
4
5
  var versioning_exports = /* @__PURE__ */ __exportAll({
@@ -7,8 +8,10 @@ var versioning_exports = /* @__PURE__ */ __exportAll({
7
8
  });
8
9
  const PREFIX_REGEX = /^\/v(\d+)\//;
9
10
  const versioningPlugin = async (fastify, opts) => {
10
- const { type, defaultVersion = "1", headerName = "accept-version", responseHeader = "x-api-version", deprecated = [], sunset } = opts;
11
+ const { type, defaultVersion = "1", headerName = "accept-version", responseHeader = "x-api-version", versions, deprecated = [], sunset } = opts;
11
12
  const deprecatedSet = new Set(deprecated);
13
+ const supportedSet = versions ? new Set(versions) : void 0;
14
+ if (supportedSet && !supportedSet.has(defaultVersion)) throw new Error(`versioningPlugin: defaultVersion '${defaultVersion}' is not in versions [${[...supportedSet].join(", ")}]`);
12
15
  fastify.decorateRequest("apiVersion", defaultVersion);
13
16
  fastify.addHook("onRequest", async (request, reply) => {
14
17
  let version = defaultVersion;
@@ -19,6 +22,10 @@ const versioningPlugin = async (fastify, opts) => {
19
22
  const match = request.url.match(PREFIX_REGEX);
20
23
  if (match) version = match[1] ?? defaultVersion;
21
24
  }
25
+ if (supportedSet && !supportedSet.has(version)) throw createDomainError("arc.unsupported_api_version", `API version '${version}' is not supported`, 400, {
26
+ requested: version,
27
+ supported: [...supportedSet]
28
+ });
22
29
  request.apiVersion = version;
23
30
  reply.header(responseHeader, version);
24
31
  if (deprecatedSet.has(version)) {