@classytic/arc 2.41.0 → 2.43.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 (230) hide show
  1. package/dist/{EventTransport-CbUKDzZZ.d.mts → EventTransport-CiicKz2Z.d.mts} +1 -1
  2. package/dist/{EventTransport-n7qO5Cz3.mjs → EventTransport-tzA7CAB4.mjs} +22 -17
  3. package/dist/{HookSystem-CxvZBo77.mjs → HookSystem-B6UaKRbF.mjs} +2 -2
  4. package/dist/{QueryCache-DFA-QrU9.d.mts → QueryCache--5uAuYPW.d.mts} +7 -3
  5. package/dist/{ResourceRegistry-Cu7uTJp9.mjs → ResourceRegistry-CuCARu2B.mjs} +44 -13
  6. package/dist/actionPermissions-BotX1YOa.mjs +776 -0
  7. package/dist/audit/index.d.mts +43 -5
  8. package/dist/audit/index.mjs +48 -26
  9. package/dist/auth/audit.mjs +1 -1
  10. package/dist/auth/index.d.mts +43 -13
  11. package/dist/auth/index.mjs +130 -155
  12. package/dist/auth/redis-session.mjs +1 -1
  13. package/dist/{authorizationDecision-2YY_Kaj1.mjs → authorizationDecision-DQYrkl5f.mjs} +7 -102
  14. package/dist/{base-CxrV1LmG.d.mts → base-CZyixEQR.d.mts} +27 -5
  15. package/dist/boundedMap-BULzfXYU.mjs +57 -0
  16. package/dist/{buildHandler-CUhjGHGZ.mjs → buildHandler-CRQ8EcX5.mjs} +4 -4
  17. package/dist/cache/index.d.mts +22 -34
  18. package/dist/cache/index.mjs +4 -4
  19. package/dist/{caching-DsWDjIxx.mjs → caching-Cc2M1DwO.mjs} +3 -3
  20. package/dist/claimed-B10RTfkM.mjs +8 -0
  21. package/dist/cleanup/index.d.mts +6 -10
  22. package/dist/cleanup/index.mjs +27 -73
  23. package/dist/cli/commands/describe.d.mts +4 -2
  24. package/dist/cli/commands/describe.mjs +7 -4
  25. package/dist/cli/commands/docs.mjs +3 -3
  26. package/dist/cli/commands/doctor.mjs +1 -1
  27. package/dist/cli/commands/generate.mjs +2 -2
  28. package/dist/cli/commands/init.mjs +2 -2
  29. package/dist/cli/commands/introspect.mjs +2 -2
  30. package/dist/cli/index.mjs +3 -3
  31. package/dist/{constants-BxLgNFF7.mjs → constants-Cu7Mx7_U.mjs} +4 -2
  32. package/dist/context/index.d.mts +2 -5
  33. package/dist/context/index.mjs +18 -38
  34. package/dist/core/index.d.mts +3 -3
  35. package/dist/core/index.mjs +8 -8
  36. package/dist/{core-iNGqYerO.mjs → core-Cb0LjccA.mjs} +5 -4
  37. package/dist/{createActionRouter-BBlbPs3k.mjs → createActionRouter-xjqonMRO.mjs} +87 -39
  38. package/dist/{createAggregationRouter-Dv5SuK2x.mjs → createAggregationRouter-zRYWw_5D.mjs} +11 -8
  39. package/dist/{preloadResources-Bbp4QJ8V.mjs → createApp-D-953ZAq.mjs} +299 -390
  40. package/dist/{createMcpServer-CbDWkZ1T.d.mts → createMcpServer-C9cxS8zt.d.mts} +1 -1
  41. package/dist/{defineAggregation-UyeurGkp.mjs → defineAggregation-Bm8RjMbq.mjs} +1 -1
  42. package/dist/{defineResource-DzGT-2LP.mjs → defineResource-DIC7JZGU.mjs} +118 -1024
  43. package/dist/discovery/index.mjs +1 -1
  44. package/dist/docs/index.d.mts +10 -4
  45. package/dist/docs/index.mjs +4 -4
  46. package/dist/{elevation-BXCfVcNG.d.mts → elevation-BW2J3tNj.d.mts} +1 -2
  47. package/dist/{elevation-BF-vhGxY.mjs → elevation-DG8SrLN-.mjs} +41 -12
  48. package/dist/encryption/index.mjs +3 -3
  49. package/dist/{errorHandler-CIMkCOWm.mjs → errorHandler-BnfDviL1.mjs} +26 -5
  50. package/dist/{errors-C_YhELhe.mjs → errors-CtrywMHP.mjs} +58 -23
  51. package/dist/{errors-BGdVwP25.d.mts → errors-JYc9vwqE.d.mts} +9 -0
  52. package/dist/{eventPlugin-B52abJ8W.d.mts → eventPlugin-BtmA4-3P.d.mts} +65 -9
  53. package/dist/{eventPlugin-CmXaPWLv.mjs → eventPlugin-Q6BhzHzs.mjs} +82 -21
  54. package/dist/{subscribe-helpers-AVYPgqTl.mjs → eventTypes-Cdhm6ZlV.mjs} +151 -17
  55. package/dist/events/index.d.mts +33 -8
  56. package/dist/events/index.mjs +49 -53
  57. package/dist/events/transports/redis-stream-entry.d.mts +1 -1
  58. package/dist/events/transports/redis-stream-entry.mjs +139 -157
  59. package/dist/events/transports/redis.d.mts +1 -1
  60. package/dist/events/transports/redis.mjs +2 -2
  61. package/dist/factory/index.d.mts +32 -17
  62. package/dist/factory/index.mjs +213 -32
  63. package/dist/{fastify-augmentation-Cq2nHVaW.d.mts → fastify-augmentation-DhUJ-mvo.d.mts} +11 -25
  64. package/dist/{fields-I_EeRWND.d.mts → fields-BBSpf8pS.d.mts} +1 -1
  65. package/dist/{fields-BTyPLHK0.mjs → fields-ToPYpMqS.mjs} +1 -1
  66. package/dist/filter-merge-IpGywvym.mjs +99 -0
  67. package/dist/{generate-D_YmiCNf.mjs → generate-DRTkLlIl.mjs} +2 -2
  68. package/dist/{headers-CNECDE6q.mjs → headers-BbkIF6vA.mjs} +2 -2
  69. package/dist/health-D-Ft6JM6.mjs +22 -0
  70. package/dist/{health-CxQzbJmr.mjs → health-DqbsG-_w.mjs} +115 -55
  71. package/dist/hooks/index.d.mts +2 -2
  72. package/dist/hooks/index.mjs +2 -2
  73. package/dist/idempotency/index.d.mts +31 -7
  74. package/dist/idempotency/index.mjs +155 -98
  75. package/dist/idempotency/redis.d.mts +1 -1
  76. package/dist/idempotency/redis.mjs +48 -11
  77. package/dist/{index-BHdZD8zK.d.mts → index-Bxriq3so.d.mts} +84 -21
  78. package/dist/{preloadResources-CjS2OfpU.d.mts → index-C8zVGYsk.d.mts} +37 -38
  79. package/dist/{index-D4u76eQW.d.mts → index-CRoPtISf.d.mts} +23 -73
  80. package/dist/{index-CbJiYrfv.d.mts → index-Chgz3d0i.d.mts} +1 -1
  81. package/dist/index-JdbZAIsm.d.mts +510 -0
  82. package/dist/{index-DHbyPo5j.d.mts → index-mqaqZNpe.d.mts} +253 -254
  83. package/dist/index.d.mts +8 -8
  84. package/dist/index.mjs +11 -11
  85. package/dist/{init-Y2MlSN9n.mjs → init-B_oUYDXI.mjs} +1 -1
  86. package/dist/integrations/event-gateway.d.mts +8 -3
  87. package/dist/integrations/event-gateway.mjs +4 -4
  88. package/dist/integrations/index.d.mts +3 -3
  89. package/dist/integrations/index.mjs +1 -1
  90. package/dist/integrations/jobs/index.d.mts +1 -1
  91. package/dist/integrations/jobs/index.mjs +18 -5
  92. package/dist/integrations/mcp/index.d.mts +72 -59
  93. package/dist/integrations/mcp/index.mjs +19 -4
  94. package/dist/integrations/mcp/testing.d.mts +2 -2
  95. package/dist/integrations/mcp/testing.mjs +2 -2
  96. package/dist/integrations/streamline.d.mts +78 -136
  97. package/dist/integrations/streamline.mjs +116 -54
  98. package/dist/integrations/webhooks.d.mts +60 -120
  99. package/dist/integrations/webhooks.mjs +36 -80
  100. package/dist/integrations/websocket-pushref-redis.d.mts +1 -1
  101. package/dist/integrations/websocket-pushref-redis.mjs +1 -1
  102. package/dist/integrations/websocket-redis.d.mts +1 -1
  103. package/dist/integrations/websocket-redis.mjs +1 -1
  104. package/dist/integrations/websocket.d.mts +3 -3
  105. package/dist/integrations/websocket.mjs +2 -2
  106. package/dist/{interface-DEWNOMza.d.mts → interface-C0395yUN.d.mts} +13 -1
  107. package/dist/{jsonBody-Cq9KYl2Z.mjs → jsonBody-B4YVGcql.mjs} +2 -2
  108. package/dist/{keys-Cmv4cBRa.mjs → keys-B2KtgMLJ.mjs} +7 -18
  109. package/dist/{loadResources-CblF7xt2.mjs → loadResources-Dn4lalw2.mjs} +3 -3
  110. package/dist/{loadResourcesFromEntry-99fIMC3p.mjs → loadResourcesFromEntry-3sLYbuse.mjs} +2 -2
  111. package/dist/logger/index.d.mts +1 -8
  112. package/dist/logger/index.mjs +3 -153
  113. package/dist/logger-DXjfIqjV.mjs +160 -0
  114. package/dist/{mcpPlugin-xF3Gw-HW.mjs → mcpPlugin-C08HalN5.mjs} +433 -170
  115. package/dist/{memory-CpDZ9NPw.mjs → memory-9Cp7611C.mjs} +36 -34
  116. package/dist/{metrics-BaKnq8ag.mjs → metrics-CWI11eRu.mjs} +39 -72
  117. package/dist/middleware/index.d.mts +2 -2
  118. package/dist/middleware/index.mjs +3 -3
  119. package/dist/migrations/index.mjs +2 -2
  120. package/dist/{module-BkQEesNQ.mjs → module-BSvOQC7E.mjs} +8 -8
  121. package/dist/multiTenant-cY0FkDiA.d.mts +104 -0
  122. package/dist/{multipartBody-C92aJ8am.mjs → multipartBody-CXM2a-Dy.mjs} +46 -7
  123. package/dist/{openapi-C3LFRzPq.mjs → openapi-DT_G4VhY.mjs} +48 -10
  124. package/dist/outbox-admin/index.d.mts +3 -3
  125. package/dist/outbox-admin/index.mjs +6 -18
  126. package/dist/{outbox-module-dsCyLxXH.mjs → outbox-module-Bjgzl6tg.mjs} +203 -33
  127. package/dist/{outbox-module-BHdUnuuK.d.mts → outbox-module-gZCHoE4H.d.mts} +79 -6
  128. package/dist/permissions/index.d.mts +5 -4
  129. package/dist/permissions/index.mjs +8 -6
  130. package/dist/{permissions-DiHXy8Ck.mjs → permissions-Cm0uYHvd.mjs} +149 -33
  131. package/dist/{pipe-CBVmUJ-j.mjs → pipe-BqeGT6UO.mjs} +2 -2
  132. package/dist/pipeline/index.d.mts +2 -2
  133. package/dist/pipeline/index.mjs +2 -2
  134. package/dist/plan-FVR6LP59.mjs +565 -0
  135. package/dist/{BaseController-DYJRZ0EJ.mjs → plugin-BccET1tL.mjs} +1256 -344
  136. package/dist/plugins/index.d.mts +14 -11
  137. package/dist/plugins/index.mjs +44 -22
  138. package/dist/plugins/response-cache.mjs +8 -14
  139. package/dist/plugins/tracing-entry.mjs +2 -2
  140. package/dist/{pluralize-2_U6QZQg.mjs → pluralize-CVIOcgMF.mjs} +1 -1
  141. package/dist/presets/filesUpload.d.mts +2 -2
  142. package/dist/presets/filesUpload.mjs +5 -5
  143. package/dist/presets/index.d.mts +26 -31
  144. package/dist/presets/index.mjs +4 -4
  145. package/dist/presets/multiTenant.d.mts +2 -107
  146. package/dist/presets/multiTenant.mjs +22 -162
  147. package/dist/presets/search.d.mts +2 -2
  148. package/dist/presets/search.mjs +2 -2
  149. package/dist/{presets-BdlugYTc.mjs → presets-Cpvj6fGO.mjs} +99 -107
  150. package/dist/{projection-Uvgp-EEj.mjs → projection-Ce4aQhQk.mjs} +2 -2
  151. package/dist/{pushref-registry-Dov9l4qE.d.mts → pushref-registry-UxCkoA4C.d.mts} +1 -1
  152. package/dist/{queryCachePlugin-BzqxconG.d.mts → queryCachePlugin-C2lZjTxk.d.mts} +1 -1
  153. package/dist/{queryCachePlugin-B5F09bYr.mjs → queryCachePlugin-CFWNH8aT.mjs} +12 -14
  154. package/dist/{rateLimitKey-CK-EU2L8.mjs → rateLimitKey-C4O-kPm0.mjs} +1 -1
  155. package/dist/{realtime-FnnD0n3Z.mjs → realtime-DmPE9S54.mjs} +25 -21
  156. package/dist/redis-stream-BMk-bpQS.d.mts +219 -0
  157. package/dist/{redis-Bzi1y3F9.d.mts → redis-vLvtcGy_.d.mts} +13 -24
  158. package/dist/redisShapes-CQksY67q.d.mts +55 -0
  159. package/dist/registry/index.d.mts +9 -4
  160. package/dist/registry/index.mjs +4 -4
  161. package/dist/{registry-D3y8VEJ1.mjs → registry-Bcz9G1bh.mjs} +10 -8
  162. package/dist/{renewingLease-B3VYh5aU.mjs → renewingLease-qODa7KJQ.mjs} +1 -1
  163. package/dist/{replyHelpers-DPHsdw-5.mjs → replyHelpers-CG_VtVgS.mjs} +2 -2
  164. package/dist/{requestContext-cSVKnYPk.mjs → requestContext-Dsu9P1bV.mjs} +1 -1
  165. package/dist/requirement-CbiLdQ1n.d.mts +22 -0
  166. package/dist/requirement-KDdoRcOP.mjs +35 -0
  167. package/dist/resourceRateLimit-Wf6d6FL9.d.mts +30 -0
  168. package/dist/resourceRateLimit-dSjVr1pk.mjs +180 -0
  169. package/dist/{rolldown-runtime-DjDuyglE.mjs → rolldown-runtime-D1w9Y1VJ.mjs} +1 -1
  170. package/dist/routeValidator-D36SW8qt.mjs +70 -0
  171. package/dist/{runtimeCapabilities-DSA_dH1_.mjs → runtimeCapabilities-0tccjRlj.mjs} +1 -1
  172. package/dist/{schedules-BktJb3Y3.mjs → schedules-C-OpmHBq.mjs} +46 -37
  173. package/dist/{schemaConverter-BLCSmNbr.mjs → schemaConverter-BORek3O-.mjs} +2 -2
  174. package/dist/{schemaIR-K16Bb5BX.mjs → schemaIR-C7Vbp71I.mjs} +2 -2
  175. package/dist/schemas/index.d.mts +10 -0
  176. package/dist/schemas/index.mjs +8 -3
  177. package/dist/scim/index.mjs +2 -2
  178. package/dist/scope/index.d.mts +2 -2
  179. package/dist/scope/index.mjs +6 -6
  180. package/dist/{scope-jSxuBTFM.mjs → scope-DQthe88b.mjs} +4 -4
  181. package/dist/shutdown-MqA81z_P.mjs +59 -0
  182. package/dist/{simpleEqualityMatcher-BiOnyflP.mjs → simpleEqualityMatcher-auW8QjXN.mjs} +1 -1
  183. package/dist/{sse-CH7Slxc1.mjs → sse-CYzQBVFe.mjs} +11 -15
  184. package/dist/{sseStream-CN-Lh1Tu.mjs → sseStream-B5_Afiqn.mjs} +2 -2
  185. package/dist/{store-helpers-9DX9-1j_.mjs → store-helpers-BOzk-CuX.mjs} +1 -1
  186. package/dist/{streaming-dvppnxsr.mjs → streaming-DjTQhZrN.mjs} +1 -1
  187. package/dist/subscriberIdentity-CW39uA8G.mjs +28 -0
  188. package/dist/tenancy--ed9eH_z.mjs +43 -0
  189. package/dist/tenantPreparation-BKon6Yox.mjs +67 -0
  190. package/dist/testing/cleanupStoreContract.mjs +1 -1
  191. package/dist/testing/index.d.mts +193 -175
  192. package/dist/testing/index.mjs +286 -212
  193. package/dist/testing/outboxStoreContract.mjs +137 -2
  194. package/dist/testing/requiredPorts.d.mts +17 -0
  195. package/dist/testing/requiredPorts.mjs +65 -0
  196. package/dist/testing/storageContract.mjs +1 -1
  197. package/dist/timing-qczZ0YC-.mjs +46 -0
  198. package/dist/{transactionContext-DCWMI6hV.mjs → transactionContext-DjFkrNG6.mjs} +1 -1
  199. package/dist/{typeGuards-D7tHFdH6.mjs → typeGuards-CySF12G-.mjs} +1 -1
  200. package/dist/types/index.d.mts +4 -4
  201. package/dist/types/index.mjs +1 -1
  202. package/dist/types/storage.mjs +1 -1
  203. package/dist/{types-CWQn9-IC.d.mts → types-B4Aq4Sdj.d.mts} +22 -9
  204. package/dist/{types-DfVDvkdP.d.mts → types-BJ-2XqVF.d.mts} +189 -204
  205. package/dist/{types-CDy3-zw1.mjs → types-Bk9NOgOC.mjs} +4 -4
  206. package/dist/usage/index.d.mts +2 -3
  207. package/dist/usage/index.mjs +5 -5
  208. package/dist/{userHelpers-DDMOZ_Pm.mjs → userHelpers-aklawRaK.mjs} +1 -1
  209. package/dist/utils/index.d.mts +84 -223
  210. package/dist/utils/index.mjs +13 -12
  211. package/dist/{utils-B-zj_b__.mjs → utils-ec1oTo1n.mjs} +315 -404
  212. package/dist/{validate-R0G_JrnK.mjs → validate-9yzHC1e2.mjs} +6 -14
  213. package/dist/{versioning-D6JIK8Tk.d.mts → versioning-Bsb51gGT.d.mts} +28 -13
  214. package/dist/{versioning-DuVWcu-F.mjs → versioning-fhhWHV74.mjs} +4 -4
  215. package/dist/{websocket-BOXVggMf.d.mts → websocket-Cs4awVyD.d.mts} +1 -1
  216. package/dist/{workScope-BhsWde4k.mjs → workScope-zpPyge4t.mjs} +2 -2
  217. package/package.json +578 -574
  218. package/skills/arc/SKILL.md +13 -4
  219. package/skills/arc/references/auth.md +8 -5
  220. package/skills/arc/references/integrations.md +1 -1
  221. package/skills/arc/references/mcp.md +8 -6
  222. package/skills/arc/references/multi-tenancy.md +19 -14
  223. package/skills/arc/references/production.md +27 -10
  224. package/dist/actionPermissions-DPwPWPOV.mjs +0 -350
  225. package/dist/index-CFfLKMH4.d.mts +0 -290
  226. package/dist/interface-BIpgzbZl.mjs +0 -17
  227. package/dist/redis-stream-BPKscsTB.d.mts +0 -260
  228. package/dist/registerRoute-DRsSHPSM.mjs +0 -364
  229. package/dist/sync/changelog.d.mts +0 -3
  230. package/dist/sync/changelog.mjs +0 -3
