@classytic/arc 2.16.1 → 2.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +22 -2
  2. package/dist/{BaseController-Vk8I7C0x.mjs → BaseController-CgHomZ4d.mjs} +5 -47
  3. package/dist/audit/index.mjs +2 -1
  4. package/dist/auth/index.d.mts +1 -1
  5. package/dist/auth/index.mjs +2 -2
  6. package/dist/auth/redis-session.mjs +28 -2
  7. package/dist/cli/commands/describe.d.mts +1 -1
  8. package/dist/cli/commands/docs.mjs +1 -1
  9. package/dist/core/index.d.mts +2 -2
  10. package/dist/core/index.mjs +3 -3
  11. package/dist/{core-wKeGXn38.mjs → core-D8tpQ4ZL.mjs} +174 -31
  12. package/dist/{createActionRouter-eiOFtnC3.mjs → createActionRouter-BHUMb1-y.mjs} +5 -2
  13. package/dist/{createAggregationRouter-C4unhBnV.mjs → createAggregationRouter-BYVvhfc5.mjs} +1 -1
  14. package/dist/{createApp-DdPBtmzp.mjs → createApp-CzCEEsEm.mjs} +2 -2
  15. package/dist/docs/index.d.mts +1 -1
  16. package/dist/docs/index.mjs +2 -2
  17. package/dist/{elevation-Dci0AYLT.mjs → elevation-6mfTLBC-.mjs} +1 -1
  18. package/dist/{errorHandler-DNPzqaTT.mjs → errorHandler-wQejMBT3.mjs} +45 -4
  19. package/dist/events/transports/redis-stream-entry.mjs +34 -1
  20. package/dist/events/transports/redis.mjs +17 -1
  21. package/dist/factory/index.d.mts +1 -1
  22. package/dist/factory/index.mjs +1 -1
  23. package/dist/{fastify-augmentation-CJm0vh-l.d.mts → fastify-augmentation-7LIR0nDv.d.mts} +1 -1
  24. package/dist/hooks/index.d.mts +1 -1
  25. package/dist/idempotency/index.mjs +20 -4
  26. package/dist/{index-DBDgy6Hw.d.mts → index-C1JSBan-.d.mts} +177 -1
  27. package/dist/{index-CC_pgZjY.d.mts → index-CruZir1o.d.mts} +1 -1
  28. package/dist/index.d.mts +2 -2
  29. package/dist/index.mjs +5 -5
  30. package/dist/integrations/index.d.mts +1 -1
  31. package/dist/integrations/jobs.d.mts +36 -1
  32. package/dist/integrations/jobs.mjs +82 -9
  33. package/dist/integrations/mcp/index.d.mts +65 -3
  34. package/dist/integrations/mcp/index.mjs +93 -2
  35. package/dist/integrations/mcp/testing.d.mts +1 -1
  36. package/dist/integrations/mcp/testing.mjs +1 -1
  37. package/dist/integrations/websocket.mjs +2 -1
  38. package/dist/{mcpPlugin-Dbgz9_Rj.mjs → mcpPlugin-C6FViZXw.mjs} +111 -8
  39. package/dist/middleware/index.d.mts +1 -1
  40. package/dist/middleware/index.mjs +1 -1
  41. package/dist/{multipartBody-BOvVSVCD.mjs → multipartBody-VjVWmZl8.mjs} +2 -1
  42. package/dist/{openapi-BRnd3pT7.mjs → openapi-DKDs9NfD.mjs} +2 -2
  43. package/dist/permissions/index.mjs +2 -2
  44. package/dist/{permissions-CTxMrreC.mjs → permissions-B1Qy55fg.mjs} +169 -169
  45. package/dist/pipeline/index.d.mts +1 -1
  46. package/dist/plugins/index.d.mts +3 -3
  47. package/dist/plugins/index.mjs +1 -1
  48. package/dist/plugins/tracing-entry.mjs +1 -1
  49. package/dist/presets/filesUpload.d.mts +1 -1
  50. package/dist/presets/filesUpload.mjs +2 -2
  51. package/dist/presets/index.d.mts +1 -1
  52. package/dist/presets/index.mjs +1 -1
  53. package/dist/presets/multiTenant.d.mts +1 -1
  54. package/dist/presets/search.d.mts +1 -1
  55. package/dist/presets/search.mjs +1 -1
  56. package/dist/{presets-C9BE6WaZ.mjs → presets-BYbPobbF.mjs} +5 -3
  57. package/dist/registry/index.d.mts +1 -1
  58. package/dist/{routerShared-ClV4cCaa.mjs → routerShared-nCyJGOKX.mjs} +127 -4
  59. package/dist/scope/index.mjs +2 -2
  60. package/dist/testing/index.d.mts +2 -2
  61. package/dist/testing/index.mjs +1 -1
  62. package/dist/types/index.d.mts +1 -1
  63. package/dist/{types-Dw--SyTq.d.mts → types-BRmS8TNa.d.mts} +1 -1
  64. package/dist/{types-BD0BCdf6.d.mts → types-CoMjNmkF.d.mts} +12 -1
  65. package/dist/utils/index.d.mts +79 -2
  66. package/dist/utils/index.mjs +2 -2
  67. package/dist/{utils-DPXZnZUx.mjs → utils-BMQK8mRl.mjs} +111 -2
  68. package/package.json +1 -1
  69. package/skills/arc/SKILL.md +43 -1
  70. package/skills/arc/references/api-reference.md +2 -0
  71. package/skills/arc/references/mcp.md +30 -1
  72. /package/dist/{types-D57iXYb8.mjs → types-DV9WDfeg.mjs} +0 -0
