@classytic/arc 2.15.4 → 2.16.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 (160) hide show
  1. package/README.md +1 -0
  2. package/bin/arc.js +12 -0
  3. package/dist/{BaseController-dx3m2J8V.mjs → BaseController-Vk8I7C0x.mjs} +61 -19
  4. package/dist/{HookSystem-Iiebom92.mjs → HookSystem-Cmf7-Etp.mjs} +8 -4
  5. package/dist/{QueryCache-D41bfdBB.d.mts → QueryCache-8YmAQ-zW.d.mts} +1 -1
  6. package/dist/{ResourceRegistry-CTERg_2x.mjs → ResourceRegistry-CzSzVK1m.mjs} +55 -9
  7. package/dist/audit/index.d.mts +1 -1
  8. package/dist/audit/index.mjs +4 -2
  9. package/dist/auth/index.d.mts +4 -4
  10. package/dist/auth/index.mjs +4 -4
  11. package/dist/auth/redis-session.d.mts +1 -1
  12. package/dist/{betterAuthOpenApi--M_i87dQ.mjs → betterAuthOpenApi-ClWxaceA.mjs} +10 -6
  13. package/dist/buildHandler-C_SkBNF3.mjs +302 -0
  14. package/dist/cache/index.d.mts +3 -3
  15. package/dist/cache/index.mjs +3 -3
  16. package/dist/{caching-SM8gghN6.mjs → caching-8MfrrUYU.mjs} +1 -1
  17. package/dist/cli/commands/describe.d.mts +35 -1
  18. package/dist/cli/commands/describe.mjs +52 -12
  19. package/dist/cli/commands/docs.d.mts +1 -4
  20. package/dist/cli/commands/docs.mjs +4 -16
  21. package/dist/cli/commands/generate.d.mts +2 -20
  22. package/dist/cli/commands/generate.mjs +1 -546
  23. package/dist/cli/commands/init.d.mts +2 -40
  24. package/dist/cli/commands/init.mjs +1 -3045
  25. package/dist/cli/commands/introspect.mjs +53 -64
  26. package/dist/cli/index.d.mts +2 -2
  27. package/dist/cli/index.mjs +2 -2
  28. package/dist/{constants-Cxde4rpC.mjs → constants-TrJVIJl0.mjs} +7 -0
  29. package/dist/core/index.d.mts +3 -3
  30. package/dist/core/index.mjs +5 -5
  31. package/dist/{core-CvmOqEms.mjs → core-wKeGXn38.mjs} +222 -44
  32. package/dist/createActionRouter-eiOFtnC3.mjs +288 -0
  33. package/dist/{createAggregationRouter-B0bPDf5b.mjs → createAggregationRouter-C4unhBnV.mjs} +3 -2
  34. package/dist/{createApp-PFegs47-.mjs → createApp-DdPBtmzp.mjs} +16 -14
  35. package/dist/{defineEvent-D5h7EvAx.mjs → defineEvent-NPKUJRt8.mjs} +1 -1
  36. package/dist/docs/index.d.mts +2 -2
  37. package/dist/docs/index.mjs +1 -1
  38. package/dist/{errorHandler-Bk-AGhkU.mjs → errorHandler-DNPzqaTT.mjs} +17 -14
  39. package/dist/errors-4ZloYFvm.d.mts +91 -0
  40. package/dist/{eventPlugin-CaKTYkYM.mjs → eventPlugin-CnJCUyM0.mjs} +1 -1
  41. package/dist/{eventPlugin-qXpqTebY.d.mts → eventPlugin-DjEx_J3j.d.mts} +1 -1
  42. package/dist/events/index.d.mts +3 -3
  43. package/dist/events/index.mjs +5 -5
  44. package/dist/events/transports/redis-stream-entry.d.mts +1 -1
  45. package/dist/events/transports/redis.d.mts +1 -1
  46. package/dist/factory/index.d.mts +2 -2
  47. package/dist/factory/index.mjs +4 -3
  48. package/dist/fastify-augmentation-CJm0vh-l.d.mts +42 -0
  49. package/dist/{fields-COhcH3fk.d.mts → fields-DqZLp5d6.d.mts} +1 -1
  50. package/dist/generate-CcM24hoq.mjs +654 -0
  51. package/dist/generate-cRnsafMS.d.mts +38 -0
  52. package/dist/hooks/index.d.mts +1 -1
  53. package/dist/hooks/index.mjs +1 -1
  54. package/dist/idempotency/index.d.mts +2 -2
  55. package/dist/idempotency/index.mjs +1 -1
  56. package/dist/idempotency/redis.d.mts +1 -1
  57. package/dist/{index-BTqLEvhu.d.mts → index-3L-adFJO.d.mts} +12 -12
  58. package/dist/{index-BstGxcc3.d.mts → index-CC_pgZjY.d.mts} +88 -17
  59. package/dist/{index-BswOSJCE.d.mts → index-DBDgy6Hw.d.mts} +430 -19
  60. package/dist/index.d.mts +6 -6
  61. package/dist/index.mjs +7 -8
  62. package/dist/init-D-vrTZ1G.d.mts +71 -0
  63. package/dist/init-DYzfniuX.mjs +3098 -0
  64. package/dist/integrations/event-gateway.d.mts +2 -2
  65. package/dist/integrations/event-gateway.mjs +1 -1
  66. package/dist/integrations/index.d.mts +2 -2
  67. package/dist/integrations/jobs.mjs +3 -3
  68. package/dist/integrations/mcp/index.d.mts +239 -7
  69. package/dist/integrations/mcp/index.mjs +2 -528
  70. package/dist/integrations/mcp/testing.d.mts +2 -2
  71. package/dist/integrations/mcp/testing.mjs +6 -10
  72. package/dist/integrations/streamline.mjs +26 -1
  73. package/dist/integrations/websocket-redis.d.mts +1 -1
  74. package/dist/integrations/websocket.d.mts +1 -1
  75. package/dist/integrations/websocket.mjs +1 -0
  76. package/dist/loadResourcesFromEntry-DCjYMgfC.mjs +51 -0
  77. package/dist/{resourceToTools-tFYUNmM0.mjs → mcpPlugin-Dbgz9_Rj.mjs} +1021 -318
  78. package/dist/{memory-UBydS5ku.mjs → memory-QOLe11D5.mjs} +2 -0
  79. package/dist/middleware/index.d.mts +1 -1
  80. package/dist/middleware/index.mjs +1 -1
  81. package/dist/{openapi-BHXhoX8O.mjs → openapi-BRnd3pT7.mjs} +82 -44
  82. package/dist/permissions/index.d.mts +2 -2
  83. package/dist/permissions/index.mjs +1 -1
  84. package/dist/{permissions-ohQyv50e.mjs → permissions-CTxMrreC.mjs} +2 -2
  85. package/dist/{pipe-Zr0KXjQe.mjs → pipe-DiCyvyPN.mjs} +1 -0
  86. package/dist/pipeline/index.d.mts +1 -1
  87. package/dist/pipeline/index.mjs +1 -1
  88. package/dist/plugins/index.d.mts +5 -59
  89. package/dist/plugins/index.mjs +10 -10
  90. package/dist/plugins/response-cache.mjs +5 -5
  91. package/dist/plugins/tracing-entry.d.mts +1 -1
  92. package/dist/plugins/tracing-entry.mjs +1 -1
  93. package/dist/{pluralize-DQgqgifU.mjs → pluralize-B9M8xvy-.mjs} +2 -1
  94. package/dist/preloadResources-BOXzPhKU.mjs +45 -0
  95. package/dist/presets/filesUpload.d.mts +4 -4
  96. package/dist/presets/filesUpload.mjs +2 -2
  97. package/dist/presets/index.d.mts +1 -1
  98. package/dist/presets/index.mjs +1 -1
  99. package/dist/presets/multiTenant.d.mts +1 -1
  100. package/dist/presets/multiTenant.mjs +4 -3
  101. package/dist/presets/search.d.mts +2 -2
  102. package/dist/presets/search.mjs +1 -1
  103. package/dist/{presets-BbkjdPeH.mjs → presets-C9BE6WaZ.mjs} +2 -2
  104. package/dist/{queryCachePlugin-m1XsgAIJ.mjs → queryCachePlugin-B4XMSSe7.mjs} +2 -2
  105. package/dist/{queryCachePlugin-CqMdLI2-.d.mts → queryCachePlugin-Do4P_WBC.d.mts} +2 -2
  106. package/dist/{redis-DiMkdHEl.d.mts → redis-cpqVb85w.d.mts} +1 -1
  107. package/dist/{redis-stream-D6HzR1Z_.d.mts → redis-stream-BuiAS3aE.d.mts} +1 -1
  108. package/dist/registry/index.d.mts +341 -2
  109. package/dist/registry/index.mjs +3 -3
  110. package/dist/registry-BrlHkrRS.mjs +582 -0
  111. package/dist/{routerShared-DrOa-26E.mjs → routerShared-ClV4cCaa.mjs} +40 -16
  112. package/dist/scope/index.d.mts +3 -3
  113. package/dist/scope/index.mjs +3 -3
  114. package/dist/{sse-Bz-5ZeTt.mjs → sse-jh5OqFjR.mjs} +1 -1
  115. package/dist/testing/index.d.mts +2 -25
  116. package/dist/testing/index.mjs +17 -51
  117. package/dist/testing/storageContract.d.mts +1 -1
  118. package/dist/types/index.d.mts +5 -5
  119. package/dist/types/storage.d.mts +1 -1
  120. package/dist/{types-BQsjgQzS.d.mts → types-BD0BCdf6.d.mts} +106 -12
  121. package/dist/{types-C_s5moIu.mjs → types-Bi0r0vjG.mjs} +53 -1
  122. package/dist/{types-CTYvcwHe.d.mts → types-CfapW2t8.d.mts} +42 -1
  123. package/dist/{types-DrBaUwyV.d.mts → types-Dw--SyTq.d.mts} +57 -11
  124. package/dist/utils/index.d.mts +1287 -2
  125. package/dist/utils/index.mjs +1 -1
  126. package/dist/{utils-_h9B3c57.mjs → utils-DPXZnZUx.mjs} +158 -41
  127. package/dist/{buildHandler-CcFOpJLh.mjs → validate-DFIP2VrM.mjs} +73 -302
  128. package/dist/{versioning-hmkPcDlX.d.mts → versioning-5H4930tj.d.mts} +1 -1
  129. package/package.json +22 -29
  130. package/skills/arc/SKILL.md +300 -706
  131. package/skills/arc/references/auth.md +19 -7
  132. package/skills/arc-code-review/SKILL.md +1 -1
  133. package/skills/arc-code-review/references/arc-cheatsheet.md +100 -322
  134. package/dist/createActionRouter-S3MLVYot.mjs +0 -220
  135. package/dist/index-bRjYu21O.d.mts +0 -1320
  136. package/dist/org/index.d.mts +0 -66
  137. package/dist/org/index.mjs +0 -486
  138. package/dist/org/types.d.mts +0 -82
  139. package/dist/org/types.mjs +0 -1
  140. package/dist/registry-I-ogLgL9.mjs +0 -46
  141. /package/dist/{EventTransport-CT_52aWU.d.mts → EventTransport-B1GFiYUy.d.mts} +0 -0
  142. /package/dist/{EventTransport-DLWoUMHy.mjs → EventTransport-ClEDfiMS.mjs} +0 -0
  143. /package/dist/{actionPermissions-CyUkQu6O.mjs → actionPermissions-Bjmvn7Eb.mjs} +0 -0
  144. /package/dist/{elevation-DgoeTyfX.mjs → elevation-Dci0AYLT.mjs} +0 -0
  145. /package/dist/{elevation-BXOWoGCF.d.mts → elevation-DvwtjDwu.d.mts} +0 -0
  146. /package/dist/{errorHandler-DFr45ZG4.d.mts → errorHandler-DuqTqeqn.d.mts} +0 -0
  147. /package/dist/{externalPaths-BD5nw6St.d.mts → externalPaths-ClykOQqa.d.mts} +0 -0
  148. /package/dist/{interface-beEtJyWM.d.mts → interface-BVkZsvEe.d.mts} +0 -0
  149. /package/dist/{interface-DfLGcus7.d.mts → interface-DoE6UoC2.d.mts} +0 -0
  150. /package/dist/{keys-CGcCbNyu.mjs → keys-DopsCuyQ.mjs} +0 -0
  151. /package/dist/{loadResources-DBMQg_Aj.mjs → loadResources-fol6JuvW.mjs} +0 -0
  152. /package/dist/{metrics-Qnvwc-LQ.mjs → metrics-B-PU4-Yu.mjs} +0 -0
  153. /package/dist/{replyHelpers-CK-FNO8E.mjs → replyHelpers-boS5Rx26.mjs} +0 -0
  154. /package/dist/{schemaIR-lYhC2gE5.mjs → schemaIR-DAOvBWgT.mjs} +0 -0
  155. /package/dist/{sessionManager-C4Le_UB3.d.mts → sessionManager-DlmaHUAl.d.mts} +0 -0
  156. /package/dist/{storage-Dfzt4VTl.d.mts → storage-HcAgP5Ni.d.mts} +0 -0
  157. /package/dist/{store-helpers-BkIN9-vu.mjs → store-helpers-5JgaxieH.mjs} +0 -0
  158. /package/dist/{tracing-QJVprktp.d.mts → tracing-bRqqUxFO.d.mts} +0 -0
  159. /package/dist/{versioning-BUrT5aP4.mjs → versioning-CuIwUQZ2.mjs} +0 -0
  160. /package/dist/{websocket-ChC2rqe1.d.mts → websocket-BJ0w-3Dt.d.mts} +0 -0