@@ -1,215 +1,239 @@
1
- /*! @classytic/arc v2.41.0 | MIT | https://classytic.com */
2
- import { m as RESERVED_QUERY_PARAMS } from "./constants-BxLgNFF7.mjs";
3
- import { arcLog } from "./logger/index.mjs";
4
- import { t as ArcError, u as ValidationError } from "./errors-C_YhELhe.mjs";
5
- import { scheduleBackground } from "@classytic/repo-core/cache";
6
- import { errorContractSchema as errorContractSchema$1, errorDetailSchema as errorDetailSchema$1 } from "@classytic/repo-core/errors";
1
+ /*! @classytic/arc v2.43.0 | MIT | https://classytic.com */
2
+ import { h as RESERVED_QUERY_PARAMS } from "./constants-Cu7Mx7_U.mjs";
3
+ import { t as arcLog } from "./logger-DXjfIqjV.mjs";
4
+ import { t as ArcError, u as ValidationError } from "./errors-CtrywMHP.mjs";
5
+ import { errorContractSchema as errorContractSchema$1 } from "@classytic/repo-core/errors";
6
+ import { readEnv } from "@classytic/primitives/environment";
7
+ import { OPERATOR_DESCRIPTIONS, URL_OPERATORS, escapeRegex, isQueryGrammarError, readFilterClauses, readPageRequest, readSearch, readSelect, readSort } from "@classytic/repo-core/query-parser";
7
8
  //#region src/utils/filter-dialect.ts
