@classytic/arc 2.23.0 → 2.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/README.md +2 -0
  2. package/dist/{BaseController-BQ8KNvWC.mjs → BaseController-B65zbz9m.mjs} +94 -23
  3. package/dist/QueryCache-DOMT_iq7.d.mts +72 -0
  4. package/dist/actionPermissions-CAphcmUG.mjs +24 -0
  5. package/dist/{applyPermissionResult-Tw4iXtlf.mjs → applyPermissionResult-BnyVE9eD.mjs} +10 -0
  6. package/dist/audit/index.d.mts +32 -3
  7. package/dist/audit/index.mjs +7 -4
  8. package/dist/auth/index.d.mts +2 -2
  9. package/dist/auth/index.mjs +263 -259
  10. package/dist/{buildHandler-D_2ULWxZ.mjs → buildHandler-Dtwd8fdz.mjs} +1 -1
  11. package/dist/cache/index.d.mts +41 -3
  12. package/dist/cache/index.mjs +33 -4
  13. package/dist/cleanup/index.d.mts +313 -0
  14. package/dist/cleanup/index.mjs +1267 -0
  15. package/dist/cli/commands/describe.d.mts +1 -1
  16. package/dist/cli/commands/docs.mjs +1 -1
  17. package/dist/cli/commands/generate.mjs +1 -1
  18. package/dist/cli/commands/init.mjs +1 -1
  19. package/dist/cli/index.mjs +2 -2
  20. package/dist/context/index.mjs +2 -64
  21. package/dist/core/index.d.mts +3 -3
  22. package/dist/core/index.mjs +5 -4
  23. package/dist/core-BGa8TXJR.mjs +221 -0
  24. package/dist/{createActionRouter-BuOv-m4Y.mjs → createActionRouter-C-N7z2EL.mjs} +3 -2
  25. package/dist/{createAggregationRouter-CgJv_BU_.mjs → createAggregationRouter-wzwFa_dW.mjs} +3 -3
  26. package/dist/{createApp-CjOeWJ9_.mjs → createApp-Du5G54TM.mjs} +187 -96
  27. package/dist/{core-DQ6rDTyH.mjs → defineResource-2zvVVpxg.mjs} +297 -230
  28. package/dist/docs/index.d.mts +1 -1
  29. package/dist/docs/index.mjs +2 -2
  30. package/dist/{elevation-COpYKt2h.mjs → elevation-CIz1GjGm.mjs} +4 -3
  31. package/dist/{eventPlugin-A6EbthXR.d.mts → eventPlugin-Bg3cP2_u.d.mts} +38 -3
  32. package/dist/{eventPlugin-DJG2D7sy.mjs → eventPlugin-DrQ_FesK.mjs} +22 -4
  33. package/dist/events/index.d.mts +83 -314
  34. package/dist/events/index.mjs +183 -95
  35. package/dist/events/transports/redis-stream-entry.d.mts +1 -1
  36. package/dist/events/transports/redis-stream-entry.mjs +24 -2
  37. package/dist/factory/index.d.mts +5 -5
  38. package/dist/factory/index.mjs +30 -8
  39. package/dist/{fastify-augmentation-g_f9omSc.d.mts → fastify-augmentation-OzYGIiU1.d.mts} +26 -2
  40. package/dist/{generate-usRSdQmP.mjs → generate-D0wZupBg.mjs} +1 -1
  41. package/dist/headers-BvlQSDMB.mjs +45 -0
  42. package/dist/health-CB_m4S32.mjs +377 -0
  43. package/dist/hooks/index.d.mts +1 -1
  44. package/dist/idempotency/index.mjs +27 -3
  45. package/dist/{index-wMoWBLkn.d.mts → index-BMnbzvtO.d.mts} +3 -3
  46. package/dist/{integrations/jobs.d.mts → index-CG4yqMDB.d.mts} +82 -2
  47. package/dist/index-CZY4EGga.d.mts +107 -0
  48. package/dist/{index-CC5ruDGh.d.mts → index-CjVg-Epg.d.mts} +37 -5
  49. package/dist/{index-BBsvQt7U.d.mts → index-DPRH0Cme.d.mts} +36 -106
  50. package/dist/index.d.mts +6 -6
  51. package/dist/index.mjs +7 -6
  52. package/dist/{init-CjO4p_q1.mjs → init-tlRSt667.mjs} +206 -208
  53. package/dist/integrations/event-gateway.d.mts +1 -1
  54. package/dist/integrations/event-gateway.mjs +1 -1
  55. package/dist/integrations/index.d.mts +3 -3
  56. package/dist/integrations/jobs/index.d.mts +2 -0
  57. package/dist/integrations/{jobs.mjs → jobs/index.mjs} +110 -67
  58. package/dist/integrations/mcp/index.d.mts +4 -4
  59. package/dist/integrations/mcp/index.mjs +2 -2
  60. package/dist/integrations/mcp/testing.d.mts +1 -1
  61. package/dist/integrations/mcp/testing.mjs +1 -1
  62. package/dist/integrations/webhooks.d.mts +37 -30
  63. package/dist/integrations/webhooks.mjs +51 -62
  64. package/dist/integrations/websocket-redis.d.mts +1 -1
  65. package/dist/integrations/websocket.d.mts +1 -1
  66. package/dist/integrations/websocket.mjs +41 -14
  67. package/dist/{interface-C1_WLTMK.d.mts → interface-BGZCzOGL.d.mts} +24 -31
  68. package/dist/{mcpPlugin-BtzIL_kM.mjs → mcpPlugin-Cs6nRGCt.mjs} +12 -10
  69. package/dist/{memory-CLXA2VeJ.mjs → memory-CmnzXE5Z.mjs} +29 -0
  70. package/dist/middleware/index.d.mts +17 -1
  71. package/dist/middleware/index.mjs +1 -1
  72. package/dist/migrations/index.d.mts +94 -44
  73. package/dist/migrations/index.mjs +196 -44
  74. package/dist/module-_-M7C5ES.mjs +29 -0
  75. package/dist/{multipartBody-CHEQSZ6G.mjs → multipartBody-D0eBvkeq.mjs} +17 -0
  76. package/dist/{openapi-jSDx-VDv.mjs → openapi-BLV4OMXb.mjs} +3 -3
  77. package/dist/permissions/index.d.mts +2 -2
  78. package/dist/permissions/index.mjs +3 -3
  79. package/dist/{permissions-tbxFHxjZ.mjs → permissions-BrtiBKGs.mjs} +229 -229
  80. package/dist/pipeline/index.d.mts +1 -1
  81. package/dist/{actionPermissions-lszoJle1.mjs → pipeline-BGRnV-To.mjs} +4 -27
  82. package/dist/plugins/index.d.mts +6 -77
  83. package/dist/plugins/index.mjs +11 -95
  84. package/dist/plugins/response-cache.mjs +1 -0
  85. package/dist/plugins/tracing-entry.mjs +1 -1
  86. package/dist/{index-Ba10fdKp.d.mts → preloadResources-CM4uUaFv.d.mts} +51 -50
  87. package/dist/preloadResources-DSY5HGpS.mjs +45 -0
  88. package/dist/presets/filesUpload.d.mts +3 -3
  89. package/dist/presets/filesUpload.mjs +3 -3
  90. package/dist/presets/index.d.mts +2 -1
  91. package/dist/presets/index.mjs +1 -1
  92. package/dist/presets/multiTenant.d.mts +1 -1
  93. package/dist/presets/multiTenant.mjs +1 -1
  94. package/dist/presets/search.d.mts +2 -2
  95. package/dist/presets/search.mjs +1 -1
  96. package/dist/{presets-BOe4Ugkl.mjs → presets-DwFDVKP0.mjs} +2 -2
  97. package/dist/{queryCachePlugin-DzqLN904.mjs → queryCachePlugin-DSGuSVLW.mjs} +39 -8
  98. package/dist/{queryCachePlugin-CfYWq-Ak.d.mts → queryCachePlugin-br4otiqF.d.mts} +2 -2
  99. package/dist/{registerRoute-CWO6mdYe.mjs → rateLimit-DrED-C4v.mjs} +2 -109
  100. package/dist/{realtime-I8OrfnMR.mjs → realtime-DEuDu9io.mjs} +3 -3
  101. package/dist/{redis-stream-iVR2l7aS.d.mts → redis-stream-uIaauNpZ.d.mts} +22 -0
  102. package/dist/registerRoute-DTp18Ah_.mjs +108 -0
  103. package/dist/registry/index.d.mts +2 -2
  104. package/dist/registry/index.mjs +1 -1
  105. package/dist/{registry-vcOCtPij.mjs → registry-9km_w1aP.mjs} +15 -0
  106. package/dist/renewingLease-3cuQH7h8.mjs +44 -0
  107. package/dist/{requestContext-BZKOr13H.mjs → requestContext-MrlJwECf.mjs} +2 -2
  108. package/dist/schedules-CjOd2MG9.mjs +113 -0
  109. package/dist/schemas/index.d.mts +67 -67
  110. package/dist/schemas/index.mjs +2 -3
  111. package/dist/scim/index.mjs +1 -2
  112. package/dist/scope/index.d.mts +2 -2
  113. package/dist/scope/index.mjs +4 -4
  114. package/dist/{scope-LONQ3mfZ.mjs → scope-DfaPntpo.mjs} +4 -5
  115. package/dist/{sse-0u4P4-OI.mjs → sse-BQ1w0dfl.mjs} +1 -1
  116. package/dist/testing/cleanupStoreContract.d.mts +10 -0
  117. package/dist/testing/cleanupStoreContract.mjs +375 -0
  118. package/dist/testing/index.d.mts +5 -3
  119. package/dist/testing/index.mjs +4 -2
  120. package/dist/testing/outboxStoreContract.d.mts +13 -0
  121. package/dist/testing/outboxStoreContract.mjs +247 -0
  122. package/dist/transactionContext-PNdoyLLm.mjs +64 -0
  123. package/dist/types/index.d.mts +4 -4
  124. package/dist/{types-bHblZ9YN.mjs → types-CSyCxl2V.mjs} +38 -7
  125. package/dist/types-C_ZZRcD6.d.mts +450 -0
  126. package/dist/{module-B4u10iOJ.d.mts → types-Cu6vVJmh.d.mts} +196 -60
  127. package/dist/{types-DH0GupI7.d.mts → types-DjC-KUCz.d.mts} +118 -20
  128. package/dist/{types-Csxga7d3.d.mts → types-Dmw-6Qeo.d.mts} +96 -4
  129. package/dist/{types-5OuqeTDX.d.mts → types-QFGc3Cjv.d.mts} +1 -1
  130. package/dist/usage/index.d.mts +3 -52
  131. package/dist/usage/index.mjs +0 -0
  132. package/dist/userHelpers-Cg_y3fBz.mjs +29 -0
  133. package/dist/utils/index.d.mts +58 -85
  134. package/dist/utils/index.mjs +4 -2
  135. package/dist/{utils-CZyeRXTh.mjs → utils-taUVD4p3.mjs} +47 -337
  136. package/dist/{validate-C2mGgSXg.mjs → validate-Bk44F2yT.mjs} +1 -1
  137. package/dist/{versioning-D6PvCwpq.d.mts → versioning-DXgY5BUm.d.mts} +11 -29
  138. package/dist/{versioning-Dk-71iIz.mjs → versioning-gKWyryT3.mjs} +8 -1
  139. package/dist/{websocket-uuWqM6UY.d.mts → websocket-_Pd0CgR0.d.mts} +15 -1
  140. package/package.json +541 -513
  141. package/skills/arc/references/api-reference.md +3 -1
  142. package/skills/arc/references/integrations.md +20 -11
  143. package/dist/QueryCache-Z1B38koh.d.mts +0 -40
  144. package/dist/betterAuthOpenApi-CV1lxpRX.mjs +0 -2
  145. package/dist/health-ScyjIVo5.mjs +0 -202
  146. package/dist/requestId-5BhTVzBv.mjs +0 -113
  147. package/dist/types-XTApa8b6.mjs +0 -24
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # @classytic/arc
2
2
 