@@ -1,5 +1,5 @@
1
1
  import { a as OrgAccessDeniedError, c as ServiceUnavailableError, d as createDomainError, f as createError, i as NotFoundError, l as UnauthorizedError, n as ConflictError, o as OrgRequiredError, p as isArcError, r as ForbiddenError, s as RateLimitError, t as ArcError, u as ValidationError } from "../errors-j4aJm1Wg.mjs";
2
- import { A as assertValidConfig, C as withCompensation, D as CircuitState, E as CircuitBreakerRegistry, M as validateResourceConfig, N as simpleEqualityMatcher, O as createCircuitBreaker, S as defineCompensation, T as CircuitBreakerError, _ as ArcQueryParser, a as bareListResponse, b as defineGuard, c as errorDetailSchema, d as keysetListResponse, f as listResponse, g as responses, h as queryParams, i as aggregateListResponse, j as formatValidationErrors, k as createCircuitBreakerRegistry, l as getDefaultCrudSchemas, m as paginationSchema, n as createStateMachine, o as deleteResponse, p as offsetListResponse, r as scheduleBackground, s as errorContractSchema, t as getUserId, u as getListQueryParams, v as createQueryParser, w as CircuitBreaker, x as defineErrorMapper, y as handleRaw } from "../utils-_h9B3c57.mjs";
2
+ import { A as assertValidConfig, C as withCompensation, D as CircuitState, E as CircuitBreakerRegistry, M as validateResourceConfig, N as simpleEqualityMatcher, O as createCircuitBreaker, S as defineCompensation, T as CircuitBreakerError, _ as ArcQueryParser, a as bareListResponse, b as defineGuard, c as errorDetailSchema, d as keysetListResponse, f as listResponse, g as responses, h as queryParams, i as aggregateListResponse, j as formatValidationErrors, k as createCircuitBreakerRegistry, l as getDefaultCrudSchemas, m as paginationSchema, n as createStateMachine, o as deleteResponse, p as offsetListResponse, r as scheduleBackground, s as errorContractSchema, t as getUserId, u as getListQueryParams, v as createQueryParser, w as CircuitBreaker, x as defineErrorMapper, y as handleRaw } from "../utils-DPXZnZUx.mjs";
3
3
  import { a as toJsonSchema, i as isZodSchema, n as convertRouteSchema, r as isJsonSchema, t as convertOpenApiSchemas } from "../schemaConverter-De34B1ZG.mjs";