8
9
  /**
9
- * Default dialect — MongoDB / MongoKit operator shape. Emits `$`-prefixed
10
- * tokens and the `$options: 'i'` case-insensitive sibling. This is what
11
- * `ArcQueryParser` uses when no dialect is supplied, so behaviour is identical
12
- * to every prior arc version.
10
+ * Filter dialects — what `ArcQueryParser` EMITS. Reading a URL (keys, operators, coercion,
11
+ * refusals) is repo-core's shared grammar; a dialect turns its canonical clauses into one store's
12
+ * filter shape. The default is the Mongo record dialect arc's pipeline already speaks
13
+ * (`toRepositoryFilter` → Filter IR), so its meaning matches `parseUrl` and mongokit exactly —
14
+ * `runQueryGrammarConformance` holds all three to the same rows.
13
15
  */
16
+ /** One clause → one field condition, in Mongo record syntax with the Filter IR's meaning. */
17
+ function mongoCondition(clause) {
18
+ switch (clause.op) {
19
+ case "eq": return clause.value;
20
+ case "ne": return clause.value === null ? { $ne: null } : { $nin: [clause.value, null] };
21
+ case "nin": return { $nin: clause.values.includes(null) ? [...clause.values] : [...clause.values, null] };
22
+ case "in": return { $in: [...clause.values] };
23
+ case "gt":
24
+ case "gte":
25
+ case "lt":
26
+ case "lte": return { [`$${clause.op}`]: clause.value };
27
+ case "exists": return clause.value ? { $ne: null } : null;
28
+ case "contains": return {
29
+ $regex: escapeRegex(clause.text),
30
+ $options: "i"
31
+ };
32
+ case "startsWith": return {
33
+ $regex: `^${escapeRegex(clause.text)}`,
34
+ $options: "i"
35
+ };
36
+ case "endsWith": return {
37
+ $regex: `${escapeRegex(clause.text)}$`,
38
+ $options: "i"
39
+ };
40
+ case "ieq": return {
41
+ $regex: `^${escapeRegex(clause.text)}$`,
42
+ $options: "i"
43
+ };
44
+ case "regex": return clause.flags ? {
45
+ $regex: clause.pattern,
46
+ $options: clause.flags
47
+ } : { $regex: clause.pattern };
48
+ }
49
+ }
50
+ /** Merge conditions on one field into one operator object; `$and` when an operator repeats. */
51
+ function mergeByField(clauses, condition, eqToken) {
52
+ const merged = {};
53
+ const overflow = [];
54
+ for (const clause of clauses) {
55
+ const next = condition(clause);
56
+ if (!(clause.field in merged)) {
57
+ merged[clause.field] = next;
58
+ continue;
59
+ }
60
+ const left = asOperators(merged[clause.field], eqToken);
61
+ const right = asOperators(next, eqToken);
62
+ if (left && right && !Object.keys(right).some((op) => op in left)) merged[clause.field] = {
63
+ ...left,
64
+ ...right
65
+ };
66
+ else overflow.push({ [clause.field]: next });
67
+ }
68
+ return overflow.length > 0 ? {
69
+ ...merged,
70
+ $and: overflow
71
+ } : merged;
72
+ }
73
+ function asOperators(condition, eqToken) {
74
+ if (condition === null || Array.isArray(condition) || condition instanceof Date) return condition === null ? void 0 : { [eqToken]: condition };
75
+ if (typeof condition === "object") return condition;
76
+ return { [eqToken]: condition };
77
+ }
14
78
  const MONGO_DIALECT = {
15
- operators: {
16
- eq: "$eq",
17
- ne: "$ne",
18
- gt: "$gt",
19
- gte: "$gte",
20
- lt: "$lt",
21
- lte: "$lte",
22
- in: "$in",
23
- nin: "$nin",
24
- like: "$regex",
25
- contains: "$regex",
26
- regex: "$regex",
27
- exists: "$exists"
28
- },
29
- caseInsensitiveModifier: () => ({ $options: "i" })
79
+ emit: (clauses) => mergeByField(clauses, mongoCondition, "$eq"),
80
+ tokens: [
81
+ "$eq",
82
+ "$ne",
83
+ "$gt",
84
+ "$gte",
85
+ "$lt",
86
+ "$lte",
87
+ "$in",
88
+ "$nin",
89
+ "$regex",
90
+ "$options",
91
+ "$and"
92
+ ]
30
93
  };