3
+ [![Sponsor](https://img.shields.io/github/sponsors/classytic?style=flat-square&label=Sponsor&logo=GitHub&color=EA4AAA)](https://github.com/sponsors/classytic)
4
+
3
5
  Database-agnostic resource framework for Fastify. One `defineResource()` call -> REST + auth + permissions + events + caching + OpenAPI + MCP tools.
4
6
 
5
7
  Fastify 5+ | Node.js 22+ | ESM only
@@ -1,14 +1,14 @@
1
1
  import { h as SYSTEM_FIELDS, o as DEFAULT_TENANT_FIELD } from "./constants-DNre0wRg.mjs";
2
2
  import { arcLog } from "./logger/index.mjs";
3
- import { d as createDomainError, f as createError, i as NotFoundError, r as ForbiddenError } from "./errors-DkO8Nk6-.mjs";
4
- import { b as isElevated, c as getOrgId, n as PUBLIC_SCOPE, x as isMember } from "./types-bHblZ9YN.mjs";
3
+ import { d as createDomainError, f as createError, i as NotFoundError, p as isArcError, r as ForbiddenError } from "./errors-DkO8Nk6-.mjs";
4
+ import { n as getUserRoles, t as getUserId } from "./userHelpers-Cg_y3fBz.mjs";
5
+ import { b as isElevated, c as getOrgId, n as PUBLIC_SCOPE, x as isMember } from "./types-CSyCxl2V.mjs";
5
6
  import { t as simpleEqualityMatcher } from "./simpleEqualityMatcher-Cg05HPsn.mjs";
6
- import { a as applyFieldWritePermissions, s as resolveEffectiveRoles } from "./applyPermissionResult-Tw4iXtlf.mjs";
7
- import { t as getUserRoles } from "./types-XTApa8b6.mjs";
8
- import { _ as ArcQueryParser, r as scheduleBackground, t as getUserId } from "./utils-CZyeRXTh.mjs";
7
+ import { a as applyFieldWritePermissions, s as resolveEffectiveRoles } from "./applyPermissionResult-BnyVE9eD.mjs";
8
+ import { g as ArcQueryParser, n as scheduleBackground } from "./utils-taUVD4p3.mjs";
9
9
  import { t as buildQueryKey } from "./keys-Cd316u_y.mjs";
10
10
  //#region src/core/AccessControl.ts
11
- const log = arcLog("access-control");
11
+ const log$1 = arcLog("access-control");
12
12
  var AccessControl = class {
13
13
  tenantField;
14
14
  idField;
@@ -88,7 +88,7 @@ var AccessControl = class {
88
88
  _warnNoMatcher(policyFilters) {
89
89
  if (this._warnedNoMatcher) return;
90
90
  this._warnedNoMatcher = true;
91
- log.warn("`_policyFilters` contains operator-shaped entries (e.g. `$in`, `$ne`, `$regex`) but `DataAdapter.matchesFilter` is not set. Arc's flat-equality fallback cannot evaluate operators and will reject these items on non-compound fetches (`validateItemAccess`, `getBySlug`, cache revalidation). Wire up `matchesFilter` on your adapter — use `matchFilter` from `@classytic/repo-core/filter` for IR-based adapters, or your DB's native predicate engine.", { policyFilterKeys: Object.keys(policyFilters) });
91
+ log$1.warn("`_policyFilters` contains operator-shaped entries (e.g. `$in`, `$ne`, `$regex`) but `DataAdapter.matchesFilter` is not set. Arc's flat-equality fallback cannot evaluate operators and will reject these items on non-compound fetches (`validateItemAccess`, `getBySlug`, cache revalidation). Wire up `matchesFilter` on your adapter — use `matchFilter` from `@classytic/repo-core/filter` for IR-based adapters, or your DB's native predicate engine.", { policyFilterKeys: Object.keys(policyFilters) });
92
92
  }
93
93
  /**
94
94
  * Check org/tenant scope for a document — uses configurable tenantField.
@@ -453,6 +453,7 @@ async function executeHookedOp(ctx, args, executor) {
453
453
  const beforeReturn = await hooks.executeBefore(resourceName, args.op, args.input, hookOpts);
454
454
  if (pipeProcessed) processedData = beforeReturn;
455
455
  } catch (err) {
456
+ if (isArcError(err)) throw err;
456
457
  throw createError(400, "Hook execution failed", {
457
458
  code: `BEFORE_${args.op.toUpperCase()}_HOOK_ERROR`,
458
459
  message: err.message
@@ -618,6 +619,20 @@ const defaultParser = new ArcQueryParser();
618
619
  function getDefaultQueryParser() {
619
620
  return defaultParser;
620
621
  }
622
+ const log = arcLog("query");
623
+ /**
624
+ * Deny-by-default (2.24 flip): `populate=` / `lookup=` with NO allowlist
625
+ * configured is rejected — client-driven joins/population are an easy
626
+ * accidental N+1 / expensive-join surface, and "absent means all" made
627
+ * every resource opt into it silently. A one-shot dev log explains the
628
+ * drop the first time a request hits it.
629
+ */
630
+ let notedDeniedJoins = false;
631
+ function noteDeniedJoins(kind) {
632
+ if (notedDeniedJoins || process.env.NODE_ENV === "production") return;
633
+ notedDeniedJoins = true;
634
+ log.warn(`client requested ${kind} but no allowlist is configured — DENIED (2.24 default flip; it was allow-all). Set schemaOptions.query.allowedPopulate / allowedLookups to enable client-driven joins. (dev-only note, shown once)`);
635
+ }
621
636
  var QueryResolver = class {
622
637
  queryParser;
623
638
  maxLimit;
@@ -709,7 +724,10 @@ var QueryResolver = class {
709
724
  const allowedPopulate = schemaOptions.query?.allowedPopulate;
710
725
  const requested = typeof populate === "string" ? populate.split(",").map((p) => p.trim()) : Array.isArray(populate) ? populate.map(String) : [];
711
726
  if (requested.length === 0) return void 0;
712
- if (!allowedPopulate) return requested;
727
+ if (!allowedPopulate) {
728
+ noteDeniedJoins("populate");
729
+ return;
730
+ }
713
731
  const sanitized = requested.filter((p) => allowedPopulate.includes(p));
714
732
  return sanitized.length > 0 ? sanitized : void 0;
715
733
  }
@@ -717,25 +735,32 @@ var QueryResolver = class {
717
735
  sanitizePopulateOptions(options, schemaOptions) {
718
736
  if (!options || options.length === 0) return void 0;
719
737
  const allowedPopulate = schemaOptions.query?.allowedPopulate;
720
- if (!allowedPopulate) return options;
738
+ if (!allowedPopulate) {
739
+ noteDeniedJoins("populate");
740
+ return;
741
+ }
721
742
  const sanitized = options.filter((opt) => allowedPopulate.includes(opt.path));
722
743
  return sanitized.length > 0 ? sanitized : void 0;
723
744
  }
724
745
  /**
725
- * Sanitize lookup/join options.
726
- * If schemaOptions.query.allowedLookups is set, only those collections are allowed.
727
- * Validates lookup structure to prevent injection.
746
+ * Sanitize lookup/join options. Deny-by-default: `allowedLookups` must be
747
+ * configured for any client-driven lookup to pass; listed collections are
748
+ * then structurally validated to prevent injection.
728
749
  */
729
750
  sanitizeLookups(lookups, schemaOptions) {
730
751
  if (!lookups || lookups.length === 0) return void 0;
731
752
  const allowedLookups = schemaOptions.query?.allowedLookups;
753
+ if (!allowedLookups) {
754
+ noteDeniedJoins("lookups");
755
+ return;
756
+ }
732
757
  const validFieldName = /^[a-zA-Z_][a-zA-Z0-9_.]*$/;
733
758
  const sanitized = lookups.filter((lookup) => {
734
759
  if (!lookup.from || !lookup.localField || !lookup.foreignField) return false;
735
760
  if (!validFieldName.test(lookup.from)) return false;
736
761
  if (!validFieldName.test(lookup.localField)) return false;
737
762
  if (!validFieldName.test(lookup.foreignField)) return false;
738
- if (allowedLookups && !allowedLookups.includes(lookup.from)) return false;
763
+ if (!allowedLookups.includes(lookup.from)) return false;
739
764
  return true;
740
765
  });
741
766
  return sanitized.length > 0 ? sanitized : void 0;
@@ -1083,6 +1108,45 @@ var BaseCrudController = class {
1083
1108
  async runAfterHook(req, op, data, meta) {
1084
1109
  return executeAfterHook(this.hookOpContext(req), op, data, meta);
1085
1110
  }
1111
+ /**
1112
+ * Per-instance single-flight for cache fills — concurrent requests for
1113
+ * the SAME cache key coalesce onto one repository query instead of each
1114
+ * issuing its own (a popular entry expiring under 1,000 concurrent
1115
+ * readers must produce 1 DB query, not 1,000). Keys already encode the
1116
+ * resolved query + user/org scope + resource version, so coalescing can
1117
+ * never cross tenants, scopes, or invalidation generations. Per-process
1118
+ * by design; distributed refresh leases are a store-level concern
1119
+ * (planned with the repo-core CacheEngine façade, which ships
1120
+ * single-flight natively).
1121
+ */
1122
+ inFlightCacheFills = /* @__PURE__ */ new Map();
1123
+ cacheSingleFlight(key, fill) {
1124
+ const existing = this.inFlightCacheFills.get(key);
1125
+ if (existing) return existing;
1126
+ const flight = fill().finally(() => {
1127
+ this.inFlightCacheFills.delete(key);
1128
+ });
1129
+ this.inFlightCacheFills.set(key, flight);
1130
+ return flight;
1131
+ }
1132
+ /**
1133
+ * Register a background SWR refresh for `key` unless one is already in
1134
+ * flight. Registration is SYNCHRONOUS (before the deferred tick) so a
1135
+ * burst of stale readers arriving in the same tick schedules exactly one
1136
+ * revalidation.
1137
+ */
1138
+ scheduleCacheRefresh(key, refresh) {
1139
+ if (this.inFlightCacheFills.has(key)) return;
1140
+ const flight = new Promise((resolve) => {
1141
+ scheduleBackground(() => {
1142
+ refresh().catch(() => {}).finally(() => {
1143
+ this.inFlightCacheFills.delete(key);
1144
+ resolve();
1145
+ });
1146
+ });
1147
+ });
1148
+ this.inFlightCacheFills.set(key, flight);
1149
+ }
1086
1150
  /** Cached `list()` flow with SWR semantics. Returns null when cache is disabled. */
1087
1151
  async withListCache(req, options) {
1088
1152
  const cacheConfig = this.resolveCacheConfig("list");
@@ -1095,13 +1159,17 @@ var BaseCrudController = class {
1095
1159
  const { data, status } = await qc.get(key);
1096
1160
  if (status === "fresh") return this.cacheResponse(data, "HIT");
1097
1161
  if (status === "stale") {
1098
- scheduleBackground(() => {
1099
- this.executeListQuery(options, req).then((fresh) => qc.set(key, fresh, cacheConfig)).catch(() => {});
1162
+ this.scheduleCacheRefresh(key, async () => {
1163
+ const fresh = await this.executeListQuery(options, req);
1164
+ await qc.set(key, fresh, cacheConfig);
1100
1165
  });
1101
1166
  return this.cacheResponse(data, "STALE");
1102
1167
  }
1103
- const result = await this.executeListQuery(options, req);
1104
- await qc.set(key, result, cacheConfig);
1168
+ const result = await this.cacheSingleFlight(key, async () => {
1169
+ const fresh = await this.executeListQuery(options, req);
1170
+ await qc.set(key, fresh, cacheConfig);
1171
+ return fresh;
1172
+ });
1105
1173
  return this.cacheResponse(result, "MISS");
1106
1174
  }
1107
1175
  /** Cached `get()` flow with SWR semantics. Returns null when cache is disabled. */
@@ -1119,16 +1187,18 @@ var BaseCrudController = class {
1119
1187
  const { data, status } = await qc.get(key);
1120
1188
  if (status === "fresh") return this.cacheResponse(data, "HIT");
1121
1189
  if (status === "stale") {
1122
- scheduleBackground(() => {
1123
- this.executeGetQuery(id, options, req).then(({ doc: fresh }) => {
1124
- if (fresh) qc.set(key, fresh, cacheConfig);
1125
- }).catch(() => {});
1190
+ this.scheduleCacheRefresh(key, async () => {
1191
+ const { doc: fresh } = await this.executeGetQuery(id, options, req);
1192
+ if (fresh) await qc.set(key, fresh, cacheConfig);
1126
1193
  });
1127
1194
  return this.cacheResponse(data, "STALE");
1128
1195
  }
1129
- const { doc, reason } = await this.executeGetQuery(id, options, req);
1196
+ const { doc, reason } = await this.cacheSingleFlight(key, async () => {
1197
+ const result = await this.executeGetQuery(id, options, req);
1198
+ if (result.doc) await qc.set(key, result.doc, cacheConfig);
1199
+ return result;
1200
+ });
1130
1201
  if (!doc) this.throwNotFound(reason);
1131
- await qc.set(key, doc, cacheConfig);
1132
1202
  return this.cacheResponse(doc, "MISS");
1133
1203
  }
1134
1204
  async list(req) {
@@ -1614,6 +1684,7 @@ function SoftDeleteMixin(Base) {
1614
1684
  meta: { id }
1615
1685
  });
1616
1686
  } catch (err) {
1687
+ if (isArcError(err)) throw err;
1617
1688
  throw createError(400, "Hook execution failed", {
1618
1689
  code: "BEFORE_RESTORE_HOOK_ERROR",
1619
1690
  message: err.message
@@ -0,0 +1,72 @@
1
+ import { r as CacheStore } from "./interface-BGZCzOGL.mjs";
2
+ //#region src/cache/QueryCache.d.ts
3
+ /** Metadata wrapper stored in CacheStore */
4
+ interface CacheEnvelope<T = unknown> {
5
+ data: T;
6
+ createdAt: number;
7
+ staleAfter: number;
8
+ expiresAt: number;
9
+ tags: string[];
10
+ }
11
+ interface QueryCacheConfig {
12
+ /** Seconds data is "fresh" (no revalidation). Default: 0 */
13
+ staleTime?: number;
14
+ /** Seconds stale data stays cached (SWR window). Default: 60 */
15
+ gcTime?: number;
16
+ /** Tags for group invalidation */
17
+ tags?: string[];
18
+ /**
19
+ * Randomized freshness jitter (0–1, default 0 = off). Each write varies
20
+ * its effective `staleTime` by ±(jitter × staleTime), so entries cached
21
+ * together don't all go stale in the same instant — spreading
22
+ * revalidation load instead of synchronizing a refresh burst.
23
+ * Single-flight coalescing (BaseCrudController) handles same-key
24
+ * stampedes; jitter de-synchronizes ACROSS keys. 0.1 is a sane value.
25
+ */
26
+ jitter?: number;
27
+ }
28
+ type CacheStatus = "fresh" | "stale" | "miss";
29
+ interface CacheResult<T> {
30
+ data: T;
31
+ status: CacheStatus;
32
+ }
33
+ declare class QueryCache {
34
+ private readonly store;
35
+ constructor(store: CacheStore);
36
+ get<T>(key: string): Promise<CacheResult<T>>;
37
+ set<T>(key: string, data: T, config: QueryCacheConfig): Promise<void>;
38
+ invalidate(key: string): Promise<void>;
39
+ /** Get current version for a resource (defaults to 0 if not set) */
40
+ getResourceVersion(resource: string): Promise<number>;
41
+ /** Bump resource version — orphans all cached queries for this resource */
42
+ bumpResourceVersion(resource: string): Promise<void>;
43
+ /** Get current version for a tag */
44
+ getTagVersion(tag: string): Promise<number>;
45
+ /** Bump tag version — orphans all cached queries tagged with this tag */
46
+ bumpTagVersion(tag: string): Promise<void>;
47
+ /**
48
+ * Version-bump semantics:
49
+ *
50
+ * - `store.increment` present (Redis `INCR`) — atomic and strictly
51
+ * monotonic across replicas; concurrent bumps can never collide or
52
+ * go backwards.
53
+ * - fallback — read-modify-write with `max(now, current + 1)`: two
54
+ * bumps in the same millisecond and clock regressions still advance
55
+ * the version. Concurrent bumps from DIFFERENT replicas can lose one
56
+ * write, which is why distributed hosts should use an incrementing
57
+ * store (`runtime: 'distributed'` already requires a shared one).
58
+ *
59
+ * Version keys must outlive every derived cache entry — that invariant
60
+ * is what makes version invalidation sound (a version key expiring back
61
+ * to 0 would make entries written under the pre-reset version
62
+ * addressable again). The 1-year TTL applies with the repo-core
63
+ * TTL-ON-CREATE semantics, so a version key expires one year after its
64
+ * FIRST bump — safe, because a revived collision would need an entry to
65
+ * outlive that reset and entry TTLs are minutes-scale (staleTime +
66
+ * gcTime). The read-modify-write fallback uses plain `set`, which
67
+ * refreshes the TTL per bump — strictly longer-lived, equally safe.
68
+ */
69
+ private bumpVersion;
70
+ }
71
+ //#endregion
72
+ export { QueryCacheConfig as a, QueryCache as i, CacheResult as n, CacheStatus as r, CacheEnvelope as t };
@@ -0,0 +1,24 @@
1
+ //#region src/core/actionPermissions.ts
2
+ /**
3
+ * Return the effective `PermissionCheck` for a single action, or `undefined`
4
+ * when the resource declares no gate at any level.
5
+ *
6
+ * Callers decide what "no gate" means:
7
+ * - HTTP: boot-time throw in `normalizeActionsToRouterConfig`.
8
+ * - MCP: tool-generation throw in `resourceToTools` (mirrors HTTP — the
9
+ * two surfaces fail closed identically so MCP can't expose an
10
+ * unauthenticated mutating tool when the HTTP plugin lifecycle hasn't
11
+ * run).
12
+ * - OpenAPI: docs advertise the endpoint as unauthenticated.
13
+ */
14
+ function resolveActionPermission(input) {
15
+ const { action, resourcePermissions, resourceActionPermissions, globalAuth } = input;
16
+ const explicit = typeof action !== "function" && action.permissions ? action.permissions : void 0;
17
+ if (explicit) return explicit;
18
+ if (resourceActionPermissions) return resourceActionPermissions;
19
+ if (globalAuth) return globalAuth;
20
+ const updateFallback = resourcePermissions?.update;
21
+ if (updateFallback) return updateFallback;
22
+ }
23
+ //#endregion
24
+ export { resolveActionPermission as t };
@@ -1,3 +1,4 @@
1
+ import { t as ArcError } from "./errors-DkO8Nk6-.mjs";
1
2
  //#region src/permissions/fields.ts
2
3
  /**
3
4
  * Field-Level Permissions
@@ -238,6 +239,15 @@ async function evaluateAndApplyPermission(check, context, request, reply, opts)
238
239
  resource: context.resource,
239
240
  action: context.action
240
241
  }, "Permission check threw");
242
+ if (err instanceof ArcError) {
243
+ reply.code(err.status).send({
244
+ code: err.code,
245
+ message: err.message,
246
+ status: err.status,
247
+ ...err.meta ? { meta: err.meta } : {}
248
+ });
249
+ return false;
250
+ }
241
251
  reply.code(403).send({
242
252
  code: "arc.forbidden",
243
253
  message: "Permission denied",
@@ -1,5 +1,5 @@
1
- import "../index-BBsvQt7U.mjs";
2
- import { i as UserBase } from "../types-Csxga7d3.mjs";
1
+ import "../index-DPRH0Cme.mjs";
2
+ import { i as UserBase } from "../types-Dmw-6Qeo.mjs";
3
3
  import { FastifyPluginAsync } from "fastify";
4
4
  import { RepositoryLike } from "@classytic/repo-core/adapter";
5
5
  //#region src/audit/stores/interface.d.ts
@@ -131,6 +131,17 @@ interface AuditPluginOptions {
131
131
  /** Interval between purges in ms. Default 86_400_000 (24h). 0 disables the timer. */
132
132
  purgeIntervalMs?: number;
133
133
  };
134
+ /**
135
+ * Unit-of-Work session provider for the built-in `repository` store. When a
136
+ * request runs inside a `transactionContext.run(session, …)` scope, the audit
137
+ * row is written **with that session**, so it commits/rolls back atomically
138
+ * with the domain write. Defaults to `() => transactionContext.get()`, so
139
+ * atomic audit works out of the box for kits that honor a write session
140
+ * (mongokit). Outside a transaction scope the provider returns `undefined`
141
+ * and the audit write proceeds session-less (unchanged). Pass `false` (or
142
+ * `() => undefined`) to opt out.
143
+ */
144
+ sessionProvider?: (() => unknown) | false;
134
145
  /**
135
146
  * Automatically audit CRUD operations via the hook system (default: true when enabled).
136
147
  * When enabled, create/update/delete operations are auto-logged without manual calls.
@@ -207,7 +218,25 @@ declare const auditPlugin: FastifyPluginAsync<AuditPluginOptions>;
207
218
  declare const _default: FastifyPluginAsync<AuditPluginOptions>;
208
219
  //#endregion
209
220
  //#region src/audit/repository-audit-adapter.d.ts
210
- declare function repositoryAsAuditStore(repository: RepositoryLike): AuditStore;
221
+ /** Options for {@link repositoryAsAuditStore}. */
222
+ interface RepositoryAuditStoreOptions {
223
+ /**
224
+ * Unit-of-Work session provider (AsyncLocalStorage integration), mirroring
225
+ * {@link EventOutbox}'s `sessionProvider`. When set, `log()` calls it to get
226
+ * the active DB session and writes the audit row **with that session**, so
227
+ * the audit entry commits/rolls back **atomically with the domain write**
228
+ * that produced it — no unaudited successful write, and no orphan audit for a
229
+ * rolled-back write. Returns `undefined` outside a transaction scope, in
230
+ * which case the write proceeds session-less (unchanged legacy behaviour).
231
+ *
232
+ * Wire with arc's `transactionContext` (auditPlugin does this by default):
233
+ * ```ts
234
+ * repositoryAsAuditStore(repo, { sessionProvider: () => transactionContext.get() })
235
+ * ```
236
+ */
237
+ readonly sessionProvider?: () => unknown;
238
+ }
239
+ declare function repositoryAsAuditStore(repository: RepositoryLike, options?: RepositoryAuditStoreOptions): AuditStore;
211
240
  //#endregion
212
241
  //#region src/audit/stores/memory.d.ts
213
242
  interface MemoryAuditStoreOptions {
@@ -1,8 +1,10 @@
1
+ import { t as transactionContext } from "../transactionContext-PNdoyLLm.mjs";
1
2
  import fp from "fastify-plugin";
2
3
  import { and, anyOf, eq, gte, lt, lte } from "@classytic/repo-core/filter";
3
4
  //#region src/audit/repository-audit-adapter.ts
4
- function repositoryAsAuditStore(repository) {
5
+ function repositoryAsAuditStore(repository, options = {}) {
5
6
  const idField = repository.idField ?? "_id";
7
+ const { sessionProvider } = options;
6
8
  return {
7
9
  name: "repository",
8
10
  async log(entry) {
@@ -22,7 +24,8 @@ function repositoryAsAuditStore(repository) {
22
24
  metadata: entry.metadata,
23
25
  timestamp: entry.timestamp
24
26
  };
25
- await repository.create(doc);
27
+ const session = sessionProvider?.();
28
+ await repository.create(doc, session != null ? { session } : void 0);
26
29
  },
27
30
  async purgeOlderThan(cutoff) {
28
31
  if (!repository.deleteMany) return 0;
@@ -173,7 +176,7 @@ var MemoryAuditStore = class {
173
176
  //#endregion
174
177
  //#region src/audit/auditPlugin.ts
175
178
  const auditPlugin = async (fastify, opts = {}) => {
176
- const { enabled = false, repository, customStores = [] } = opts;
179
+ const { enabled = false, repository, customStores = [], sessionProvider = () => transactionContext.get() } = opts;
177
180
  if (!enabled) {
178
181
  fastify.decorate("audit", createNoopLogger());
179
182
  fastify.decorateRequest("auditContext", void 0);
@@ -181,7 +184,7 @@ const auditPlugin = async (fastify, opts = {}) => {
181
184
  return;
182
185
  }
183
186
  const stores = [];
184
- if (repository) stores.push(repositoryAsAuditStore(repository));
187
+ if (repository) stores.push(repositoryAsAuditStore(repository, { sessionProvider: sessionProvider === false ? void 0 : sessionProvider }));
185
188
  stores.push(...customStores);
186
189
  if (stores.length === 0) stores.push(new MemoryAuditStore());
187
190
  async function logToStores(entry) {
@@ -1,5 +1,5 @@
1
- import { Ht as AuthHelpers, Ut as AuthPluginOptions } from "../index-BBsvQt7U.mjs";
2
- import { t as PermissionCheck } from "../types-Csxga7d3.mjs";
1
+ import { Ht as AuthHelpers, Ut as AuthPluginOptions } from "../index-DPRH0Cme.mjs";
2
+ import { t as PermissionCheck } from "../types-Dmw-6Qeo.mjs";
3
3
  import { t as ExternalOpenApiPaths } from "../externalPaths-BE6wFpYv.mjs";
4
4
  import { a as SessionManagerOptions, c as createSessionManager, i as SessionData, n as MemorySessionStoreOptions, o as SessionManagerResult, r as SessionCookieOptions, s as SessionStore, t as MemorySessionStore } from "../sessionManager-BHOM3N_0.mjs";
5
5
  import { FastifyPluginAsync, FastifyReply as FastifyReply$1, FastifyRequest } from "fastify";