4
4
  import { t as hasEvents } from "../typeGuards-BzkXkvVv.mjs";
5
5
  export { ArcError, ArcQueryParser, CircuitBreaker, CircuitBreakerError, CircuitBreakerRegistry, CircuitState, ConflictError, ForbiddenError, NotFoundError, OrgAccessDeniedError, OrgRequiredError, RateLimitError, ServiceUnavailableError, UnauthorizedError, ValidationError, aggregateListResponse, assertValidConfig, bareListResponse, convertOpenApiSchemas, convertRouteSchema, createCircuitBreaker, createCircuitBreakerRegistry, createDomainError, createError, createQueryParser, createStateMachine, defineCompensation, defineErrorMapper, defineGuard, deleteResponse, errorContractSchema, errorDetailSchema, formatValidationErrors, getDefaultCrudSchemas, getListQueryParams, getUserId, handleRaw, hasEvents, isArcError, isJsonSchema, isZodSchema, keysetListResponse, listResponse, offsetListResponse, paginationSchema, queryParams, responses, scheduleBackground, simpleEqualityMatcher, toJsonSchema, validateResourceConfig, withCompensation };
@@ -1,7 +1,7 @@
1
- import { m as RESERVED_QUERY_PARAMS, t as CRUD_OPERATIONS } from "./constants-Cxde4rpC.mjs";
1
+ import { m as RESERVED_QUERY_PARAMS, t as CRUD_OPERATIONS } from "./constants-TrJVIJl0.mjs";
2
2
  import { arcLog } from "./logger/index.mjs";