31
94
  /**
32
- * Store-neutral reference dialect — emits UN-prefixed operator tokens as a
33
- * translation AST (`?price[gte]=40` → `{ price: { gte: 40 } }`, with
34
- * `{ caseInsensitive: true }` for `like`/`contains`). A non-Mongo adapter can
35
- * consume this directly instead of writing a full `QueryParserInterface`. Use
36
- * as a starting point; adjust the token names to your store's vocabulary.
95
+ * Store-neutral: `{ field: { <op>: value } }` with the grammar's own operator names, for a host
96
+ * that compiles the canonical clauses itself.
37
97
  */
38
98
  const NEUTRAL_DIALECT = {
39
- operators: {
40
- eq: "eq",
41
- ne: "ne",
42
- gt: "gt",
43
- gte: "gte",
44
- lt: "lt",
45
- lte: "lte",
46
- in: "in",
47
- nin: "nin",
48
- like: "like",
49
- contains: "contains",
50
- regex: "regex",
51
- exists: "exists"
52
- },
53
- caseInsensitiveModifier: () => ({ caseInsensitive: true })
99
+ emit: (clauses) => mergeByField(clauses, (clause) => {
100
+ switch (clause.op) {
101
+ case "in":
102
+ case "nin": return { [clause.op]: clause.values };
103
+ case "contains":
104
+ case "startsWith":
105
+ case "endsWith":
106
+ case "ieq": return { [clause.op]: clause.text };
107
+ case "regex": return clause.flags ? {
108
+ regex: clause.pattern,
109
+ flags: clause.flags
110
+ } : { regex: clause.pattern };
111
+ default: return { [clause.op]: clause.value };
112
+ }
113
+ }, "eq"),
114
+ tokens: [
115
+ "eq",
116
+ "ne",
117
+ "gt",
118
+ "gte",
119
+ "lt",
120
+ "lte",
121
+ "in",
122
+ "nin",
123
+ "exists",
124
+ "contains",
125
+ "startsWith",
126
+ "endsWith",
127
+ "ieq",
128
+ "regex",
129
+ "flags",
130
+ "$and"
131
+ ]
54
132
  };
55
133
  //#endregion
56
134
  //#region src/utils/queryParser.ts
57
135
  /**
58
- * Arc Query Parser - Default URL-to-Query Parser
59
- *
60
- * Framework-agnostic query parser that converts URL parameters to query options.
61
- * This is Arc's built-in parser; users can swap in MongoKit's QueryParser,
62
- * pgkit's parser, or any custom parser implementing QueryParserInterface.
63
- *
64
- * @example
65
- * // Use Arc default parser (auto-applied if no queryParser option)
66
- * defineResource({ name: 'product', adapter: ... });
67
- *
68
- * // Use MongoKit's QueryParser (recommended for MongoDB - has $lookup, aggregations, etc.)
69
- * // Fail-closed by default (mongokit >=3.25): invalid/blocked query input
70
- * // becomes HTTP 400 instead of a silently broadened result set.
71
- * import { QueryParser } from '@classytic/mongokit';
72
- * defineResource({
73
- * name: 'product',
74
- * adapter: ...,
75
- * queryParser: new QueryParser(),
76
- * });
77
- *
78
- * // Use custom parser for SQL databases
79
- * defineResource({
80
- * name: 'user',
81
- * adapter: ...,
82
- * queryParser: new PgQueryParser(),
83
- * });
136
+ * Arc's default URL-to-query parser. Reading the URL is repo-core's shared grammar (the same
137
+ * contract as mongokit's `QueryParser` and `parseUrl`); this adds arc's populate handling and
138
+ * emits filters through a {@link FilterDialect}. Swap in any `QueryParserInterface` via
139
+ * `defineResource({ queryParser })`.
84
140
  */
85
141
  const log = arcLog("queryParser");
