@classytic/arc 2.41.1 → 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.
- package/dist/{EventTransport-CbUKDzZZ.d.mts → EventTransport-CiicKz2Z.d.mts} +1 -1
- package/dist/{EventTransport-DSqD_Xsg.mjs → EventTransport-tzA7CAB4.mjs} +22 -17
- package/dist/{HookSystem-CMF94SEA.mjs → HookSystem-B6UaKRbF.mjs} +2 -2
- package/dist/{QueryCache-DFA-QrU9.d.mts → QueryCache--5uAuYPW.d.mts} +7 -3
- package/dist/{ResourceRegistry-MGpXoxHZ.mjs → ResourceRegistry-CuCARu2B.mjs} +44 -13
- package/dist/actionPermissions-BotX1YOa.mjs +776 -0
- package/dist/audit/index.d.mts +43 -5
- package/dist/audit/index.mjs +48 -26
- package/dist/auth/audit.mjs +1 -1
- package/dist/auth/index.d.mts +43 -13
- package/dist/auth/index.mjs +130 -155
- package/dist/auth/redis-session.mjs +1 -1
- package/dist/{authorizationDecision-DawKGoXb.mjs → authorizationDecision-DQYrkl5f.mjs} +7 -102
- package/dist/{base-CxrV1LmG.d.mts → base-CZyixEQR.d.mts} +27 -5
- package/dist/boundedMap-BULzfXYU.mjs +57 -0
- package/dist/{buildHandler-CvxKOc0m.mjs → buildHandler-CRQ8EcX5.mjs} +4 -4
- package/dist/cache/index.d.mts +22 -34
- package/dist/cache/index.mjs +4 -4
- package/dist/{caching-DbCBxlgw.mjs → caching-Cc2M1DwO.mjs} +3 -3
- package/dist/claimed-B10RTfkM.mjs +8 -0
- package/dist/cleanup/index.d.mts +6 -10
- package/dist/cleanup/index.mjs +27 -73
- package/dist/cli/commands/describe.d.mts +4 -2
- package/dist/cli/commands/describe.mjs +7 -4
- package/dist/cli/commands/docs.mjs +3 -3
- package/dist/cli/commands/doctor.mjs +1 -1
- package/dist/cli/commands/generate.mjs +2 -2
- package/dist/cli/commands/init.mjs +2 -2
- package/dist/cli/commands/introspect.mjs +2 -2
- package/dist/cli/index.mjs +3 -3
- package/dist/{constants-D5Ubn1fe.mjs → constants-Cu7Mx7_U.mjs} +4 -2
- package/dist/context/index.d.mts +2 -5
- package/dist/context/index.mjs +18 -38
- package/dist/core/index.d.mts +3 -3
- package/dist/core/index.mjs +8 -8
- package/dist/{core-5lazhva9.mjs → core-Cb0LjccA.mjs} +5 -4
- package/dist/{createActionRouter-DNqoaK81.mjs → createActionRouter-xjqonMRO.mjs} +87 -39
- package/dist/{createAggregationRouter-BcbQJf10.mjs → createAggregationRouter-zRYWw_5D.mjs} +11 -8
- package/dist/{preloadResources-f3boenVZ.mjs → createApp-D-953ZAq.mjs} +299 -390
- package/dist/{createMcpServer-CbDWkZ1T.d.mts → createMcpServer-C9cxS8zt.d.mts} +1 -1
- package/dist/{defineAggregation-D_eJjm5A.mjs → defineAggregation-Bm8RjMbq.mjs} +1 -1
- package/dist/{defineResource-DxkMTjRn.mjs → defineResource-DIC7JZGU.mjs} +118 -1024
- package/dist/discovery/index.mjs +1 -1
- package/dist/docs/index.d.mts +10 -4
- package/dist/docs/index.mjs +4 -4
- package/dist/{elevation-BXCfVcNG.d.mts → elevation-BW2J3tNj.d.mts} +1 -2
- package/dist/{elevation-CkvG0LG2.mjs → elevation-DG8SrLN-.mjs} +41 -12
- package/dist/encryption/index.mjs +3 -3
- package/dist/{errorHandler-D1GHKGlV.mjs → errorHandler-BnfDviL1.mjs} +26 -5
- package/dist/{errors-BH7YU_9O.mjs → errors-CtrywMHP.mjs} +58 -23
- package/dist/{errors-BGdVwP25.d.mts → errors-JYc9vwqE.d.mts} +9 -0
- package/dist/{eventPlugin-B52abJ8W.d.mts → eventPlugin-BtmA4-3P.d.mts} +65 -9
- package/dist/{eventPlugin-zZY-unhr.mjs → eventPlugin-Q6BhzHzs.mjs} +82 -21
- package/dist/{subscribe-helpers-DatDkcuG.mjs → eventTypes-Cdhm6ZlV.mjs} +151 -17
- package/dist/events/index.d.mts +33 -8
- package/dist/events/index.mjs +49 -53
- package/dist/events/transports/redis-stream-entry.d.mts +1 -1
- package/dist/events/transports/redis-stream-entry.mjs +139 -157
- package/dist/events/transports/redis.d.mts +1 -1
- package/dist/events/transports/redis.mjs +2 -2
- package/dist/factory/index.d.mts +32 -17
- package/dist/factory/index.mjs +213 -32
- package/dist/{fastify-augmentation-Cq2nHVaW.d.mts → fastify-augmentation-DhUJ-mvo.d.mts} +11 -25
- package/dist/{fields-I_EeRWND.d.mts → fields-BBSpf8pS.d.mts} +1 -1
- package/dist/{fields-BHrkoF5y.mjs → fields-ToPYpMqS.mjs} +1 -1
- package/dist/filter-merge-IpGywvym.mjs +99 -0
- package/dist/{generate-BLRf2m3y.mjs → generate-DRTkLlIl.mjs} +2 -2
- package/dist/{headers-BIXWKrzQ.mjs → headers-BbkIF6vA.mjs} +2 -2
- package/dist/health-D-Ft6JM6.mjs +22 -0
- package/dist/{health-ZRHeDHXH.mjs → health-DqbsG-_w.mjs} +115 -55
- package/dist/hooks/index.d.mts +2 -2
- package/dist/hooks/index.mjs +2 -2
- package/dist/idempotency/index.d.mts +31 -7
- package/dist/idempotency/index.mjs +155 -98
- package/dist/idempotency/redis.d.mts +1 -1
- package/dist/idempotency/redis.mjs +48 -11
- package/dist/{index-BHdZD8zK.d.mts → index-Bxriq3so.d.mts} +84 -21
- package/dist/{preloadResources-CjS2OfpU.d.mts → index-C8zVGYsk.d.mts} +37 -38
- package/dist/{index-D4u76eQW.d.mts → index-CRoPtISf.d.mts} +23 -73
- package/dist/{index-CbJiYrfv.d.mts → index-Chgz3d0i.d.mts} +1 -1
- package/dist/index-JdbZAIsm.d.mts +510 -0
- package/dist/{index-DHbyPo5j.d.mts → index-mqaqZNpe.d.mts} +253 -254
- package/dist/index.d.mts +8 -8
- package/dist/index.mjs +11 -11
- package/dist/{init-CpLkjU18.mjs → init-B_oUYDXI.mjs} +1 -1
- package/dist/integrations/event-gateway.d.mts +8 -3
- package/dist/integrations/event-gateway.mjs +4 -4
- package/dist/integrations/index.d.mts +3 -3
- package/dist/integrations/index.mjs +1 -1
- package/dist/integrations/jobs/index.d.mts +1 -1
- package/dist/integrations/jobs/index.mjs +18 -5
- package/dist/integrations/mcp/index.d.mts +72 -59
- package/dist/integrations/mcp/index.mjs +19 -4
- package/dist/integrations/mcp/testing.d.mts +2 -2
- package/dist/integrations/mcp/testing.mjs +2 -2
- package/dist/integrations/streamline.d.mts +78 -136
- package/dist/integrations/streamline.mjs +116 -54
- package/dist/integrations/webhooks.d.mts +60 -120
- package/dist/integrations/webhooks.mjs +36 -80
- package/dist/integrations/websocket-pushref-redis.d.mts +1 -1
- package/dist/integrations/websocket-pushref-redis.mjs +1 -1
- package/dist/integrations/websocket-redis.d.mts +1 -1
- package/dist/integrations/websocket-redis.mjs +1 -1
- package/dist/integrations/websocket.d.mts +3 -3
- package/dist/integrations/websocket.mjs +2 -2
- package/dist/{interface-DEWNOMza.d.mts → interface-C0395yUN.d.mts} +13 -1
- package/dist/{jsonBody-ELR9lfeU.mjs → jsonBody-B4YVGcql.mjs} +2 -2
- package/dist/{keys-cpZCtcIE.mjs → keys-B2KtgMLJ.mjs} +7 -18
- package/dist/{loadResources-BPfWiI5s.mjs → loadResources-Dn4lalw2.mjs} +3 -3
- package/dist/{loadResourcesFromEntry-ixtfL3J2.mjs → loadResourcesFromEntry-3sLYbuse.mjs} +2 -2
- package/dist/logger/index.d.mts +1 -8
- package/dist/logger/index.mjs +3 -153
- package/dist/logger-DXjfIqjV.mjs +160 -0
- package/dist/{mcpPlugin-BX5PujI0.mjs → mcpPlugin-C08HalN5.mjs} +433 -170
- package/dist/{memory-Iotz0cCx.mjs → memory-9Cp7611C.mjs} +36 -34
- package/dist/{metrics-NMsmpRmY.mjs → metrics-CWI11eRu.mjs} +39 -72
- package/dist/middleware/index.d.mts +2 -2
- package/dist/middleware/index.mjs +3 -3
- package/dist/migrations/index.mjs +2 -2
- package/dist/{module-DpLJG2_N.mjs → module-BSvOQC7E.mjs} +8 -8
- package/dist/multiTenant-cY0FkDiA.d.mts +104 -0
- package/dist/{multipartBody-DtRWM6_U.mjs → multipartBody-CXM2a-Dy.mjs} +46 -7
- package/dist/{openapi-CmFf3yq_.mjs → openapi-DT_G4VhY.mjs} +48 -10
- package/dist/outbox-admin/index.d.mts +3 -3
- package/dist/outbox-admin/index.mjs +6 -18
- package/dist/{outbox-module-D2z5WkDY.mjs → outbox-module-Bjgzl6tg.mjs} +203 -33
- package/dist/{outbox-module-BHdUnuuK.d.mts → outbox-module-gZCHoE4H.d.mts} +79 -6
- package/dist/permissions/index.d.mts +5 -4
- package/dist/permissions/index.mjs +8 -6
- package/dist/{permissions-D8TBH44E.mjs → permissions-Cm0uYHvd.mjs} +149 -33
- package/dist/{pipe-FMWNheSO.mjs → pipe-BqeGT6UO.mjs} +2 -2
- package/dist/pipeline/index.d.mts +2 -2
- package/dist/pipeline/index.mjs +2 -2
- package/dist/plan-FVR6LP59.mjs +565 -0
- package/dist/{BaseController-CYEwjyjN.mjs → plugin-BccET1tL.mjs} +1256 -345
- package/dist/plugins/index.d.mts +14 -11
- package/dist/plugins/index.mjs +44 -22
- package/dist/plugins/response-cache.mjs +8 -14
- package/dist/plugins/tracing-entry.mjs +2 -2
- package/dist/{pluralize-CXWZIzTv.mjs → pluralize-CVIOcgMF.mjs} +1 -1
- package/dist/presets/filesUpload.d.mts +2 -2
- package/dist/presets/filesUpload.mjs +5 -5
- package/dist/presets/index.d.mts +26 -31
- package/dist/presets/index.mjs +4 -4
- package/dist/presets/multiTenant.d.mts +2 -107
- package/dist/presets/multiTenant.mjs +22 -162
- package/dist/presets/search.d.mts +2 -2
- package/dist/presets/search.mjs +2 -2
- package/dist/{presets-ZJSPgmT2.mjs → presets-Cpvj6fGO.mjs} +99 -107
- package/dist/{projection-BzIgzoKZ.mjs → projection-Ce4aQhQk.mjs} +2 -2
- package/dist/{pushref-registry-Dov9l4qE.d.mts → pushref-registry-UxCkoA4C.d.mts} +1 -1
- package/dist/{queryCachePlugin-BzqxconG.d.mts → queryCachePlugin-C2lZjTxk.d.mts} +1 -1
- package/dist/{queryCachePlugin-DZHPOm9p.mjs → queryCachePlugin-CFWNH8aT.mjs} +12 -14
- package/dist/{rateLimitKey-BvOvcBDi.mjs → rateLimitKey-C4O-kPm0.mjs} +1 -1
- package/dist/{realtime-8tIQWZz4.mjs → realtime-DmPE9S54.mjs} +25 -21
- package/dist/redis-stream-BMk-bpQS.d.mts +219 -0
- package/dist/{redis-Bzi1y3F9.d.mts → redis-vLvtcGy_.d.mts} +13 -24
- package/dist/redisShapes-CQksY67q.d.mts +55 -0
- package/dist/registry/index.d.mts +9 -4
- package/dist/registry/index.mjs +4 -4
- package/dist/{registry-DNkgGQcK.mjs → registry-Bcz9G1bh.mjs} +10 -8
- package/dist/{renewingLease-yF8BdW4b.mjs → renewingLease-qODa7KJQ.mjs} +1 -1
- package/dist/{replyHelpers-CNdvMfM4.mjs → replyHelpers-CG_VtVgS.mjs} +2 -2
- package/dist/{requestContext-cDkAUr-P.mjs → requestContext-Dsu9P1bV.mjs} +1 -1
- package/dist/requirement-CbiLdQ1n.d.mts +22 -0
- package/dist/requirement-KDdoRcOP.mjs +35 -0
- package/dist/resourceRateLimit-Wf6d6FL9.d.mts +30 -0
- package/dist/resourceRateLimit-dSjVr1pk.mjs +180 -0
- package/dist/{rolldown-runtime-eysyzFAF.mjs → rolldown-runtime-D1w9Y1VJ.mjs} +1 -1
- package/dist/routeValidator-D36SW8qt.mjs +70 -0
- package/dist/{runtimeCapabilities-CkhS6jn6.mjs → runtimeCapabilities-0tccjRlj.mjs} +1 -1
- package/dist/{schedules-j6uXXAeJ.mjs → schedules-C-OpmHBq.mjs} +46 -37
- package/dist/{schemaConverter-BVA7cFRG.mjs → schemaConverter-BORek3O-.mjs} +2 -2
- package/dist/{schemaIR-CTvPDoPB.mjs → schemaIR-C7Vbp71I.mjs} +2 -2
- package/dist/schemas/index.d.mts +10 -0
- package/dist/schemas/index.mjs +8 -3
- package/dist/scim/index.mjs +2 -2
- package/dist/scope/index.d.mts +2 -2
- package/dist/scope/index.mjs +6 -6
- package/dist/{scope-BUi1xV1I.mjs → scope-DQthe88b.mjs} +4 -4
- package/dist/shutdown-MqA81z_P.mjs +59 -0
- package/dist/{simpleEqualityMatcher-C9bzq2gO.mjs → simpleEqualityMatcher-auW8QjXN.mjs} +1 -1
- package/dist/{sse-BeqVKQKu.mjs → sse-CYzQBVFe.mjs} +11 -15
- package/dist/{sseStream-Ppzo-WUH.mjs → sseStream-B5_Afiqn.mjs} +2 -2
- package/dist/{store-helpers-dW71XVWL.mjs → store-helpers-BOzk-CuX.mjs} +1 -1
- package/dist/{streaming-IcRFjWQU.mjs → streaming-DjTQhZrN.mjs} +1 -1
- package/dist/subscriberIdentity-CW39uA8G.mjs +28 -0
- package/dist/tenancy--ed9eH_z.mjs +43 -0
- package/dist/tenantPreparation-BKon6Yox.mjs +67 -0
- package/dist/testing/cleanupStoreContract.mjs +1 -1
- package/dist/testing/index.d.mts +193 -175
- package/dist/testing/index.mjs +286 -212
- package/dist/testing/outboxStoreContract.mjs +137 -2
- package/dist/testing/requiredPorts.d.mts +17 -0
- package/dist/testing/requiredPorts.mjs +65 -0
- package/dist/testing/storageContract.mjs +1 -1
- package/dist/timing-qczZ0YC-.mjs +46 -0
- package/dist/{transactionContext-CPbdlnVw.mjs → transactionContext-DjFkrNG6.mjs} +1 -1
- package/dist/{typeGuards-BLjPgVBm.mjs → typeGuards-CySF12G-.mjs} +1 -1
- package/dist/types/index.d.mts +4 -4
- package/dist/types/index.mjs +1 -1
- package/dist/types/storage.mjs +1 -1
- package/dist/{types-CWQn9-IC.d.mts → types-B4Aq4Sdj.d.mts} +22 -9
- package/dist/{types-DfVDvkdP.d.mts → types-BJ-2XqVF.d.mts} +189 -204
- package/dist/{types-CgenB1gv.mjs → types-Bk9NOgOC.mjs} +4 -4
- package/dist/usage/index.d.mts +2 -3
- package/dist/usage/index.mjs +5 -5
- package/dist/{userHelpers-ChRvkuSZ.mjs → userHelpers-aklawRaK.mjs} +1 -1
- package/dist/utils/index.d.mts +84 -223
- package/dist/utils/index.mjs +13 -12
- package/dist/{utils-CxcctrAh.mjs → utils-ec1oTo1n.mjs} +315 -404
- package/dist/{validate-DrYWnOzI.mjs → validate-9yzHC1e2.mjs} +6 -14
- package/dist/{versioning-D6JIK8Tk.d.mts → versioning-Bsb51gGT.d.mts} +28 -13
- package/dist/{versioning-DKkT4hNJ.mjs → versioning-fhhWHV74.mjs} +4 -4
- package/dist/{websocket-BOXVggMf.d.mts → websocket-Cs4awVyD.d.mts} +1 -1
- package/dist/{workScope-CxKqoIFn.mjs → workScope-zpPyge4t.mjs} +2 -2
- package/package.json +16 -12
- package/skills/arc/SKILL.md +13 -4
- package/skills/arc/references/auth.md +8 -5
- package/skills/arc/references/integrations.md +1 -1
- package/skills/arc/references/mcp.md +8 -6
- package/skills/arc/references/multi-tenancy.md +19 -14
- package/skills/arc/references/production.md +27 -10
- package/dist/actionPermissions-BoPGQ5-4.mjs +0 -350
- package/dist/index-CFfLKMH4.d.mts +0 -290
- package/dist/interface-YzIbF7Ii.mjs +0 -17
- package/dist/redis-stream-BPKscsTB.d.mts +0 -260
- package/dist/registerRoute-Bsnk6NiZ.mjs +0 -364
- package/dist/sync/changelog.d.mts +0 -3
- package/dist/sync/changelog.mjs +0 -3
|
@@ -1,215 +1,239 @@
|
|
|
1
|
-
/*! @classytic/arc v2.
|
|
2
|
-
import {
|
|
3
|
-
import { arcLog } from "./logger
|
|
4
|
-
import { t as ArcError, u as ValidationError } from "./errors-
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
33
|
-
*
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
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
|
-
*
|
|
88
|
-
*
|
|
89
|
-
* -
|
|
90
|
-
*
|
|
91
|
-
* -
|
|
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
|
-
*
|
|
126
|
-
*
|
|
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
|
-
|
|
134
|
-
|
|
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.
|
|
160
|
-
this.
|
|
161
|
-
if (options.
|
|
162
|
-
|
|
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
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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.
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
204
|
-
|
|
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
|
-
*
|
|
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
|
|
226
|
-
if (
|
|
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 (
|
|
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
|
-
*
|
|
319
|
-
*
|
|
320
|
-
*
|
|
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
|
-
|
|
324
|
-
if (
|
|
325
|
-
|
|
326
|
-
|
|
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
|
|
396
|
-
|
|
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
|
-
*
|
|
424
|
-
*
|
|
425
|
-
*
|
|
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):", ...
|
|
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
|
-
*
|
|
863
|
-
*
|
|
864
|
-
*
|
|
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
|
-
*
|
|
890
|
-
*
|
|
891
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
950
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
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 {
|
|
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 };
|