@@ -337,7 +337,7 @@ async function registerAuth(fastify, config, trackPlugin) {
337
337
  */
338
338
  async function registerElevation(fastify, config, trackPlugin) {
339
339
  if (!config.elevation) return;
340
- const { elevationPlugin } = await import("./elevation-Dci0AYLT.mjs").then((n) => n.r);
340
+ const { elevationPlugin } = await import("./elevation-6mfTLBC-.mjs").then((n) => n.r);
341
341
  await fastify.register(elevationPlugin, config.elevation);
342
342
  trackPlugin("arc-elevation", config.elevation);
343
343
  fastify.log.debug("Elevation plugin enabled");
@@ -347,7 +347,7 @@ async function registerElevation(fastify, config, trackPlugin) {
347
347
  */
348
348
  async function registerErrorHandler(fastify, config, trackPlugin) {
349
349
  if (config.errorHandler === false) return;
350
- const { errorHandlerPlugin } = await import("./errorHandler-DNPzqaTT.mjs").then((n) => n.r);
350
+ const { errorHandlerPlugin } = await import("./errorHandler-wQejMBT3.mjs").then((n) => n.r);
351
351
  const errorOpts = typeof config.errorHandler === "object" ? config.errorHandler : { includeStack: config.preset !== "production" };
352
352
  await fastify.register(errorHandlerPlugin, errorOpts);
353
353
  trackPlugin("arc-error-handler", errorOpts);
@@ -1,4 +1,4 @@
1
- import { p as RegistryEntry } from "../index-DBDgy6Hw.mjs";
1
+ import { p as RegistryEntry } from "../index-C1JSBan-.mjs";
2
2
  import { t as ExternalOpenApiPaths } from "../externalPaths-ClykOQqa.mjs";
3
3
  import { FastifyPluginAsync } from "fastify";
4
4
 
@@ -1,5 +1,5 @@
1
- import { t as getUserRoles } from "../types-D57iXYb8.mjs";
2
- import { n as openApiPlugin, r as openapi_default, t as buildOpenApiSpec } from "../openapi-BRnd3pT7.mjs";
1
+ import { t as getUserRoles } from "../types-DV9WDfeg.mjs";
2
+ import { n as openApiPlugin, r as openapi_default, t as buildOpenApiSpec } from "../openapi-DKDs9NfD.mjs";
3
3
  import fp from "fastify-plugin";
4
4
  //#region src/docs/scalar.ts
5
5
  const scalarPlugin = async (fastify, opts = {}) => {
@@ -1,6 +1,6 @@
1
1
  import { t as __exportAll } from "./chunk-BpYLSNr0.mjs";
2
2
  import { arcLog } from "./logger/index.mjs";
3
- import { t as getUserRoles } from "./types-D57iXYb8.mjs";
3
+ import { t as getUserRoles } from "./types-DV9WDfeg.mjs";
4
4
  import fp from "fastify-plugin";
5
5
  //#region src/scope/elevation.ts
6
6
  var elevation_exports = /* @__PURE__ */ __exportAll({
@@ -39,13 +39,48 @@ function fastifyValidationDetails(error) {
39
39
  return (error.validation ?? []).map((v) => {
40
40
  const missingProperty = v.params?.missingProperty;
41
41
  const path = v.instancePath?.replace(/^\//, "") || (typeof missingProperty === "string" ? missingProperty : void 0);
42
+ const meta = {};
43
+ if (v.keyword === "maximum" || v.keyword === "minimum" || v.keyword === "exclusiveMaximum" || v.keyword === "exclusiveMinimum") {
44
+ const bound = v.params?.limit;
45
+ if (typeof bound === "number") meta.bound = bound;
46
+ }
47
+ if (v.keyword === "enum") {
48
+ const allowed = v.params?.allowedValues;
49
+ if (Array.isArray(allowed)) meta.allowedValues = allowed;
50
+ }
42
51
  return {
43
52
  ...path ? { path } : {},
44
53
  code: v.keyword ?? "invalid",
45
- message: v.message || "Invalid value"
54
+ message: v.message || "Invalid value",
55
+ ...Object.keys(meta).length > 0 ? { meta } : {}
46
56
  };
47
57
  });
48
58
  }
59
+ /**
60
+ * Hoist a context-aware message + machine-readable `meta` for the
61
+ * common pagination-cap miss (`?limit=200` against a maxLimit=100
62
+ * resource). Pre-2.17.0 the wire envelope carried "Validation failed"
63
+ * + a detail with the AJV string; hosts had to grep the detail's
64
+ * message to surface the cap. Now the top-level message is
65
+ * "limit must be <= 100 (got 200)" AND `meta.cap` / `meta.received`
66
+ * are stamped on the contract for programmatic callers.
67
+ */
68
+ function describePaginationCap(error, details) {
69
+ if (!Array.isArray(error.validation) || error.validation.length === 0) return null;
70
+ const v = error.validation.find((entry) => (entry.instancePath === "/limit" || entry.instancePath === "/page") && (entry.keyword === "maximum" || entry.keyword === "minimum"));
71
+ if (!v) return null;
72
+ const field = v.instancePath === "/limit" ? "limit" : "page";
73
+ const bound = v.params?.limit;
74
+ if (typeof bound !== "number") return null;
75
+ const detail = details.find((d) => d.path === field);
76
+ return {
77
+ message: detail?.message ? `Query parameter '${field}' ${detail.message} (cap is ${bound})` : `Query parameter '${field}' violates bound ${bound}`,
78
+ meta: {
79
+ field,
80
+ cap: bound
81
+ }
82
+ };
83
+ }
49
84
  /** Map Mongoose `ValidationError.errors` → canonical `ErrorDetail[]`. */
50
85
  function mongooseValidationDetails(errors) {
51
86
  return Object.entries(errors).map(([field, e]) => ({
@@ -116,13 +151,19 @@ function classify(error, ctx) {
116
151
  const fastifyErr = error;
117
152
  if (Array.isArray(fastifyErr.validation)) {
118
153
  const arcInternalCode = typeof fastifyErr.code === "string" && fastifyErr.code.startsWith("arc.") ? fastifyErr.code : void 0;
119
- const message = arcInternalCode ? error.message || "Validation failed" : "Validation failed";
154
+ const details = fastifyValidationDetails(fastifyErr);
155
+ const paginationCap = arcInternalCode ? null : describePaginationCap(fastifyErr, details);
156
+ const message = paginationCap?.message ?? (arcInternalCode ? error.message || "Validation failed" : "Validation failed");
157
+ const mergedMeta = {
158
+ ...fastifyErr.meta ?? {},
159
+ ...paginationCap?.meta ?? {}
160
+ };
120
161
  return {
121
162
  code: arcInternalCode ?? "arc.validation_error",
122
163
  message,
123
164
  status: typeof fastifyErr.statusCode === "number" ? fastifyErr.statusCode : 400,
124
- details: fastifyValidationDetails(fastifyErr),
125
- ...fastifyErr.meta ? { meta: fastifyErr.meta } : {}
165
+ details,
166
+ ...Object.keys(mergedMeta).length > 0 ? { meta: mergedMeta } : {}
126
167
  };
127
168
  }
128
169
  if (typeof fastifyErr.statusCode === "number") return {
@@ -1,4 +1,37 @@
1
+ import sjson from "secure-json-parse";
1
2
  //#region src/events/transports/redis-stream.ts
3
+ /**
4
+ * Redis Stream Event Transport — Durable Event Delivery
5
+ *
6
+ * Uses Redis Streams (`XADD`/`XREADGROUP`) for persistent, at-least-once event
7
+ * delivery across multiple service instances. Unlike Pub/Sub, events are stored
8
+ * in Redis and survive crashes/restarts.
9
+ *
10
+ * **Delivery guarantee:** at-least-once. Failed messages are left unacked and
11
+ * reclaimed after `claimTimeoutMs`, which can result in duplicate handler
12
+ * execution. Consumers must be idempotent (e.g. use `event.meta.id` as a
13
+ * deduplication key) to achieve effectively-once processing.
14
+ *
15
+ * Key features:
16
+ * - Consumer groups: each event delivered to exactly one consumer per group
17
+ * - Crash recovery: pending entries are auto-claimed after `claimTimeoutMs`
18
+ * - Dead letter stream: events exceeding `maxRetries` are moved to a DLQ
19
+ * - Backpressure: configurable block time and batch size
20
+ *
21
+ * @example
22
+ * ```typescript
23
+ * import { RedisStreamTransport } from '@classytic/arc/events';
24
+ * import Redis from 'ioredis';
25
+ *
26
+ * const transport = new RedisStreamTransport(new Redis(), {
27
+ * stream: 'arc-events',
28
+ * group: 'api-service',
29
+ * consumer: 'worker-1',
30
+ * });
31
+ *
32
+ * await app.register(eventPlugin, { transport });
33
+ * ```
34
+ */
2
35
  var RedisStreamTransport = class {
3
36
  name = "redis-stream";
4
37
  redis;
@@ -350,7 +383,7 @@ function parseStreamFields(fields) {
350
383
  }
351
384
  if (!eventType || !rawData) return null;
352
385
  try {
353
- const parsed = JSON.parse(rawData, (key, value) => {
386
+ const parsed = sjson.parse(rawData, (key, value) => {
354
387
  if (key === "timestamp" && typeof value === "string") return new Date(value);
355
388
  return value;
356
389
  });
@@ -1,5 +1,21 @@
1
+ import sjson from "secure-json-parse";
1
2
  //#region src/events/transports/redis.ts
2
3
  /**
4
+ * Redis Event Transport
5
+ *
6
+ * Uses Redis Pub/Sub for cross-process event delivery.
7
+ * Optional dependency — only imported when explicitly used.
8
+ *
9
+ * @example
10
+ * import { RedisEventTransport } from '@classytic/arc/events/redis';
11
+ * import Redis from 'ioredis';
12
+ *
13
+ * const redis = new Redis();
14
+ * const transport = new RedisEventTransport(redis, { channel: 'arc-events' });
15
+ *
16
+ * await app.register(eventPlugin, { transport });
17
+ */
18
+ /**
3
19
  * DomainEvent contains a `Date` in `meta.timestamp`. We serialize it as an
4
20
  * ISO string and revive it on the other end so subscribers always receive a
5
21
  * proper Date object.
@@ -11,7 +27,7 @@ function serialize(event) {
11
27
  });
12
28
  }
13
29
  function deserialize(raw) {
14
- return JSON.parse(raw, (key, value) => {
30
+ return sjson.parse(raw, (key, value) => {
15
31
  if (key === "timestamp" && typeof value === "string") return new Date(value);
16
32
  return value;
17
33
  });
@@ -1,4 +1,4 @@
1
- import { a as CustomPluginAuthOption, c as RawBodyOptions, d as preloadResourcesAsync, f as LoadResourcesOptions, h as loadResources, i as CustomAuthenticatorOption, l as UnderPressureOptions, m as ResourceModule, n as BetterAuthOption, o as JwtAuthOption, p as ResourceLike, r as CreateAppOptions, s as MultipartOptions, t as AuthOption, u as preloadResources } from "../types-Dw--SyTq.mjs";
1
+ import { a as CustomPluginAuthOption, c as RawBodyOptions, d as preloadResourcesAsync, f as LoadResourcesOptions, h as loadResources, i as CustomAuthenticatorOption, l as UnderPressureOptions, m as ResourceModule, n as BetterAuthOption, o as JwtAuthOption, p as ResourceLike, r as CreateAppOptions, s as MultipartOptions, t as AuthOption, u as preloadResources } from "../types-BRmS8TNa.mjs";
2
2
  import { FastifyInstance, FastifyServerOptions } from "fastify";
3
3
 
4
4
  //#region src/factory/createApp.d.ts
@@ -1,5 +1,5 @@
1
1
  import { n as preloadResourcesAsync, t as preloadResources } from "../preloadResources-BOXzPhKU.mjs";
2
- import { a as edgePreset, c as testingPreset, i as developmentPreset, n as createApp, o as getPreset, s as productionPreset, t as ArcFactory } from "../createApp-DdPBtmzp.mjs";
2
+ import { a as edgePreset, c as testingPreset, i as developmentPreset, n as createApp, o as getPreset, s as productionPreset, t as ArcFactory } from "../createApp-CzCEEsEm.mjs";
3
3
  import { t as loadResources } from "../loadResources-fol6JuvW.mjs";
4
4
  //#region src/factory/edge.ts
5
5
  /**
@@ -1,4 +1,4 @@
1
- import { An as HookSystem, z as ResourceRegistry } from "./index-DBDgy6Hw.mjs";
1
+ import { An as HookSystem, z as ResourceRegistry } from "./index-C1JSBan-.mjs";
2
2
  import { t as ExternalOpenApiPaths } from "./externalPaths-ClykOQqa.mjs";
3
3
  import { FastifyPluginAsync } from "fastify";
4
4
 
@@ -1,2 +1,2 @@
1
- import { An as HookSystem, Dn as HookOperation, En as HookHandler, Fn as beforeCreate, In as beforeDelete, Ln as beforeUpdate, Mn as afterCreate, Nn as afterDelete, On as HookPhase, Pn as afterUpdate, Rn as createHookSystem, Tn as HookContext, jn as HookSystemOptions, kn as HookRegistration, wn as DefineHookOptions, zn as defineHook } from "../index-DBDgy6Hw.mjs";
1
+ import { An as HookSystem, Dn as HookOperation, En as HookHandler, Fn as beforeCreate, In as beforeDelete, Ln as beforeUpdate, Mn as afterCreate, Nn as afterDelete, On as HookPhase, Pn as afterUpdate, Rn as createHookSystem, Tn as HookContext, jn as HookSystemOptions, kn as HookRegistration, wn as DefineHookOptions, zn as defineHook } from "../index-C1JSBan-.mjs";
2
2
  export { type DefineHookOptions, type HookContext, type HookHandler, type HookOperation, type HookPhase, type HookRegistration, HookSystem, type HookSystemOptions, afterCreate, afterDelete, afterUpdate, beforeCreate, beforeDelete, beforeUpdate, createHookSystem, defineHook };
@@ -266,6 +266,12 @@ var MemoryIdempotencyStore = class {
266
266
  */
267
267
  const HEADER_IDEMPOTENCY_REPLAYED = "x-idempotency-replayed";
268
268
  const HEADER_IDEMPOTENCY_KEY = "x-idempotency-key";
269
+ /**
270
+ * Cap recursion depth in body fingerprinting. Real APIs almost never exceed
271
+ * ~10 levels; the cap is generous so well-formed payloads are unaffected,
272
+ * yet low enough to stop a `{"a":{"a":{...thousands deep...}}}` stack bomb.
273
+ */
274
+ const MAX_FINGERPRINT_DEPTH = 32;
269
275
  const idempotencyPlugin = async (fastify, opts = {}) => {
270
276
  const { enabled = false, headerName = "idempotency-key", ttlMs = 864e5, lockTimeoutMs = 3e4, methods = [
271
277
  "POST",
@@ -298,14 +304,24 @@ const idempotencyPlugin = async (fastify, opts = {}) => {
298
304
  return true;
299
305
  }
300
306
  /**
301
- * Normalize body for consistent hashing (sort keys recursively)
307
+ * Normalize body for consistent hashing (sort keys recursively).
308
+ *
309
+ * Recursion is bounded by `MAX_FINGERPRINT_DEPTH` to defeat a deep-nesting
310
+ * DoS: an attacker who sends a 1 MB JSON body of nested `{"a":{"a":...}}`
311
+ * would otherwise blow the call stack inside this hot path. The cap
312
+ * exceeds any sensible API shape (real bodies are <10 levels deep) while
313
+ * still being well under V8's default stack budget. At the limit we
314
+ * substitute a sentinel string so the hash still differs from siblings
315
+ * but recursion stops — the request is fingerprinted as if everything
316
+ * beyond the cap were `"<truncated>"`.
302
317
  */
303
- function normalizeBody(obj) {
318
+ function normalizeBody(obj, depth = 0) {
304
319
  if (obj === null || typeof obj !== "object") return obj;
305
- if (Array.isArray(obj)) return obj.map(normalizeBody);
320
+ if (depth >= MAX_FINGERPRINT_DEPTH) return "<truncated>";
321
+ if (Array.isArray(obj)) return obj.map((item) => normalizeBody(item, depth + 1));
306
322
  const sorted = {};
307
323
  const keys = Object.keys(obj).sort();
308
- for (const key of keys) sorted[key] = normalizeBody(obj[key]);
324
+ for (const key of keys) sorted[key] = normalizeBody(obj[key], depth + 1);
309
325
  return sorted;
310
326
  }
311
327
  /**
@@ -2458,8 +2458,42 @@ interface RouteDefinition {
2458
2458
  * @default false
2459
2459
  */
2460
2460
  readonly tenantScope?: boolean;
2461
- /** SSE streaming mode */
2461
+ /**
2462
+ * SSE streaming mode. Two handler shapes work:
2463
+ *
2464
+ * - Write to `reply.raw` directly (NDJSON, custom SSE — historical contract).
2465
+ * - Return a Web `ReadableStream` (Vercel AI SDK's `result.toUIMessageStream()`,
2466
+ * `fetch().body`, etc.). 2.17.1+ auto-pipes these through
2467
+ * `pipeUIMessageStreamToReply()` so each chunk is JSON-encoded into
2468
+ * an SSE `data:` frame and client-disconnect cancels the source.
2469
+ * Pre-2.17.1 this crashed with `chunk must be a string or Buffer`.
2470
+ */
2462
2471
  readonly streamResponse?: boolean;
2472
+ /**
2473
+ * INTERNAL — marker stamped by built-in presets (softDelete, tree, …)
2474
+ * onto routes they emit. Read by the MCP collision detector
2475
+ * (`createMcpServer.resolveToolCollisions`) to attribute the route
2476
+ * source ("preset:softDelete vs user `actions.restore`") and to
2477
+ * auto-namespace the preset side when a user-authored tool legitimately
2478
+ * shadows it. Not part of the public surface — hosts must not set this.
2479
+ *
2480
+ * @internal
2481
+ */
2482
+ readonly _presetSource?: string;
2483
+ /**
2484
+ * Apply the resource's `fields` (field-level write permissions) to this
2485
+ * custom route's request body.
2486
+ *
2487
+ * Default: `true` for body-bearing methods (POST/PUT/PATCH), `false`
2488
+ * otherwise. Set to `false` to opt out — handler accepts the body as
2489
+ * supplied with no field-write filtering. The resource-level
2490
+ * `onFieldWriteDenied: 'reject' | 'strip'` setting governs how denials
2491
+ * are surfaced.
2492
+ *
2493
+ * `raw: true` routes ALWAYS bypass this — they've opted out of the
2494
+ * pipeline entirely.
2495
+ */
2496
+ readonly fieldWrite?: boolean;
2463
2497
  /**
2464
2498
  * Fastify route schema. Each slot (`body`, `querystring`, `params`,
2465
2499
  * `headers`, `response[status]`) accepts a plain JSON Schema object
@@ -2941,6 +2975,86 @@ interface ResourceConfig<TDoc = AnyRecord> {
2941
2975
  skipValidation?: boolean;
2942
2976
  /** Don't register in introspection */
2943
2977
  skipRegistry?: boolean;
2978
+ /**
2979
+ * Default `limit` value when the request omits `?limit=`. Default 20.
2980
+ * Surfaced at the resource level (rather than only via a custom
2981
+ * `queryParser`) so hosts can declare it inline next to the resource
2982
+ * shape. Threads into both the auto-built controller's QueryResolver
2983
+ * AND the OpenAPI listQuery schema.
2984
+ *
2985
+ * For "fetch all" reference data, prefer the `referenceData: true`
2986
+ * shorthand below — it pins this AND `maxLimit` AND aggressive cache
2987
+ * defaults in one declaration.
2988
+ */
2989
+ defaultLimit?: number;
2990
+ /**
2991
+ * URL-driven `limit` ceiling. Default 100. Requests with
2992
+ * `?limit=N` above this cap fail validation with a 400 that names
2993
+ * the cap (`Query parameter 'limit' must be <= 100 (got N)`,
2994
+ * `meta.cap: 100` on the wire envelope) — 2.17.0+ surfaces the cap
2995
+ * value programmatically so callers can self-correct.
2996
+ */
2997
+ maxLimit?: number;
2998
+ /**
2999
+ * Mark this resource as **reference data** — small, mostly-static rows
3000
+ * (currencies, countries, plans, credentials, pipeline stages) that
3001
+ * callers want to fetch all of at once and cache aggressively.
3002
+ *
3003
+ * Sets the following defaults (each only when the corresponding
3004
+ * narrower flag is not explicitly set, so this stays composable):
3005
+ *
3006
+ * - `crud: { list: true, get: true }` — read-only surface (reference
3007
+ * data is rarely mutated through the public API; mutate via
3008
+ * migrations / admin tools).
3009
+ * - `defaultLimit: 1000` — one request returns everything by default.
3010
+ * - `maxLimit: 1000` — same cap so explicit `?limit=` requests
3011
+ * don't have to know the resource is reference data.
3012
+ * - `cache: { staleTime: 300, gcTime: 600 }` — 5 min fresh / 10 min
3013
+ * GC window so reference data isn't refetched on every request
3014
+ * (values are seconds per `ResourceCacheConfig`). No-op without
3015
+ * `queryCachePlugin` — same contract as the manual `cache` field;
3016
+ * a first-mount diagnostic fires when the plugin is missing.
3017
+ *
3018
+ * Pre-2.17.0 hosts achieved this by sprinkling `defaultLimit: 1000` +
3019
+ * a custom queryParser + a `cache:` block across every reference
3020
+ * resource. The shorthand collapses that to one flag.
3021
+ *
3022
+ * @example
3023
+ * ```ts
3024
+ * defineResource({
3025
+ * name: 'credential-type',
3026
+ * adapter,
3027
+ * referenceData: true,
3028
+ * });
3029
+ * ```
3030
+ */
3031
+ referenceData?: boolean;
3032
+ /**
3033
+ * Shorthand for the "service resource" pattern — a resource that ships
3034
+ * ONLY custom routes (no auto-CRUD, no adapter, no validation, no
3035
+ * introspection registry entry). Setting `customRoutesOnly: true`
3036
+ * is equivalent to the three-flag combo:
3037
+ *
3038
+ * ```ts
3039
+ * { disableDefaultRoutes: true, skipValidation: true, skipRegistry: true }
3040
+ * ```
3041
+ *
3042
+ * Each of those flags stays public — power users that need to opt OUT
3043
+ * of one (e.g. `customRoutesOnly: true` + `skipRegistry: false` because
3044
+ * a custom-routes-only resource still wants OpenAPI docs) can pass the
3045
+ * narrow flag explicitly; explicit settings always win over the
3046
+ * shorthand. The shorthand only fills in flags that were not set.
3047
+ *
3048
+ * @example
3049
+ * ```ts
3050
+ * defineResource({
3051
+ * name: 'health',
3052
+ * customRoutesOnly: true,
3053
+ * routes: [{ method: 'GET', path: '/ping', handler: () => ({ ok: true }) }],
3054
+ * });
3055
+ * ```
3056
+ */
3057
+ customRoutesOnly?: boolean;
2944
3058
  /**
2945
3059
  * Per-resource MCP opt-out.
2946
3060
  *
@@ -3084,7 +3198,16 @@ declare class ResourceDefinition<TDoc = AnyRecord> {
3084
3198
  readonly updateMethod?: "PUT" | "PATCH" | "both";
3085
3199
  readonly pipe?: PipelineConfig;
3086
3200
  readonly fields?: FieldPermissionMap;
3201
+ /**
3202
+ * Policy for field-write denials — `'reject'` (default, secure) raises 403
3203
+ * listing the denied fields; `'strip'` silently drops them. Honored by
3204
+ * both `BodySanitizer` (auto-CRUD) and `buildFieldWritePreHandler`
3205
+ * (custom routes) so the policy is consistent across all body-bearing
3206
+ * routes for the resource.
3207
+ */
3208
+ readonly onFieldWriteDenied?: "reject" | "strip";
3087
3209
  readonly cache?: ResourceCacheConfig;
3210
+ readonly referenceData: boolean;
3088
3211
  /**
3089
3212
  * Per-resource MCP opt-out. `false` keeps the resource out of every
3090
3213
  * `mcpPlugin` registration regardless of the plugin's `expose` /
@@ -3121,8 +3244,34 @@ declare class ResourceDefinition<TDoc = AnyRecord> {
3121
3244
  * `buildResourcePlugin` so the host's configured logger owns the
3122
3245
  * output — the framework never speaks to `console.*` from `src/`
3123
3246
  * outside of the CLI.
3247
+ *
3248
+ * Prefer the public {@link warnings} getter for programmatic access
3249
+ * (CI fail-on-warnings, lint integrations) — it returns a frozen
3250
+ * snapshot so callers can't accidentally mutate the diagnostic list.
3124
3251
  */
3125
3252
  _diagnostics?: ResourceDiagnostic[];
3253
+ /**
3254
+ * Public view of {@link _diagnostics} for programmatic consumption.
3255
+ *
3256
+ * Returns the collected define-time diagnostics for this resource.
3257
+ * Empty when the resource validated cleanly. Useful for CI gates that
3258
+ * want to fail the build on any non-fatal warning:
3259
+ *
3260
+ * @example
3261
+ * ```ts
3262
+ * import { orderResource } from './resources/order.resource.js';
3263
+ * if (orderResource.warnings.length > 0) {
3264
+ * for (const w of orderResource.warnings) console.error(w.message);
3265
+ * process.exit(1);
3266
+ * }
3267
+ * ```
3268
+ *
3269
+ * **Scope.** Covers define-time only — diagnostics emitted at
3270
+ * first-mount (e.g. `cache.invalidateOn` without `queryCachePlugin`)
3271
+ * surface through `fastify.log.warn` instead and are not collected
3272
+ * here; they require a Fastify instance to detect.
3273
+ */
3274
+ get warnings(): readonly ResourceDiagnostic[];
3126
3275
  /**
3127
3276
  * Per-host idempotency guard used by `buildResourcePlugin` to
3128
3277
  * skip duplicate shared-state writes when the same resource is
@@ -3176,6 +3325,26 @@ declare class ResourceDefinition<TDoc = AnyRecord> {
3176
3325
  * `BaseController<TDoc extends AnyRecord>` actually requires it.
3177
3326
  * One internal boundary cast replaces N host-side casts.
3178
3327
  */
3328
+ /**
3329
+ * Narrow overload: when the host passes an `actions: { ... }` literal, the
3330
+ * returned `ResourceDefinition.actions` is typed as the exact captured map
3331
+ * (not the wide `ActionsMap | undefined`). Without this overload, hosts that
3332
+ * wrote `defineResource({ actions: { send: ..., receive: ... } })` saw
3333
+ * `resource.actions` typed `ActionsMap | undefined` and got
3334
+ * "'resource.actions' is possibly 'undefined'" on every `resource.actions.send`
3335
+ * call. TS infers `TActions` from the object literal at the call site —
3336
+ * no explicit generic needed.
3337
+ */
3338
+ declare function defineResource<TDoc, TActions extends ActionsMap>(config: ResourceConfig<TDoc> & {
3339
+ actions: TActions;
3340
+ }): ResourceDefinition<TDoc> & {
3341
+ readonly actions: TActions;
3342
+ };
3343
+ /**
3344
+ * Wide overload: no `actions` declared. `ResourceDefinition<TDoc>.actions`
3345
+ * stays `ActionsMap | undefined` exactly as in pre-2.17.1 — no
3346
+ * back-compat breakage for hosts that never touched actions.
3347
+ */
3179
3348
  declare function defineResource<TDoc = AnyRecord>(config: ResourceConfig<TDoc>): ResourceDefinition<TDoc>;
3180
3349
  //#endregion
3181
3350
  //#region src/registry/ResourceRegistry.d.ts
@@ -3445,6 +3614,13 @@ interface CrudRouterOptions {
3445
3614
  schemaOptions?: RouteSchemaOptions;
3446
3615
  /** Field-level permissions (visibility, writability per role) */
3447
3616
  fields?: FieldPermissionMap;
3617
+ /**
3618
+ * Policy for handling fields the caller lacks write permission for.
3619
+ * Auto-CRUD reads this via `BodySanitizer`; custom routes' field-write
3620
+ * preHandler honors the same setting. Default: `'reject'` — silent
3621
+ * stripping hides misconfigurations and real attacks.
3622
+ */
3623
+ onFieldWriteDenied?: "reject" | "strip";
3448
3624
  /** HTTP method for update routes. Default: 'PATCH' */
3449
3625
  updateMethod?: "PUT" | "PATCH" | "both";
3450
3626
  /**
@@ -1,4 +1,4 @@
1
- import { C as RequestContext, F as FastifyWithDecorators, H as ActionDefinition, K as ArcFieldRule, Kt as AnyRecord, L as RequestWithExtras, T as CrudRouterOptions, Tt as AggregationConfig, V as ResourceDefinition, bt as IRequestContext, ft as RouteMcpConfig, mt as RouteSchemaOptions, q as CrudController, st as ResourceConfig, vt as IController, yt as IControllerResponse } from "./index-DBDgy6Hw.mjs";
1
+ import { C as RequestContext, F as FastifyWithDecorators, H as ActionDefinition, K as ArcFieldRule, Kt as AnyRecord, L as RequestWithExtras, T as CrudRouterOptions, Tt as AggregationConfig, V as ResourceDefinition, bt as IRequestContext, ft as RouteMcpConfig, mt as RouteSchemaOptions, q as CrudController, st as ResourceConfig, vt as IController, yt as IControllerResponse } from "./index-C1JSBan-.mjs";
2
2
  import { i as RequestScope } from "./types-CfapW2t8.mjs";
3
3
  import { c as PermissionCheck } from "./fields-DqZLp5d6.mjs";
4
4
  import { FastifyReply, FastifyRequest, RouteHandlerMethod } from "fastify";
package/dist/index.d.mts CHANGED
@@ -1,6 +1,6 @@
1
- import { $t as BaseController, A as RequestIdOptions, At as AggregationRateLimit, B as defineResource, C as RequestContext, Ct as AggMeasureShorthand, D as HealthCheck, Dt as AggregationIndexHint, E as GracefulShutdownOptions, Et as AggregationDateRangeRequirement, F as FastifyWithDecorators, J as CrudRouteKey, Jt as ArcRequest, Kt as AnyRecord, L as RequestWithExtras, N as FastifyRequestExtras, O as HealthOptions, Ot as AggregationMaterializedContext, P as FastifyWithAuth, Q as MiddlewareConfig, Qt as UserOrganization, S as QueryParserInterface, St as AggMeasureInput, T as CrudRouterOptions, Tt as AggregationConfig, V as ResourceDefinition, Vt as AuthPluginOptions, X as EventDefinition, Y as CrudSchemas, Yt as JWTPayload, Z as FieldRule, _n as ArcListResult, a as InferAdapterDoc, an as SlugMixin, bt as IRequestContext, c as TypedController, cn as BaseControllerOptions, d as PaginationResult, dn as ControllerConstructionOptions, en as SoftDeleteExt, f as IntrospectionData, g as ArcInternalMetadata, gn as ArcGetResult, gt as ControllerLike, h as ResourceMetadata, hn as ArcDeleteResult, i as ValidationResult, in as SlugExt, it as RateLimitConfig, jt as AggregationsMap, k as IntrospectionPluginOptions, kt as AggregationMaterializedResult, l as TypedRepository, ln as BaseCrudController, m as RegistryStats, mn as ArcCreateResult, mt as RouteSchemaOptions, n as ConfigError, nn as TreeExt, o as InferDocType, on as BulkExt, p as RegistryEntry, q as CrudController, qt as ApiResponse, r as ValidateOptions, rn as TreeMixin, rt as PresetResult, s as InferResourceDoc, sn as BulkMixin, st as ResourceConfig, t as RouteHandlerMethod, tn as SoftDeleteMixin, tt as PresetFunction, u as TypedResourceConfig, un as ControllerConfigurableOptions, vn as ArcUpdateResult, vt as IController, w as ServiceContext, wt as AggregationCacheConfig, xt as RouteHandler, y as OwnershipCheck, yn as ListResult, yt as IControllerResponse } from "./index-DBDgy6Hw.mjs";
1
+ import { $t as BaseController, A as RequestIdOptions, At as AggregationRateLimit, B as defineResource, C as RequestContext, Ct as AggMeasureShorthand, D as HealthCheck, Dt as AggregationIndexHint, E as GracefulShutdownOptions, Et as AggregationDateRangeRequirement, F as FastifyWithDecorators, J as CrudRouteKey, Jt as ArcRequest, Kt as AnyRecord, L as RequestWithExtras, N as FastifyRequestExtras, O as HealthOptions, Ot as AggregationMaterializedContext, P as FastifyWithAuth, Q as MiddlewareConfig, Qt as UserOrganization, S as QueryParserInterface, St as AggMeasureInput, T as CrudRouterOptions, Tt as AggregationConfig, V as ResourceDefinition, Vt as AuthPluginOptions, X as EventDefinition, Y as CrudSchemas, Yt as JWTPayload, Z as FieldRule, _n as ArcListResult, a as InferAdapterDoc, an as SlugMixin, bt as IRequestContext, c as TypedController, cn as BaseControllerOptions, d as PaginationResult, dn as ControllerConstructionOptions, en as SoftDeleteExt, f as IntrospectionData, g as ArcInternalMetadata, gn as ArcGetResult, gt as ControllerLike, h as ResourceMetadata, hn as ArcDeleteResult, i as ValidationResult, in as SlugExt, it as RateLimitConfig, jt as AggregationsMap, k as IntrospectionPluginOptions, kt as AggregationMaterializedResult, l as TypedRepository, ln as BaseCrudController, m as RegistryStats, mn as ArcCreateResult, mt as RouteSchemaOptions, n as ConfigError, nn as TreeExt, o as InferDocType, on as BulkExt, p as RegistryEntry, q as CrudController, qt as ApiResponse, r as ValidateOptions, rn as TreeMixin, rt as PresetResult, s as InferResourceDoc, sn as BulkMixin, st as ResourceConfig, t as RouteHandlerMethod, tn as SoftDeleteMixin, tt as PresetFunction, u as TypedResourceConfig, un as ControllerConfigurableOptions, vn as ArcUpdateResult, vt as IController, w as ServiceContext, wt as AggregationCacheConfig, xt as RouteHandler, y as OwnershipCheck, yn as ListResult, yt as IControllerResponse } from "./index-C1JSBan-.mjs";
2
2
  import { a as applyFieldWritePermissions, c as PermissionCheck, d as UserBase, i as applyFieldReadPermissions, l as PermissionContext, n as FieldPermissionMap, o as fields, t as FieldPermission, u as PermissionResult } from "./fields-DqZLp5d6.mjs";
3
- import { A as MAX_FILTER_DEPTH, C as DEFAULT_SORT, D as HOOK_PHASES, E as HOOK_OPERATIONS, F as RESERVED_QUERY_PARAMS, I as SYSTEM_FIELDS, M as MAX_SEARCH_LENGTH, N as MUTATION_OPERATIONS, O as HookOperation, P as MutationOperation, S as DEFAULT_MAX_LIMIT, T as DEFAULT_UPDATE_METHOD, _ as defineAggregation, b as DEFAULT_ID_FIELD, f as defineResourceVariants, j as MAX_REGEX_LENGTH, k as HookPhase, m as defineAction, s as getControllerScope, v as CRUD_OPERATIONS, w as DEFAULT_TENANT_FIELD, x as DEFAULT_LIMIT, y as CrudOperation } from "./index-CC_pgZjY.mjs";
3
+ import { A as MAX_FILTER_DEPTH, C as DEFAULT_SORT, D as HOOK_PHASES, E as HOOK_OPERATIONS, F as RESERVED_QUERY_PARAMS, I as SYSTEM_FIELDS, M as MAX_SEARCH_LENGTH, N as MUTATION_OPERATIONS, O as HookOperation, P as MutationOperation, S as DEFAULT_MAX_LIMIT, T as DEFAULT_UPDATE_METHOD, _ as defineAggregation, b as DEFAULT_ID_FIELD, f as defineResourceVariants, j as MAX_REGEX_LENGTH, k as HookPhase, m as defineAction, s as getControllerScope, v as CRUD_OPERATIONS, w as DEFAULT_TENANT_FIELD, x as DEFAULT_LIMIT, y as CrudOperation } from "./index-CruZir1o.mjs";
4
4
  import { A as requireRoles, C as allOf, E as denyAll, M as when, O as requireAuth, S as createOrgPermissions, T as anyOf, a as presets_d_exports, c as readOnly, d as requireOrgRole, f as requireScopeContext, i as ownerWithAdminBypass, k as requireOwnership, l as requireOrgInScope, m as requireTeamMembership, n as authenticated, o as publicRead, p as requireServiceScope, r as fullPublic, s as publicReadAdminWrite, t as adminOnly, u as requireOrgMembership, v as DynamicPermissionMatrix, w as allowPublic, x as createDynamicPermissionMatrix, y as DynamicPermissionMatrixConfig } from "./index-3L-adFJO.mjs";
5
5
  import { a as NotFoundError, d as ValidationError, f as createDomainError, i as ForbiddenError, t as ArcError, u as UnauthorizedError } from "./errors-4ZloYFvm.mjs";
6
6
 
package/dist/index.mjs CHANGED
@@ -1,10 +1,10 @@
1
1
  import { a as DEFAULT_SORT, c as HOOK_OPERATIONS, d as MAX_REGEX_LENGTH, f as MAX_SEARCH_LENGTH, h as SYSTEM_FIELDS, i as DEFAULT_MAX_LIMIT, l as HOOK_PHASES, m as RESERVED_QUERY_PARAMS, n as DEFAULT_ID_FIELD, o as DEFAULT_TENANT_FIELD, p as MUTATION_OPERATIONS, r as DEFAULT_LIMIT, s as DEFAULT_UPDATE_METHOD, t as CRUD_OPERATIONS, u as MAX_FILTER_DEPTH } from "./constants-TrJVIJl0.mjs";
2
2
  import { d as createDomainError, i as NotFoundError, l as UnauthorizedError, r as ForbiddenError, t as ArcError, u as ValidationError } from "./errors-j4aJm1Wg.mjs";
3
- import { a as BulkMixin, i as SlugMixin, n as TreeMixin, o as BaseCrudController, r as SoftDeleteMixin, t as BaseController } from "./BaseController-Vk8I7C0x.mjs";
4
- import { C as allowPublic, D as requireAuth, O as requireOwnership, S as allOf, T as denyAll, _ as requireOrgMembership, a as presets_exports, b as requireServiceScope, c as readOnly, d as applyFieldWritePermissions, f as fields, g as requireOrgInScope, h as createOrgPermissions, i as ownerWithAdminBypass, j as when, k as requireRoles, m as createDynamicPermissionMatrix, n as authenticated, o as publicRead, r as fullPublic, s as publicReadAdminWrite, t as adminOnly, u as applyFieldReadPermissions, v as requireOrgRole, w as anyOf, x as requireTeamMembership, y as requireScopeContext } from "./permissions-CTxMrreC.mjs";
5
- import { v as getControllerScope } from "./routerShared-ClV4cCaa.mjs";
6
- import { a as defineResource, i as defineResourceVariants, o as ResourceDefinition, s as defineAction, u as defineAggregation } from "./core-wKeGXn38.mjs";
3
+ import { a as BulkMixin, i as SlugMixin, n as TreeMixin, o as BaseCrudController, r as SoftDeleteMixin, t as BaseController } from "./BaseController-CgHomZ4d.mjs";
4
+ import { C as requireAuth, D as when, M as applyFieldWritePermissions, N as fields, T as requireRoles, _ as requireTeamMembership, a as presets_exports, b as anyOf, c as readOnly, d as createOrgPermissions, f as requireOrgInScope, g as requireServiceScope, h as requireScopeContext, i as ownerWithAdminBypass, j as applyFieldReadPermissions, m as requireOrgRole, n as authenticated, o as publicRead, p as requireOrgMembership, r as fullPublic, s as publicReadAdminWrite, t as adminOnly, u as createDynamicPermissionMatrix, v as allOf, w as requireOwnership, x as denyAll, y as allowPublic } from "./permissions-B1Qy55fg.mjs";
5
+ import { x as getControllerScope } from "./routerShared-nCyJGOKX.mjs";
6
+ import { a as defineResource, i as defineResourceVariants, o as ResourceDefinition, s as defineAction, u as defineAggregation } from "./core-D8tpQ4ZL.mjs";
7
7
  //#region src/index.ts
8
- const version = "2.16.1";
8
+ const version = "2.17.1";
9
9
  //#endregion
10
10
  export { ArcError, BaseController, BaseCrudController, BulkMixin, CRUD_OPERATIONS, DEFAULT_ID_FIELD, DEFAULT_LIMIT, DEFAULT_MAX_LIMIT, DEFAULT_SORT, DEFAULT_TENANT_FIELD, DEFAULT_UPDATE_METHOD, ForbiddenError, HOOK_OPERATIONS, HOOK_PHASES, MAX_FILTER_DEPTH, MAX_REGEX_LENGTH, MAX_SEARCH_LENGTH, MUTATION_OPERATIONS, NotFoundError, RESERVED_QUERY_PARAMS, ResourceDefinition, SYSTEM_FIELDS, SlugMixin, SoftDeleteMixin, TreeMixin, UnauthorizedError, ValidationError, adminOnly, allOf, allowPublic, anyOf, applyFieldReadPermissions, applyFieldWritePermissions, authenticated, createDomainError, createDynamicPermissionMatrix, createOrgPermissions, defineAction, defineAggregation, defineResource, defineResourceVariants, denyAll, fields, fullPublic, getControllerScope, ownerWithAdminBypass, presets_exports as permissions, publicRead, publicReadAdminWrite, readOnly, requireAuth, requireOrgInScope, requireOrgMembership, requireOrgRole, requireOwnership, requireRoles, requireScopeContext, requireServiceScope, requireTeamMembership, version, when };
@@ -1,7 +1,7 @@
1
1
  import { a as WebSocketMessage, i as WebSocketClient, o as WebSocketPluginOptions } from "../websocket-BJ0w-3Dt.mjs";
2
2
  import { EventGatewayOptions } from "./event-gateway.mjs";
3
3
  import { JobDefinition, JobDispatchOptions, JobDispatcher, JobMeta, JobsPluginOptions, QueueStats } from "./jobs.mjs";
4
- import { c as McpAuthResult, d as PromptDefinition, g as ToolDefinition, h as ToolContext, l as McpPluginOptions, m as ToolAnnotations, n as CallToolResult, o as CrudOperation, r as CreateMcpServerConfig, t as BetterAuthHandler, u as McpResourceConfig } from "../types-BD0BCdf6.mjs";
4
+ import { c as McpAuthResult, d as PromptDefinition, g as ToolDefinition, h as ToolContext, l as McpPluginOptions, m as ToolAnnotations, n as CallToolResult, o as CrudOperation, r as CreateMcpServerConfig, t as BetterAuthHandler, u as McpResourceConfig } from "../types-CoMjNmkF.mjs";
5
5
  import { StreamlinePluginOptions, WorkflowLike, WorkflowRunLike } from "./streamline.mjs";
6
6
  import { WebhookDeliveryRecord, WebhookManager, WebhookPluginOptions, WebhookStore, WebhookSubscription } from "./webhooks.mjs";
7
7
  export { type BetterAuthHandler, type CallToolResult, type CreateMcpServerConfig, type CrudOperation, type EventGatewayOptions, type JobDefinition, type JobDispatchOptions, type JobDispatcher, type JobMeta, type JobsPluginOptions, type McpAuthResult, type McpPluginOptions, type McpResourceConfig, type PromptDefinition, type QueueStats, type StreamlinePluginOptions, type ToolAnnotations, type ToolContext, type ToolDefinition, type WebSocketClient, type WebSocketMessage, type WebSocketPluginOptions, type WebhookDeliveryRecord, type WebhookManager, type WebhookPluginOptions, type WebhookStore, type WebhookSubscription, type WorkflowLike, type WorkflowRunLike };
@@ -39,6 +39,14 @@ interface JobDefinition<TData = unknown, TResult = unknown> {
39
39
  deadLetterQueue?: string;
40
40
  /** Repeat schedule — cron or interval. Requires explicit timezone for cron. */
41
41
  repeat?: JobRepeatOptions;
42
+ /**
43
+ * Arc-level semaphore: max simultaneous handler executions for this job type.
44
+ * Jobs that dequeue while all slots are full are held (state stays `active`)
45
+ * and appear as `throttled: true` in `GET /jobs/:id/status`.
46
+ * Distinct from BullMQ `concurrency` — use this when the constraint is a
47
+ * downstream resource (AI model rate limit, DB connection pool, external API).
48
+ */
49
+ maxConcurrent?: number;
42
50
  }
43
51
  interface JobMeta {
44
52
  jobId: string;
@@ -59,6 +67,27 @@ interface JobDispatchOptions {
59
67
  /** One-shot repeat override at dispatch time. Usually prefer `JobDefinition.repeat`. */
60
68
  repeat?: JobRepeatOptions;
61
69
  }
70
+ /** Point-in-time snapshot of a single job. */
71
+ interface JobStatus {
72
+ id: string;
73
+ /** Registered job name (= queue name). */
74
+ name: string;
75
+ state: "waiting" | "active" | "completed" | "failed" | "delayed" | "unknown";
76
+ /** Progress reported by the handler via BullMQ `job.updateProgress()`. */
77
+ progress: number | Record<string, unknown>;
78
+ /** True when the job is dequeued by a worker but blocked on a `maxConcurrent` semaphore slot. */
79
+ throttled?: boolean;
80
+ /** Unix ms when the job was enqueued. */
81
+ timestamp?: number;
82
+ /** Unix ms when processing started. */
83
+ processedOn?: number;
84
+ /** Unix ms when the job finished (completed or failed). */
85
+ finishedOn?: number;
86
+ /** Failure message when `state === 'failed'`. */
87
+ failedReason?: string;
88
+ /** Handler return value when `state === 'completed'` (only present if removeOnComplete hasn't cleared the job). */
89
+ returnValue?: unknown;
90
+ }
62
91
  interface JobsPluginOptions {
63
92
  /** Redis connection options (passed to BullMQ) */
64
93
  connection: {
@@ -91,6 +120,12 @@ interface JobDispatcher {
91
120
  }>;
92
121
  getQueue(name: string): unknown | null;
93
122
  getStats(): Promise<Record<string, QueueStats>>;
123
+ /**
124
+ * Fetch a point-in-time status snapshot for a job by its ID.
125
+ * Searches across all registered queues.
126
+ * Returns `null` if the job doesn't exist or has been removed by retention policy.
127
+ */
128
+ getStatus(jobId: string): Promise<JobStatus | null>;
94
129
  close(): Promise<void>;
95
130
  }
96
131
  interface QueueStats {
@@ -122,4 +157,4 @@ declare function defineJob<TData = unknown, TResult = unknown>(definition: JobDe
122
157
  */
123
158
  declare const jobsPlugin: FastifyPluginAsync<JobsPluginOptions>;
124
159
  //#endregion
125
- export { JobDefinition, JobDispatchOptions, JobDispatcher, JobMeta, JobRepeatOptions, JobsPluginOptions, QueueStats, defineJob, jobsPlugin };
160
+ export { JobDefinition, JobDispatchOptions, JobDispatcher, JobMeta, JobRepeatOptions, JobStatus, JobsPluginOptions, QueueStats, defineJob, jobsPlugin };