3
3
  import { t as ArcError } from "./errors-j4aJm1Wg.mjs";
4
- import { r as getAvailablePresets } from "./presets-BbkjdPeH.mjs";
4
+ import { r as getAvailablePresets } from "./presets-C9BE6WaZ.mjs";
5
5
  import { errorContractSchema as errorContractSchema$1, errorDetailSchema as errorDetailSchema$1 } from "@classytic/repo-core/errors";
6
6
  //#region src/utils/simpleEqualityMatcher.ts
7
7
  /**
@@ -142,7 +142,10 @@ function validateResourceConfig(config, options = {}) {
142
142
  suggestion: `Change to "${config.prefix.slice(0, -1)}"`
143
143
  });
144
144
  }
145
- if (config.routes) validateRoutes(config.routes, errors);
145
+ if (config.routes) {
146
+ validateRoutes(config.routes, errors);
147
+ validateRouteCrudCollisions(config, errors);
148
+ }
146
149
  return {
147
150
  valid: errors.length === 0,
148
151
  errors,
@@ -210,6 +213,71 @@ function validatePresetOptions(preset, warnings) {
210
213
  suggestion: validOptions.length > 0 ? `Valid options: ${validOptions.join(", ")}` : `Preset "${preset.name}" has no configurable options`
211
214
  });
212
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
+ }
213
281
  function validateRoutes(routes, errors) {
214
282
  const validMethods = [
215
283
  "GET",
@@ -236,9 +304,11 @@ function validateRoutes(routes, errors) {
236
304
  message: `Route path must start with "/" (got "${route.path}")`,
237
305
  suggestion: `Change to "/${route.path}"`
238
306
  });
239
- if (!route.handler) errors.push({
307
+ const routeWithRefs = route;
308
+ if (!route.handler && typeof routeWithRefs.controllerMethod !== "function") errors.push({
240
309
  field: `routes[${i}].handler`,
241
- message: "Route handler is required"
310
+ message: "Route must declare either `handler` (string / function) or `controllerMethod`",
311
+ suggestion: "Prefer `controllerMethod: (c: MyController) => c.method` for typed handler refs."
242
312
  });
243
313
  const routeKey = `${route.method} ${route.path}`;
244
314
  if (seenRoutes.has(routeKey)) errors.push({
@@ -761,12 +831,26 @@ const DANGEROUS_REGEX_PATTERNS = /(\{[0-9,]+\}|\*\+|\+\+|\?\+|(\(.+\))\+|\(\?:|\
761
831
  * Converts URL query parameters to a structured query format:
762
832
  * - Pagination: ?page=1&limit=20
763
833
  * - Sorting: ?sort=-createdAt,name (- prefix = descending)
764
- * - Filtering: ?status=active&price[gte]=100&price[lte]=500
834
+ * - Filtering — TWO supported forms (both produce identical output):
835
+ * - Bare (default, terse): `?status=active&price[gte]=100&price[lte]=500`
836
+ * - Bracket envelope: `?filter[status]=active&filter[price][gte]=100`
837
+ * The envelope form matches the convention used by JSON:API / Stripe /
838
+ * most modern REST style guides, and is the safe choice on busy URLs
839
+ * that might otherwise collide with reserved meta keys (`page`, `sort`,
840
+ * `select`, `populate`, etc.). Operator notation (`[gte]`, `[lte]`,
841
+ * `[ne]`, `[in]`, ...) works inside either form.
765
842
  * - Search: ?search=keyword
766
843
  * - Populate: ?populate=author,category
767
844
  * - Field selection: ?select=name,price,status
768
845
  * - Keyset pagination: ?after=cursor_value
769
846
  *
847
+ * **NOTE on parser portability.** `ArcQueryParser` accepts both filter
848
+ * forms. Other parser implementations (`@classytic/mongokit`'s
849
+ * `QueryParser`, custom hosts-side parsers) may accept only one form —
850
+ * MongoKit historically only accepts the bare form. If your host swaps
851
+ * `queryParser` to a different implementation, double-check which forms
852
+ * it understands before publishing URLs that mix the two.
853
+ *
770
854
  * For advanced MongoDB features ($lookup, aggregations), use MongoKit's QueryParser.
771
855
  */