142
+ const FIELD_RE = /^[A-Za-z_][A-Za-z0-9_-]*(?:\.[A-Za-z0-9_-]+)*$/;
86
143
  /**
87
- * Regex patterns that can cause catastrophic backtracking (ReDoS attacks)
88
- * Detects:
89
- * - Quantifiers: {n,m}
90
- * - Possessive quantifiers: *+, ++, ?+
91
- * - Nested quantifiers: (a+)+, (a*)*
92
- * - Backreferences: \1, \2, etc.
93
- */
94
- const DANGEROUS_REGEX_PATTERNS = /(\{[0-9,]+\}|\*\+|\+\+|\?\+|(\(.+\))\+|\(\?:|\\[0-9]|(\[.+\]).+(\[.+\]))/;
95
- /**
96
- * Arc's default query parser
97
- *
98
- * Converts URL query parameters to a structured query format:
99
- * - Pagination: ?page=1&limit=20
100
- * - Sorting: ?sort=-createdAt,name (- prefix = descending)
101
- * - Filtering — TWO supported forms (both produce identical output):
102
- * - Bare (default, terse): `?status=active&price[gte]=100&price[lte]=500`
103
- * - Bracket envelope: `?filter[status]=active&filter[price][gte]=100`
104
- * The envelope form matches the convention used by JSON:API / Stripe /
105
- * most modern REST style guides, and is the safe choice on busy URLs
106
- * that might otherwise collide with reserved meta keys (`page`, `sort`,
107
- * `select`, `populate`, etc.). Operator notation (`[gte]`, `[lte]`,
108
- * `[ne]`, `[in]`, ...) works inside either form.
109
- * - Search: ?search=keyword
110
- * - Populate: ?populate=author,category
111
- * - Field selection: ?select=name,price,status
112
- * - Keyset pagination: ?after=cursor_value
113
- *
114
- * **NOTE on parser portability.** `ArcQueryParser` accepts both filter
115
- * forms. Other parser implementations (`@classytic/mongokit`'s
116
- * `QueryParser`, custom hosts-side parsers) may accept only one form —
117
- * MongoKit historically only accepts the bare form. If your host swaps
118
- * `queryParser` to a different implementation, double-check which forms
119
- * it understands before publishing URLs that mix the two.
120
- *
121
- * For advanced MongoDB features ($lookup, aggregations), use MongoKit's QueryParser.
144
+ * Arc's default query parser:
145
+ * - Paging `?page=1&limit=20`, keyset `?after=cursor`; sort `?sort=-createdAt,name`
146
+ * - Filters bare (`?status=active&price[gte]=100`) or in the `?filter[...]` envelope, which
147
+ * avoids collisions with reserved keys (`page`, `sort`, ...)
148
+ * - Search `?search=`, populate `?populate=a,b`, select `?select=a,-b`
122
149
  */
123
150
  var ArcQueryParser = class {
124
151
  /**
125
- * PUBLIC, deliberately: `QueryResolver` reads it so it does not apply a second cap
126
- * on top of this one. Three layers capping independently, lowest winning silently,
127
- * is what made a 696-row chart of accounts render as 100.
152
+ * Public so `QueryResolver` reuses this cap instead of stacking its own
153
+ * (independent caps silently truncate to the lowest).
128
154
  */
129
155
  maxLimit;
130
156
  defaultLimit;
131
157
  maxRegexLength;
132
158
  maxSearchLength;
133
- maxFilterDepth;
134
- _allowedFilterFields;
135
- _allowedSortFields;
136
- _allowedOperators;
137
- strictFilterFields;
159
+ strict;
160
+ dialect;
138
161
  /** Allowed filter fields (used by MCP for auto-derive) */
139
162
  allowedFilterFields;
140
163
  /** Allowed sort fields (used by MCP for sort descriptions) */
141
164
  allowedSortFields;
142
165
  /** Allowed operators (used by MCP for operator descriptions) */
143
166
  allowedOperators;
144
- /**
145
- * Datastore dialect — owns the store-specific operator tokens + the
146
- * case-insensitive modifier. Defaults to Mongo (backward-compatible).
147
- */
148
- dialect;
149
- /** Supported filter operators (neutral name → emitted token), from the dialect. */
150
- get operators() {
151
- return this.dialect.operators;
152
- }
153
167
  constructor(options = {}) {
154
168
  this.dialect = options.dialect ?? MONGO_DIALECT;
155
169
  this.maxLimit = options.maxLimit ?? 1e3;
156
170
  this.defaultLimit = options.defaultLimit ?? 20;
157
171
  this.maxRegexLength = options.maxRegexLength ?? 200;
158
172
  this.maxSearchLength = options.maxSearchLength ?? 200;
159
- this.maxFilterDepth = options.maxFilterDepth ?? 10;
160
- this.strictFilterFields = options.strictFilterFields ?? process.env.ARC_STRICT_QUERY_PARAMS === "true";
161
- if (options.allowedFilterFields) {
162
- this._allowedFilterFields = new Set(options.allowedFilterFields);
163
- this.allowedFilterFields = options.allowedFilterFields;
164
- }
165
- if (options.allowedSortFields) {
166
- this._allowedSortFields = new Set(options.allowedSortFields);
167
- this.allowedSortFields = options.allowedSortFields;
168
- }
169
- if (options.allowedOperators) {
170
- this._allowedOperators = new Set(options.allowedOperators);
171
- this.allowedOperators = options.allowedOperators;
172
- }
173
+ this.strict = options.strictFilterFields ?? readEnv(process.env).bool("ARC_STRICT_QUERY_PARAMS", true);
174
+ if (options.allowedFilterFields) this.allowedFilterFields = options.allowedFilterFields;
175
+ if (options.allowedSortFields) this.allowedSortFields = options.allowedSortFields;
176
+ if (options.allowedOperators) this.allowedOperators = options.allowedOperators;
173
177
  }
174
- /**
175
- * Parse URL query parameters into structured query options
176
- */
178
+ /** Parse URL query parameters into structured query options. Invalid input is a 400. */
177
179
  parse(query) {
178
180
  const q = query ?? {};
179
- const page = this.parseNumber(q.page, 1);
180
- const limit = Math.min(this.parseNumber(q.limit, this.defaultLimit), this.maxLimit);
181
- const after = this.parseString(q.after ?? q.cursor);
182
- const sort = this.parseSort(q.sort);
181
+ const onInvalid = this.strict ? void 0 : (error) => {
182
+ if (error.meta.kind !== "policy") throw error;
183
+ this.drop(error);
184
+ };
185
+ const page = this.read(() => readPageRequest({
186
+ page: q.page,
187
+ limit: q.limit,
188
+ after: q.after,
189
+ cursor: q.cursor
190
+ }, {
191
+ defaultLimit: this.defaultLimit,
192
+ maxLimit: this.maxLimit
193
+ }), { limit: Math.min(this.defaultLimit, this.maxLimit) });
194
+ const { clauses } = this.read(() => readFilterClauses(q, {
195
+ allowedFilterFields: this.allowedFilterFields,
196
+ allowedOperators: this.allowedOperators,
197
+ maxTextLength: this.maxRegexLength,
198
+ reservedParams: [...RESERVED_QUERY_PARAMS],
199
+ onInvalid
200
+ }), {
201
+ clauses: [],
202
+ extensions: []
203
+ });
183
204
  const { populate, populateOptions } = this.parsePopulate(q.populate);
184
- const search = this.parseSearch(q.search);
185
- const select = this.parseSelect(q.select);
186
205
  return {
187
- filters: this.parseFilters(q),
188
- limit,
189
- sort,
206
+ filters: this.dialect.emit(clauses),
207
+ limit: page.limit,
208
+ sort: this.read(() => readSort(q.sort, {
209
+ allowedSortFields: this.allowedSortFields,
210
+ onInvalid
211
+ }), void 0),
190
212
  populate,
191
213
  populateOptions,
192
- search,
193
- page: after ? void 0 : page,
194
- after,
195
- select
214
+ search: this.read(() => readSearch(q.search, { maxSearchLength: this.maxSearchLength }), void 0),
215
+ page: page.after ? void 0 : page.page ?? 1,
216
+ after: page.after,
217
+ select: this.read(() => readSelect(q.select, { onInvalid }), void 0)
196
218
  };
197
219
  }
198
- parseNumber(value, defaultValue) {
199
- if (value === void 0 || value === null) return defaultValue;
200
- const num = parseInt(String(value), 10);
201
- return Number.isNaN(num) ? defaultValue : Math.max(1, num);
220
+ /** A grammar refusal is a 400 (`ValidationError`) — unless lenient and only an allowlist miss. */
221
+ read(fn, fallback) {
222
+ try {
223
+ return fn();
224
+ } catch (error) {
225
+ if (!isQueryGrammarError(error)) throw error;
226
+ if (this.strict || error.meta.kind !== "policy") throw toValidationError(error);
227
+ this.drop(error);
228
+ return fallback;
229
+ }
202
230
  }
203
- parseString(value) {
204
- if (value === void 0 || value === null) return void 0;
205
- const str = String(value).trim();
206
- return str.length > 0 ? str : void 0;
231
+ drop(error) {
232
+ log.warn(`query input dropped (ARC_STRICT_QUERY_PARAMS=false): ${error.message}`);
207
233
  }
208
234
  /**
209
- * Parse populate parameter — handles both simple string and bracket notation.
210
- *
211
- * Simple: ?populate=author,category → { populate: 'author,category' }
212
- * Bracket: ?populate[author][select]=name,email → { populateOptions: [{ path: 'author', select: 'name email' }] }
235
+ * Simple `?populate=author,category`, or bracket `?populate[author][select]=name,email` and
236
+ * `?populate[author][match][field]=value` (field equality only).
213
237
  */
214
238
  parsePopulate(value) {
215
239
  if (value === void 0 || value === null) return {};
@@ -218,18 +242,14 @@ var ArcQueryParser = class {
218
242
  return trimmed.length > 0 ? { populate: trimmed } : {};
219
243
  }
220
244
  if (typeof value === "object" && !Array.isArray(value)) {
221
- const obj = value;
222
- const keys = Object.keys(obj);
223
- if (keys.length === 0) return {};
224
245
  const options = [];
225
- for (const path of keys) {
226
- if (!/^[a-zA-Z_][a-zA-Z0-9_.]*$/.test(path)) continue;
227
- const config = obj[path];
246
+ for (const [path, config] of Object.entries(value)) {
247
+ if (!FIELD_RE.test(path)) throw new ValidationError(`Populate path "${path}" is not a field path`);
228
248
  if (typeof config === "object" && config !== null && !Array.isArray(config)) {
229
249
  const cfg = config;
230
250
  const option = { path };
231
251
  if (typeof cfg.select === "string") option.select = cfg.select.split(",").map((s) => s.trim()).filter(Boolean).join(" ");
232
- if (typeof cfg.match === "object" && cfg.match !== null) option.match = cfg.match;
252
+ if (cfg.match !== void 0) option.match = this.parsePopulateMatch(path, cfg.match);
233
253
  options.push(option);
234
254
  } else options.push({ path });
235
255
  }
@@ -237,177 +257,20 @@ var ArcQueryParser = class {
237
257
  }
238
258
  return {};
239
259
  }
240
- parseSort(value) {
241
- if (!value) return void 0;
242
- const sortStr = String(value);
243
- const result = {};
244
- for (const field of sortStr.split(",")) {
245
- const trimmed = field.trim();
246
- if (!trimmed) continue;
247
- if (!/^-?[a-zA-Z_][a-zA-Z0-9_.]*$/.test(trimmed)) continue;
248
- const fieldName = trimmed.startsWith("-") ? trimmed.slice(1) : trimmed;
249
- if (this._allowedSortFields && !this._allowedSortFields.has(fieldName)) continue;
250
- if (trimmed.startsWith("-")) result[fieldName] = -1;
251
- else result[fieldName] = 1;
252
- }
253
- return Object.keys(result).length > 0 ? result : void 0;
254
- }
255
- parseSearch(value) {
256
- if (!value) return void 0;
257
- const search = String(value).trim();
258
- if (search.length === 0) return void 0;
259
- if (search.length > this.maxSearchLength) return search.slice(0, this.maxSearchLength);
260
- return search;
261
- }
262
- parseSelect(value) {
263
- if (!value) return void 0;
264
- const selectStr = String(value);
265
- const result = {};
266
- for (const field of selectStr.split(",")) {
267
- const trimmed = field.trim();
268
- if (!trimmed) continue;
269
- if (!/^-?[a-zA-Z_][a-zA-Z0-9_.]*$/.test(trimmed)) continue;
270
- if (trimmed.startsWith("-")) result[trimmed.slice(1)] = 0;
271
- else result[trimmed] = 1;
272
- }
273
- return Object.keys(result).length > 0 ? result : void 0;
274
- }
275
- /**
276
- * Check if a value exceeds the maximum nesting depth.
277
- * Prevents filter bombs where deeply nested objects consume excessive memory/CPU.
278
- */
279
- exceedsDepth(obj, currentDepth = 0) {
280
- if (currentDepth > this.maxFilterDepth) return true;
281
- if (obj === null || obj === void 0) return false;
282
- if (Array.isArray(obj)) return obj.some((v) => this.exceedsDepth(v, currentDepth));
283
- if (typeof obj !== "object") return false;
284
- return Object.values(obj).some((v) => this.exceedsDepth(v, currentDepth + 1));
285
- }
286
- /**
287
- * Parse all filter entries from the query, supporting BOTH the bare
288
- * top-level form and the bracket-wrapped `filter[...]` envelope.
289
- *
290
- * Two equivalent ways to express the same query:
291
- * - Bare (legacy, arc default): `?status=active&price[gte]=40`
292
- * - Bracket envelope (REST convention): `?filter[status]=active&filter[price][gte]=40`
293
- *
294
- * Both forms parse to the same `filters: { status: 'active', price: { $gte: 40 } }`.
295
- * The envelope form mirrors the convention used by most REST API style
296
- * guides (JSON:API, Stripe API, etc.) and disambiguates filter fields
297
- * from reserved meta params on busy URLs — a query string with a field
298
- * literally named `page` or `sort` can't collide with the framework's
299
- * meta keys when wrapped under `filter[...]`. The bare form stays as
300
- * the default since it's the shortest path for simple queries; expect
301
- * it to remain supported indefinitely.
302
- *
303
- * Precedence when the same key appears in both forms:
304
- * `?status=closed&filter[status]=active` → bare wins (`status: closed`).
305
- * Mixing should be rare; the deterministic rule avoids silent surprises.
306
- */
307
- parseFilters(query) {
308
- const filters = {};
309
- const envelope = query.filter;
310
- if (typeof envelope === "object" && envelope !== null && !Array.isArray(envelope)) for (const [key, value] of Object.entries(envelope)) this.applyFilterEntry(filters, key, value);
311
- for (const [key, value] of Object.entries(query)) {
312
- if (RESERVED_QUERY_PARAMS.has(key)) continue;
313
- this.applyFilterEntry(filters, key, value);
314
- }
315
- return filters;
316
- }
317
260
  /**
318
- * Apply one filter entry — used by both the bracket-envelope branch and
319
- * the bare top-level branch of `parseFilters`. Centralising the field-
320
- * validation + operator-conversion logic keeps the two forms in
321
- * lockstep; a future regex/security tweak only changes one place.
261
+ * A populate `match` is plain field EQUALITY in bracket form (`populate[p][match][f]=v`). A `$`
262
+ * key, a nested object or a non-object `match` (a JSON string) is refused, never passed to the
263
+ * driver or dropped — a dropped match populates every document.
322
264
  */
323
- applyFilterEntry(filters, key, value) {
324
- if (value === void 0 || value === null) return;
325
- if (!/^[a-zA-Z_][a-zA-Z0-9_.]*(?:\[[a-z]+\])?$/.test(key)) return;
326
- const bareKey = key.replace(/\[[a-z]+\]$/, "");
327
- if (this._allowedFilterFields && !this._allowedFilterFields.has(bareKey)) {
328
- if (this.strictFilterFields) throw new ValidationError(`Unknown filter field: ${bareKey}. Allowed: ${[...this._allowedFilterFields].sort().join(", ")}`);
329
- return;
330
- }
331
- if (this.exceedsDepth(value)) return;
332
- if (typeof value === "object" && value !== null && !Array.isArray(value)) {
333
- const operatorObj = value;
334
- const operatorKeys = Object.keys(operatorObj);
335
- const allOperators = operatorKeys.every((op) => this.operators[op] && (!this._allowedOperators || this._allowedOperators.has(op)));
336
- const allKnownOperators = operatorKeys.every((op) => this.operators[op]);
337
- if (allOperators && operatorKeys.length > 0) {
338
- const fieldOps = {};
339
- let needsCaseInsensitive = false;
340
- for (const [op, opValue] of Object.entries(operatorObj)) {
341
- const token = this.operators[op];
342
- if (token) {
343
- fieldOps[token] = this.parseFilterValue(opValue, op);
344
- if (op === "contains" || op === "like") needsCaseInsensitive = true;
345
- }
346
- }
347
- if (needsCaseInsensitive) Object.assign(fieldOps, this.dialect.caseInsensitiveModifier());
348
- filters[key] = fieldOps;
349
- return;
350
- }
351
- if (allKnownOperators && this._allowedOperators) return;
352
- }
353
- const match = key.match(/^([a-zA-Z_][a-zA-Z0-9_.]*)(?:\[([a-z]+)\])?$/);
354
- if (!match) return;
355
- const [, fieldName, operator] = match;
356
- if (!fieldName) return;
357
- if (operator && this.operators[operator] && (!this._allowedOperators || this._allowedOperators.has(operator))) {
358
- const token = this.operators[operator];
359
- const parsedValue = this.parseFilterValue(value, operator);
360
- if (!filters[fieldName]) filters[fieldName] = {};
361
- const fieldFilter = filters[fieldName];
362
- fieldFilter[token] = parsedValue;
363
- if (operator === "contains" || operator === "like") Object.assign(fieldFilter, this.dialect.caseInsensitiveModifier());
364
- } else if (!operator) filters[fieldName] = this.parseFilterValue(value);
365
- }
366
- parseFilterValue(value, operator) {
367
- if (operator === "in" || operator === "nin") {
368
- if (Array.isArray(value)) return value.map((v) => this.coerceValue(v));
369
- if (typeof value === "string" && value.includes(",")) return value.split(",").map((v) => this.coerceValue(v.trim()));
370
- return [this.coerceValue(value)];
371
- }
372
- if (operator === "like" || operator === "contains" || operator === "regex") return this.sanitizeRegex(String(value));
373
- if (operator === "exists") {
374
- const str = String(value).toLowerCase();
375
- return str === "true" || str === "1";
376
- }
377
- return this.coerceValue(value);
378
- }
379
- coerceValue(value) {
380
- if (value === "true") return true;
381
- if (value === "false") return false;
382
- if (value === "null") return null;
383
- if (typeof value === "string") {
384
- const num = Number(value);
385
- if (!Number.isNaN(num) && value.trim() !== "") return num;
386
- }
387
- return value;
265
+ parsePopulateMatch(path, match) {
266
+ if (typeof match !== "object" || match === null || Array.isArray(match)) throw new ValidationError(`Populate match on "${path}" must use bracket form: populate[${path}][match][field]=value`);
267
+ for (const [key, value] of Object.entries(match)) if (!FIELD_RE.test(key) || typeof value === "object" && value !== null) throw new ValidationError(`Populate match on "${path}" accepts field equality only: "${key}"`);
268
+ return match;
388
269
  }
389
- /**
390
- * Generate OpenAPI-compatible JSON Schema for query parameters.
391
- * Arc's defineResource() auto-detects this method and uses it
392
- * to document list endpoint query parameters in OpenAPI/Swagger.
393
- */
270
+ /** JSON Schema for list query parameters (auto-detected by `defineResource`). */
394
271
  getQuerySchema() {
395
- const operatorLines = Object.entries(this.operators).map(([op, mongoOp]) => {
396
- return ` ${op} → ${mongoOp}: ${{
397
- eq: "Equal (default when no operator specified)",
398
- ne: "Not equal",
399
- gt: "Greater than",
400
- gte: "Greater than or equal",
401
- lt: "Less than",
402
- lte: "Less than or equal",
403
- in: "In list (comma-separated values)",
404
- nin: "Not in list",
405
- like: "Pattern match (case-insensitive)",
406
- contains: "Contains substring (case-insensitive)",
407
- regex: "Regex pattern",
408
- exists: "Field exists (true/false)"
409
- }[op] || op}`;
410
- });
272
+ const allowed = this.allowedOperators;
273
+ const operators = allowed ? URL_OPERATORS.filter((op) => allowed.includes(op)) : URL_OPERATORS;
411
274
  return {
412
275
  type: "object",
413
276
  properties: {
@@ -420,26 +283,9 @@ var ArcQueryParser = class {
420
283
  limit: {
421
284
  type: "integer",
422
285
  /**
423
- * The cap is DOCUMENTED here, not enforced — deliberately.
424
- *
425
- * Both layers that handle `limit` CLAMP it: this parser
426
- * (`Math.min(…, this.maxLimit)`) and `QueryResolver`. Emitting
427
- * `maximum` contradicted them, because arc layers this schema as the
428
- * route's `listQuery` and Fastify validates the querystring BEFORE
429
- * either runs — so the clamp was unreachable and `?limit=200` became a
430
- * hard 400.
431
- *
432
- * That combination is worse than either policy alone. Asking for more
433
- * rows than allowed is a benign over-ask with an obvious right answer
434
- * (give the maximum); turning it into a validation failure means a UI
435
- * passing a generous page size gets NOTHING, and a caller that does not
436
- * inspect the error body renders it as an empty list — a 400 disguised
437
- * as "no results", which is the hardest kind to find.
438
- *
439
- * A deployment wanting refusal instead of clamping should say so in the
440
- * parser's policy, where both halves can agree, not in a schema that
441
- * silently outranks them. Mirrors the same fix in mongokit's
442
- * `buildQuerySchema`.
286
+ * No `maximum` — deliberately. This schema validates the querystring
287
+ * BEFORE the parser/QueryResolver clamp runs, so a `maximum` would turn
288
+ * a benign over-ask into a 400 instead of capping it.
443
289
  */
444
290
  description: `Number of items per page (values above ${this.maxLimit} are capped to ${this.maxLimit})`,
445
291
  default: this.defaultLimit,
@@ -466,29 +312,34 @@ var ArcQueryParser = class {
466
312
  type: "string",
467
313
  description: "Cursor value for keyset pagination"
468
314
  },
315
+ before: {
316
+ type: "string",
317
+ description: "Cursor from a page's `prev` — fetch the page before it"
318
+ },
319
+ mode: {
320
+ type: "string",
321
+ enum: ["offset", "keyset"],
322
+ description: "Pagination mode (default offset). `keyset` walks by cursor"
323
+ },
469
324
  _filterOperators: {
470
325
  type: "string",
471
- description: ["Available filter operators (use as field[operator]=value):", ...operatorLines].join("\n")
326
+ description: ["Available filter operators (use as field[operator]=value):", ...operators.map((op) => ` ${op}: ${OPERATOR_DESCRIPTIONS[op] ?? op}`)].join("\n")
472
327
  }
473
328
  }
474
329
  };
475
330
  }
476
- sanitizeRegex(pattern) {
477
- const truncated = pattern.length > this.maxRegexLength;
478
- let sanitized = pattern.slice(0, this.maxRegexLength);
479
- if (DANGEROUS_REGEX_PATTERNS.test(sanitized)) {
480
- sanitized = sanitized.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
481
- log.warn(`regex pattern matched a ReDoS-dangerous shape and was escaped to a literal string. original="${pattern}" sanitized="${sanitized}"`);
482
- } else if (truncated) log.warn(`regex pattern exceeded maxRegexLength (${this.maxRegexLength}) and was truncated. original-length=${pattern.length}`);
483
- return sanitized;
484
- }
485
331
  };
486
- /**
487
- * Create a new ArcQueryParser instance
488
- */
332
+ /** Create a new ArcQueryParser instance. */
489
333
  function createQueryParser(options) {
490
334
  return new ArcQueryParser(options);
491
335
  }
336
+ /** The grammar's refusal as arc's 400, keeping the offending parameter for `details[]`. */
337
+ function toValidationError(error) {
338
+ return new ValidationError(error.message, [{
339
+ field: error.meta.param,
340
+ message: error.meta.reason
341
+ }]);
342
+ }
492
343
  //#endregion
493
344
  //#region src/utils/responseSchemas.ts
494
345
  /**
@@ -614,6 +465,19 @@ function listResponse(itemSchema) {
614
465
  next: {
615
466
  type: ["string", "null"],
616
467
  description: "Cursor token for the next page, or null."
468
+ },
469
+ prev: {
470
+ type: ["string", "null"],
471
+ description: "Cursor token for the previous page, or null."
472
+ },
473
+ end: {
474
+ type: ["string", "null"],
475
+ description: "Cursor at the page's last row."
476
+ },
477
+ totalIsEstimate: { type: "boolean" },
478
+ countedAt: {
479
+ type: ["string", "null"],
480
+ format: "date-time"
617
481
  }
618
482
  },
619
483
  additionalProperties: true
@@ -656,6 +520,14 @@ function offsetListResponse(itemSchema) {
656
520
  hasPrev: {
657
521
  type: "boolean",
658
522
  example: false
523
+ },
524
+ totalIsEstimate: {
525
+ type: "boolean",
526
+ example: false
527
+ },
528
+ countedAt: {
529
+ type: ["string", "null"],
530
+ format: "date-time"
659
531
  }
660
532
  },
661
533
  required: [
@@ -696,6 +568,15 @@ function keysetListResponse(itemSchema) {
696
568
  next: {
697
569
  type: ["string", "null"],
698
570
  description: "Cursor token for the next page, or null."
571
+ },
572
+ prev: {
573
+ type: ["string", "null"],
574
+ description: "Cursor token for the previous page, or null."
575
+ },
576
+ hasPrev: { type: "boolean" },
577
+ end: {
578
+ type: ["string", "null"],
579
+ description: "Cursor at the page's last row."
699
580
  }
700
581
  },
701
582
  required: [
@@ -859,14 +740,17 @@ function getListQueryParams() {
859
740
  };
860
741
  }
861
742
  /**
862
- * Generic item schema that allows any properties.
863
- * Used as default when no user schema is provided.
864
- * Enables fast-json-stringify while still passing through all fields.
743
+ * Default row/document schema when the resource declares none: `{}`, which
744
+ * fast-json-stringify hands to `JSON.stringify`.
745
+ *
746
+ * NOT `{ type: 'object', additionalProperties: true }`: with no declared
747
+ * properties that form walks every key of every row through fjs's generic
748
+ * path — ~3.5x slower than `JSON.stringify` for the same bytes (pinned in
749
+ * `tests/utils/default-crud-serialization.test.ts`). It never filtered a field
750
+ * either, so it bought nothing. This is serializer-only: published OpenAPI
751
+ * builds CRUD responses from the resource's own component schema.
865
752
  */
866
- const genericItemSchema = {
867
- type: "object",
868
- additionalProperties: true
869
- };
753
+ const genericItemSchema = {};
870
754
  /**
871
755
  * Recursively strip `example` keys from a schema object.
872
756
  * The `example` keyword is OpenAPI metadata — not standard JSON Schema —
@@ -886,12 +770,10 @@ function stripExamples(schema) {
886
770
  /**
887
771
  * Get default response schemas for all CRUD operations.
888
772
  *
889
- * When routes have response schemas, Fastify compiles them with
890
- * fast-json-stringify for 2-3x faster serialization and prevents
891
- * accidental field disclosure.
892
- *
893
- * These defaults use `additionalProperties: true` so all fields pass through.
894
- * Override with specific schemas for full serialization performance + safety.
773
+ * The envelope is typed (its fields are known); rows and single documents
774
+ * pass through as `{}` — see `genericItemSchema`. A resource that declares a
775
+ * response schema with real `properties` gets typed row serialization, which
776
+ * also drops undeclared fields.
895
777
  *
896
778
  * Note: `example` properties are stripped from defaults so they work with
897
779
  * any Fastify instance (not just createApp which adds `keywords: ['example']`).
@@ -944,14 +826,44 @@ const CircuitState = {
944
826
  OPEN: "OPEN",
945
827
  HALF_OPEN: "HALF_OPEN"
946
828
  };
947
- var CircuitBreakerError = class extends Error {
829
+ /**
830
+ * Thrown when a call is refused because the circuit is OPEN (or HALF_OPEN with
831
+ * every probe slot taken).
832
+ *
833
+ * An `ArcError` with status **503**, code `arc.circuit_open`. It extended plain
834
+ * `Error` and carried no status, so an open circuit reaching arc's error
835
+ * handler was classified `arc.internal_error`: a 500, logged at error level
836
+ * with a stack trace, once per fast-failed call. That is the opposite of what
837
+ * a breaker is for — it turned deliberate, cheap load shedding into the
838
+ * loudest failure in the logs, during exactly the outage where it fires
839
+ * thousands of times a minute.
840
+ *
841
+ * `retryAfter` (seconds, rounded up) is when the circuit next probes; the
842
+ * error handler sends it as `Retry-After`, so well-behaved clients back off
843
+ * for as long as the breaker is actually going to refuse them.
844
+ */
845
+ var CircuitBreakerError = class extends ArcError {
948
846
  state;
949
- constructor(message, state) {
950
- super(message);
847
+ /** Seconds until the circuit next probes, when known. */
848
+ retryAfter;
849
+ constructor(message, state, retryAfter) {
850
+ super(message, {
851
+ code: "arc.circuit_open",
852
+ statusCode: 503,
853
+ details: {
854
+ state,
855
+ ...retryAfter !== void 0 ? { retryAfter } : {}
856
+ }
857
+ });
951
858
  this.name = "CircuitBreakerError";
952
859
  this.state = state;
860
+ if (retryAfter !== void 0) this.retryAfter = retryAfter;
953
861
  }
954
862
  };
863
+ /** Whole seconds until `until` (epoch ms), never below 1 — `Retry-After: 0` invites a stampede. */
864
+ function secondsUntil(until) {
865
+ return Math.max(1, Math.ceil((until - Date.now()) / 1e3));
866
+ }
955
867
  var CircuitBreaker = class {
956
868
  state = CircuitState.CLOSED;
957
869
  failures = 0;
@@ -996,7 +908,7 @@ var CircuitBreaker = class {
996
908
  this.lastCallAt = Date.now();
997
909
  if (this.state === CircuitState.OPEN) {
998
910
  if (Date.now() < this.nextAttempt) {
999
- const error = new CircuitBreakerError(`Circuit breaker is OPEN for ${this.name}`, CircuitState.OPEN);
911
+ const error = new CircuitBreakerError(`Circuit breaker is OPEN for ${this.name}`, CircuitState.OPEN, secondsUntil(this.nextAttempt));
1000
912
  if (this.fallback) return this.fallback(...args);
1001
913
  throw error;
1002
914
  }
@@ -1006,7 +918,7 @@ var CircuitBreaker = class {
1006
918
  if (this.state === CircuitState.HALF_OPEN) {
1007
919
  if (this.halfOpenInFlight >= this.halfOpenMaxProbes) {
1008
920
  if (this.fallback) return this.fallback(...args);
1009
- throw new CircuitBreakerError(`Circuit breaker is HALF_OPEN for ${this.name} — probe slots full`, CircuitState.HALF_OPEN);
921
+ throw new CircuitBreakerError(`Circuit breaker is HALF_OPEN for ${this.name} — probe slots full`, CircuitState.HALF_OPEN, Math.max(1, Math.ceil(this.timeout / 1e3)));
1010
922
  }
1011
923
  this.halfOpenInFlight++;
1012
924
  isProbe = true;
@@ -1392,27 +1304,26 @@ function defineGuard(config) {
1392
1304
  * The return value is sent raw (no envelope). If it returns `undefined`,
1393
1305
  * the response body is empty (HTTP status only).
1394
1306
  * @param statusCode - HTTP status code for successful responses (default: 200)
1307
+ * @returns The reply, so Fastify waits for it: resolving `undefined` after `reply.send()` while an
1308
+ * async `preSerialization` hook (idempotency, encryption) is pending re-sends an empty body.
1395
1309
  */
1396
1310
  function handleRaw(handler, statusCode = 200) {
1397
1311
  return async (request, reply) => {
1398
1312
  try {
1399
1313
  const result = await handler(request, reply);
1400
- if (reply.sent) return;
1401
- if (result === void 0 || result === null) reply.code(statusCode).send();
1402
- else reply.code(statusCode).send(result);
1314
+ if (reply.sent) return reply;
1315
+ if (result === void 0 || result === null) return reply.code(statusCode).send();
1316
+ return reply.code(statusCode).send(result);
1403
1317
  } catch (err) {
1404
- if (reply.sent) return;
1405
- if (err instanceof ArcError) {
1406
- reply.code(err.statusCode).send({
1407
- error: err.message,
1408
- code: err.code,
1409
- ...err.details ? { details: err.details } : {}
1410
- });
1411
- return;
1412
- }
1318
+ if (reply.sent) return reply;
1319
+ if (err instanceof ArcError) return reply.code(err.statusCode).send({
1320
+ error: err.message,
1321
+ code: err.code,
1322
+ ...err.details ? { details: err.details } : {}
1323
+ });
1413
1324
  const error = err;
1414
1325
  const code = error.statusCode ?? error.status ?? 500;
1415
- reply.code(code).send({
1326
+ return reply.code(code).send({
1416
1327
  error: error.message ?? "Internal server error",
1417
1328
  ...error.code && { code: error.code }
1418
1329
  });
@@ -1521,4 +1432,4 @@ function createStateMachine(name, transitions = {}, options = {}) {
1521
1432
  };
1522
1433
  }
1523
1434
  //#endregion
1524
- export { NEUTRAL_DIALECT as A, offsetListResponse as C, ArcQueryParser as D, responses as E, createQueryParser as O, listResponse as S, queryParams as T, errorContractSchema$1 as _, defineCompensation as a, getListQueryParams as b, CircuitBreaker as c, CircuitState as d, createCircuitBreaker as f, deleteResponse as g, bareListResponse as h, defineErrorMapper as i, scheduleBackground as j, MONGO_DIALECT as k, CircuitBreakerError as l, aggregateListResponse as m, handleRaw as n, withCompensation as o, createCircuitBreakerRegistry as p, defineGuard as r, RedisCircuitBreakerState as s, createStateMachine as t, CircuitBreakerRegistry as u, errorDetailSchema$1 as v, paginationSchema as w, keysetListResponse as x, getDefaultCrudSchemas as y };
1435
+ export { paginationSchema as C, createQueryParser as D, ArcQueryParser as E, MONGO_DIALECT as O, offsetListResponse as S, responses as T, errorContractSchema$1 as _, defineCompensation as a, keysetListResponse as b, CircuitBreaker as c, CircuitState as d, createCircuitBreaker as f, deleteResponse as g, bareListResponse as h, defineErrorMapper as i, NEUTRAL_DIALECT as k, CircuitBreakerError as l, aggregateListResponse as m, handleRaw as n, withCompensation as o, createCircuitBreakerRegistry as p, defineGuard as r, RedisCircuitBreakerState as s, createStateMachine as t, CircuitBreakerRegistry as u, getDefaultCrudSchemas as v, queryParams as w, listResponse as x, getListQueryParams as y };