772
856
  var ArcQueryParser = class {
@@ -930,49 +1014,82 @@ var ArcQueryParser = class {
930
1014
  if (typeof obj !== "object") return false;
931
1015
  return Object.values(obj).some((v) => this.exceedsDepth(v, currentDepth + 1));
932
1016
  }
1017
+ /**
1018
+ * Parse all filter entries from the query, supporting BOTH the bare
1019
+ * top-level form and the bracket-wrapped `filter[...]` envelope.
1020
+ *
1021
+ * Two equivalent ways to express the same query:
1022
+ * - Bare (legacy, arc default): `?status=active&price[gte]=40`
1023
+ * - Bracket envelope (REST convention): `?filter[status]=active&filter[price][gte]=40`
1024
+ *
1025
+ * Both forms parse to the same `filters: { status: 'active', price: { $gte: 40 } }`.
1026
+ * The envelope form mirrors the convention used by most REST API style
1027
+ * guides (JSON:API, Stripe API, etc.) and disambiguates filter fields
1028
+ * from reserved meta params on busy URLs — a query string with a field
1029
+ * literally named `page` or `sort` can't collide with the framework's
1030
+ * meta keys when wrapped under `filter[...]`. The bare form stays as
1031
+ * the default since it's the shortest path for simple queries; expect
1032
+ * it to remain supported indefinitely.
1033
+ *
1034
+ * Precedence when the same key appears in both forms:
1035
+ * `?status=closed&filter[status]=active` → bare wins (`status: closed`).
1036
+ * Mixing should be rare; the deterministic rule avoids silent surprises.
1037
+ */
933
1038
  parseFilters(query) {
934
1039
  const filters = {};
1040
+ const envelope = query.filter;
1041
+ if (typeof envelope === "object" && envelope !== null && !Array.isArray(envelope)) for (const [key, value] of Object.entries(envelope)) this.applyFilterEntry(filters, key, value);
935
1042
  for (const [key, value] of Object.entries(query)) {
936
1043
  if (RESERVED_QUERY_PARAMS.has(key)) continue;
937
- if (value === void 0 || value === null) continue;
938
- if (!/^[a-zA-Z_][a-zA-Z0-9_.]*$/.test(key)) continue;
939
- if (this._allowedFilterFields && !this._allowedFilterFields.has(key)) continue;
940
- if (this.exceedsDepth(value)) continue;
941
- if (typeof value === "object" && value !== null && !Array.isArray(value)) {
942
- const operatorObj = value;
943
- const operatorKeys = Object.keys(operatorObj);
944
- const allOperators = operatorKeys.every((op) => this.operators[op] && (!this._allowedOperators || this._allowedOperators.has(op)));
945
- const allKnownOperators = operatorKeys.every((op) => this.operators[op]);
946
- if (allOperators && operatorKeys.length > 0) {
947
- const mongoFilters = {};
948
- let needsCaseInsensitive = false;
949
- for (const [op, opValue] of Object.entries(operatorObj)) {
950
- const mongoOp = this.operators[op];
951
- if (mongoOp) {
952
- mongoFilters[mongoOp] = this.parseFilterValue(opValue, op);
953
- if (op === "contains" || op === "like") needsCaseInsensitive = true;
954
- }
1044
+ this.applyFilterEntry(filters, key, value);
1045
+ }
1046
+ return filters;
1047
+ }
1048
+ /**
1049
+ * Apply one filter entry — used by both the bracket-envelope branch and
1050
+ * the bare top-level branch of `parseFilters`. Centralising the field-
1051
+ * validation + operator-conversion logic keeps the two forms in
1052
+ * lockstep; a future regex/security tweak only changes one place.
1053
+ */
1054
+ applyFilterEntry(filters, key, value) {
1055
+ if (value === void 0 || value === null) return;
1056
+ if (!/^[a-zA-Z_][a-zA-Z0-9_.]*(?:\[[a-z]+\])?$/.test(key)) return;
1057
+ const bareKey = key.replace(/\[[a-z]+\]$/, "");
1058
+ if (this._allowedFilterFields && !this._allowedFilterFields.has(bareKey)) return;
1059
+ if (this.exceedsDepth(value)) return;
1060
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
1061
+ const operatorObj = value;
1062
+ const operatorKeys = Object.keys(operatorObj);
1063
+ const allOperators = operatorKeys.every((op) => this.operators[op] && (!this._allowedOperators || this._allowedOperators.has(op)));
1064
+ const allKnownOperators = operatorKeys.every((op) => this.operators[op]);
1065
+ if (allOperators && operatorKeys.length > 0) {
1066
+ const mongoFilters = {};
1067
+ let needsCaseInsensitive = false;
1068
+ for (const [op, opValue] of Object.entries(operatorObj)) {
1069
+ const mongoOp = this.operators[op];
1070
+ if (mongoOp) {
1071
+ mongoFilters[mongoOp] = this.parseFilterValue(opValue, op);
1072
+ if (op === "contains" || op === "like") needsCaseInsensitive = true;
955
1073
  }
956
- if (needsCaseInsensitive) mongoFilters.$options = "i";
957
- filters[key] = mongoFilters;
958
- continue;
959
1074
  }
960
- if (allKnownOperators && this._allowedOperators) continue;
1075
+ if (needsCaseInsensitive) mongoFilters.$options = "i";
1076
+ filters[key] = mongoFilters;
1077
+ return;
961
1078
  }
962
- const match = key.match(/^([a-zA-Z_][a-zA-Z0-9_.]*)(?:\[([a-z]+)\])?$/);
963
- if (!match) continue;
964
- const [, fieldName, operator] = match;
965
- if (!fieldName) continue;
966
- if (operator && this.operators[operator] && (!this._allowedOperators || this._allowedOperators.has(operator))) {
967
- const mongoOp = this.operators[operator];
968
- const parsedValue = this.parseFilterValue(value, operator);
969
- if (!filters[fieldName]) filters[fieldName] = {};
970
- const fieldFilter = filters[fieldName];
971
- fieldFilter[mongoOp] = parsedValue;
972
- if (operator === "contains" || operator === "like") fieldFilter.$options = "i";
973
- } else if (!operator) filters[fieldName] = this.parseFilterValue(value);
1079
+ if (allKnownOperators && this._allowedOperators) return;
974
1080
  }
975
- return filters;
1081
+ const match = key.match(/^([a-zA-Z_][a-zA-Z0-9_.]*)(?:\[([a-z]+)\])?$/);
1082
+ if (!match) return;
1083
+ const [, fieldName, operator] = match;
1084
+ if (!fieldName) return;
1085
+ if (operator && this.operators[operator] && (!this._allowedOperators || this._allowedOperators.has(operator))) {
1086
+ const mongoOp = this.operators[operator];
1087
+ const parsedValue = this.parseFilterValue(value, operator);
1088
+ if (!filters[fieldName]) filters[fieldName] = {};
1089
+ const fieldFilter = filters[fieldName];
1090
+ fieldFilter[mongoOp] = parsedValue;
1091
+ if (operator === "contains" || operator === "like") fieldFilter.$options = "i";
1092
+ } else if (!operator) filters[fieldName] = this.parseFilterValue(value);
976
1093
  }
977
1094
  parseFilterValue(value, operator) {
978
1095
  if (operator === "in" || operator === "nin") {