@classytic/arc 2.15.4 → 2.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/README.md +1 -0
  2. package/bin/arc.js +12 -0
  3. package/dist/{BaseController-dx3m2J8V.mjs → BaseController-Vk8I7C0x.mjs} +61 -19
  4. package/dist/{HookSystem-Iiebom92.mjs → HookSystem-Cmf7-Etp.mjs} +8 -4
  5. package/dist/{QueryCache-D41bfdBB.d.mts → QueryCache-8YmAQ-zW.d.mts} +1 -1
  6. package/dist/{ResourceRegistry-CTERg_2x.mjs → ResourceRegistry-CzSzVK1m.mjs} +55 -9
  7. package/dist/audit/index.d.mts +1 -1
  8. package/dist/audit/index.mjs +4 -2
  9. package/dist/auth/index.d.mts +4 -4
  10. package/dist/auth/index.mjs +4 -4
  11. package/dist/auth/redis-session.d.mts +1 -1
  12. package/dist/{betterAuthOpenApi--M_i87dQ.mjs → betterAuthOpenApi-ClWxaceA.mjs} +10 -6
  13. package/dist/buildHandler-C_SkBNF3.mjs +302 -0
  14. package/dist/cache/index.d.mts +3 -3
  15. package/dist/cache/index.mjs +3 -3
  16. package/dist/{caching-SM8gghN6.mjs → caching-8MfrrUYU.mjs} +1 -1
  17. package/dist/cli/commands/describe.d.mts +35 -1
  18. package/dist/cli/commands/describe.mjs +52 -12
  19. package/dist/cli/commands/docs.d.mts +1 -4
  20. package/dist/cli/commands/docs.mjs +4 -16
  21. package/dist/cli/commands/generate.d.mts +2 -20
  22. package/dist/cli/commands/generate.mjs +1 -546
  23. package/dist/cli/commands/init.d.mts +2 -40
  24. package/dist/cli/commands/init.mjs +1 -3045
  25. package/dist/cli/commands/introspect.mjs +53 -64
  26. package/dist/cli/index.d.mts +2 -2
  27. package/dist/cli/index.mjs +2 -2
  28. package/dist/{constants-Cxde4rpC.mjs → constants-TrJVIJl0.mjs} +7 -0
  29. package/dist/core/index.d.mts +3 -3
  30. package/dist/core/index.mjs +5 -5
  31. package/dist/{core-CvmOqEms.mjs → core-wKeGXn38.mjs} +222 -44
  32. package/dist/createActionRouter-eiOFtnC3.mjs +288 -0
  33. package/dist/{createAggregationRouter-B0bPDf5b.mjs → createAggregationRouter-C4unhBnV.mjs} +3 -2
  34. package/dist/{createApp-PFegs47-.mjs → createApp-DdPBtmzp.mjs} +16 -14
  35. package/dist/{defineEvent-D5h7EvAx.mjs → defineEvent-NPKUJRt8.mjs} +1 -1
  36. package/dist/docs/index.d.mts +2 -2
  37. package/dist/docs/index.mjs +1 -1
  38. package/dist/{errorHandler-Bk-AGhkU.mjs → errorHandler-DNPzqaTT.mjs} +17 -14
  39. package/dist/errors-4ZloYFvm.d.mts +91 -0
  40. package/dist/{eventPlugin-CaKTYkYM.mjs → eventPlugin-CnJCUyM0.mjs} +1 -1
  41. package/dist/{eventPlugin-qXpqTebY.d.mts → eventPlugin-DjEx_J3j.d.mts} +1 -1
  42. package/dist/events/index.d.mts +3 -3
  43. package/dist/events/index.mjs +5 -5
  44. package/dist/events/transports/redis-stream-entry.d.mts +1 -1
  45. package/dist/events/transports/redis.d.mts +1 -1
  46. package/dist/factory/index.d.mts +2 -2
  47. package/dist/factory/index.mjs +4 -3
  48. package/dist/fastify-augmentation-CJm0vh-l.d.mts +42 -0
  49. package/dist/{fields-COhcH3fk.d.mts → fields-DqZLp5d6.d.mts} +1 -1
  50. package/dist/generate-CcM24hoq.mjs +654 -0
  51. package/dist/generate-cRnsafMS.d.mts +38 -0
  52. package/dist/hooks/index.d.mts +1 -1
  53. package/dist/hooks/index.mjs +1 -1
  54. package/dist/idempotency/index.d.mts +2 -2
  55. package/dist/idempotency/index.mjs +1 -1
  56. package/dist/idempotency/redis.d.mts +1 -1
  57. package/dist/{index-BTqLEvhu.d.mts → index-3L-adFJO.d.mts} +12 -12
  58. package/dist/{index-BstGxcc3.d.mts → index-CC_pgZjY.d.mts} +88 -17
  59. package/dist/{index-BswOSJCE.d.mts → index-DBDgy6Hw.d.mts} +430 -19
  60. package/dist/index.d.mts +6 -6
  61. package/dist/index.mjs +7 -8
  62. package/dist/init-D-vrTZ1G.d.mts +71 -0
  63. package/dist/init-DYzfniuX.mjs +3098 -0
  64. package/dist/integrations/event-gateway.d.mts +2 -2
  65. package/dist/integrations/event-gateway.mjs +1 -1
  66. package/dist/integrations/index.d.mts +2 -2
  67. package/dist/integrations/jobs.mjs +3 -3
  68. package/dist/integrations/mcp/index.d.mts +239 -7
  69. package/dist/integrations/mcp/index.mjs +2 -528
  70. package/dist/integrations/mcp/testing.d.mts +2 -2
  71. package/dist/integrations/mcp/testing.mjs +6 -10
  72. package/dist/integrations/streamline.mjs +26 -1
  73. package/dist/integrations/websocket-redis.d.mts +1 -1
  74. package/dist/integrations/websocket.d.mts +1 -1
  75. package/dist/integrations/websocket.mjs +1 -0
  76. package/dist/loadResourcesFromEntry-DCjYMgfC.mjs +51 -0
  77. package/dist/{resourceToTools-tFYUNmM0.mjs → mcpPlugin-Dbgz9_Rj.mjs} +1021 -318
  78. package/dist/{memory-UBydS5ku.mjs → memory-QOLe11D5.mjs} +2 -0
  79. package/dist/middleware/index.d.mts +1 -1
  80. package/dist/middleware/index.mjs +1 -1
  81. package/dist/{openapi-BHXhoX8O.mjs → openapi-BRnd3pT7.mjs} +82 -44
  82. package/dist/permissions/index.d.mts +2 -2
  83. package/dist/permissions/index.mjs +1 -1
  84. package/dist/{permissions-ohQyv50e.mjs → permissions-CTxMrreC.mjs} +2 -2
  85. package/dist/{pipe-Zr0KXjQe.mjs → pipe-DiCyvyPN.mjs} +1 -0
  86. package/dist/pipeline/index.d.mts +1 -1
  87. package/dist/pipeline/index.mjs +1 -1
  88. package/dist/plugins/index.d.mts +5 -59
  89. package/dist/plugins/index.mjs +10 -10
  90. package/dist/plugins/response-cache.mjs +5 -5
  91. package/dist/plugins/tracing-entry.d.mts +1 -1
  92. package/dist/plugins/tracing-entry.mjs +1 -1
  93. package/dist/{pluralize-DQgqgifU.mjs → pluralize-B9M8xvy-.mjs} +2 -1
  94. package/dist/preloadResources-BOXzPhKU.mjs +45 -0
  95. package/dist/presets/filesUpload.d.mts +4 -4
  96. package/dist/presets/filesUpload.mjs +2 -2
  97. package/dist/presets/index.d.mts +1 -1
  98. package/dist/presets/index.mjs +1 -1
  99. package/dist/presets/multiTenant.d.mts +1 -1
  100. package/dist/presets/multiTenant.mjs +4 -3
  101. package/dist/presets/search.d.mts +2 -2
  102. package/dist/presets/search.mjs +1 -1
  103. package/dist/{presets-BbkjdPeH.mjs → presets-C9BE6WaZ.mjs} +2 -2
  104. package/dist/{queryCachePlugin-m1XsgAIJ.mjs → queryCachePlugin-B4XMSSe7.mjs} +2 -2
  105. package/dist/{queryCachePlugin-CqMdLI2-.d.mts → queryCachePlugin-Do4P_WBC.d.mts} +2 -2
  106. package/dist/{redis-DiMkdHEl.d.mts → redis-cpqVb85w.d.mts} +1 -1
  107. package/dist/{redis-stream-D6HzR1Z_.d.mts → redis-stream-BuiAS3aE.d.mts} +1 -1
  108. package/dist/registry/index.d.mts +341 -2
  109. package/dist/registry/index.mjs +3 -3
  110. package/dist/registry-BrlHkrRS.mjs +582 -0
  111. package/dist/{routerShared-DrOa-26E.mjs → routerShared-ClV4cCaa.mjs} +40 -16
  112. package/dist/scope/index.d.mts +3 -3
  113. package/dist/scope/index.mjs +3 -3
  114. package/dist/{sse-Bz-5ZeTt.mjs → sse-jh5OqFjR.mjs} +1 -1
  115. package/dist/testing/index.d.mts +2 -25
  116. package/dist/testing/index.mjs +17 -51
  117. package/dist/testing/storageContract.d.mts +1 -1
  118. package/dist/types/index.d.mts +5 -5
  119. package/dist/types/storage.d.mts +1 -1
  120. package/dist/{types-BQsjgQzS.d.mts → types-BD0BCdf6.d.mts} +106 -12
  121. package/dist/{types-C_s5moIu.mjs → types-Bi0r0vjG.mjs} +53 -1
  122. package/dist/{types-CTYvcwHe.d.mts → types-CfapW2t8.d.mts} +42 -1
  123. package/dist/{types-DrBaUwyV.d.mts → types-Dw--SyTq.d.mts} +57 -11
  124. package/dist/utils/index.d.mts +1287 -2
  125. package/dist/utils/index.mjs +1 -1
  126. package/dist/{utils-_h9B3c57.mjs → utils-DPXZnZUx.mjs} +158 -41
  127. package/dist/{buildHandler-CcFOpJLh.mjs → validate-DFIP2VrM.mjs} +73 -302
  128. package/dist/{versioning-hmkPcDlX.d.mts → versioning-5H4930tj.d.mts} +1 -1
  129. package/package.json +22 -29
  130. package/skills/arc/SKILL.md +300 -706
  131. package/skills/arc/references/auth.md +19 -7
  132. package/skills/arc-code-review/SKILL.md +1 -1
  133. package/skills/arc-code-review/references/arc-cheatsheet.md +100 -322
  134. package/dist/createActionRouter-S3MLVYot.mjs +0 -220
  135. package/dist/index-bRjYu21O.d.mts +0 -1320
  136. package/dist/org/index.d.mts +0 -66
  137. package/dist/org/index.mjs +0 -486
  138. package/dist/org/types.d.mts +0 -82
  139. package/dist/org/types.mjs +0 -1
  140. package/dist/registry-I-ogLgL9.mjs +0 -46
  141. /package/dist/{EventTransport-CT_52aWU.d.mts → EventTransport-B1GFiYUy.d.mts} +0 -0
  142. /package/dist/{EventTransport-DLWoUMHy.mjs → EventTransport-ClEDfiMS.mjs} +0 -0
  143. /package/dist/{actionPermissions-CyUkQu6O.mjs → actionPermissions-Bjmvn7Eb.mjs} +0 -0
  144. /package/dist/{elevation-DgoeTyfX.mjs → elevation-Dci0AYLT.mjs} +0 -0
  145. /package/dist/{elevation-BXOWoGCF.d.mts → elevation-DvwtjDwu.d.mts} +0 -0
  146. /package/dist/{errorHandler-DFr45ZG4.d.mts → errorHandler-DuqTqeqn.d.mts} +0 -0
  147. /package/dist/{externalPaths-BD5nw6St.d.mts → externalPaths-ClykOQqa.d.mts} +0 -0
  148. /package/dist/{interface-beEtJyWM.d.mts → interface-BVkZsvEe.d.mts} +0 -0
  149. /package/dist/{interface-DfLGcus7.d.mts → interface-DoE6UoC2.d.mts} +0 -0
  150. /package/dist/{keys-CGcCbNyu.mjs → keys-DopsCuyQ.mjs} +0 -0
  151. /package/dist/{loadResources-DBMQg_Aj.mjs → loadResources-fol6JuvW.mjs} +0 -0
  152. /package/dist/{metrics-Qnvwc-LQ.mjs → metrics-B-PU4-Yu.mjs} +0 -0
  153. /package/dist/{replyHelpers-CK-FNO8E.mjs → replyHelpers-boS5Rx26.mjs} +0 -0
  154. /package/dist/{schemaIR-lYhC2gE5.mjs → schemaIR-DAOvBWgT.mjs} +0 -0
  155. /package/dist/{sessionManager-C4Le_UB3.d.mts → sessionManager-DlmaHUAl.d.mts} +0 -0
  156. /package/dist/{storage-Dfzt4VTl.d.mts → storage-HcAgP5Ni.d.mts} +0 -0
  157. /package/dist/{store-helpers-BkIN9-vu.mjs → store-helpers-5JgaxieH.mjs} +0 -0
  158. /package/dist/{tracing-QJVprktp.d.mts → tracing-bRqqUxFO.d.mts} +0 -0
  159. /package/dist/{versioning-BUrT5aP4.mjs → versioning-CuIwUQZ2.mjs} +0 -0
  160. /package/dist/{websocket-ChC2rqe1.d.mts → websocket-BJ0w-3Dt.d.mts} +0 -0
@@ -0,0 +1,582 @@
1
+ import { t as CRUD_OPERATIONS } from "./constants-TrJVIJl0.mjs";
2
+ import fp from "fastify-plugin";
3
+ //#region src/registry/assertNoTenantData.ts
4
+ /**
5
+ * Walk every cascading resource, run a tenant-scoped count, compare
6
+ * against the strategy's expected outcome.
7
+ *
8
+ * Designed for use inside compliance tests:
9
+ *
10
+ * ```ts
11
+ * import { assertNoTenantData } from '@classytic/arc/registry';
12
+ *
13
+ * it('after org delete, no tenant data leaks', async () => {
14
+ * await cascadeDeleteForOrganization(arc.registry, { organizationId: 'test-org' });
15
+ * const report = await assertNoTenantData(arc.registry, { organizationId: 'test-org' });
16
+ * expect(report.ok).toBe(true);
17
+ * expect(report.leaks).toHaveLength(0);
18
+ * });
19
+ * ```
20
+ */
21
+ async function assertNoTenantData(registry, options) {
22
+ const { organizationId, only, skipAnonymize = true } = options;
23
+ if (!organizationId) throw new Error("assertNoTenantData: `organizationId` is required");
24
+ const onlySet = only ? new Set(only) : void 0;
25
+ const leaks = [];
26
+ const skipped = [];
27
+ let checked = 0;
28
+ for (const r of registry.getAll()) {
29
+ const resolved = r.resolvedTenantPurge;
30
+ if (!resolved || resolved.source === "disabled") continue;
31
+ if (onlySet && !onlySet.has(r.name)) continue;
32
+ const tenantField = typeof r.tenantField === "string" && r.tenantField || "organizationId";
33
+ const strategy = resolved.strategy;
34
+ if (strategy.type === "skip") {
35
+ skipped.push({
36
+ resource: r.name,
37
+ reason: strategy.reason
38
+ });
39
+ continue;
40
+ }
41
+ if (strategy.type === "anonymize" && skipAnonymize) {
42
+ skipped.push({
43
+ resource: r.name,
44
+ reason: "anonymize (rows legitimately retained)"
45
+ });
46
+ continue;
47
+ }
48
+ const repo = registry.getAdapter(r.name)?.repository;
49
+ if (!repo?.count) {
50
+ skipped.push({
51
+ resource: r.name,
52
+ reason: "adapter repository has no `count` method — cannot verify"
53
+ });
54
+ continue;
55
+ }
56
+ const actual = await repo.count({ [tenantField]: organizationId });
57
+ checked++;
58
+ const expected = 0;
59
+ if (actual !== expected) leaks.push({
60
+ resource: r.name,
61
+ tenantField,
62
+ strategy,
63
+ expected,
64
+ actual
65
+ });
66
+ }
67
+ return {
68
+ ok: leaks.length === 0,
69
+ organizationId,
70
+ checked,
71
+ skipped,
72
+ leaks
73
+ };
74
+ }
75
+ //#endregion
76
+ //#region src/registry/purgeResource.ts
77
+ /**
78
+ * Run the resolved strategy against one resource's repository.
79
+ *
80
+ * - `skip` strategy: returns `path: 'skipped'` without touching the repo.
81
+ * - `purgeByField` present: routes through it (chunked, progress, abort).
82
+ * - `purgeByField` absent + hard strategy: legacy `deleteMany` fallback.
83
+ * - `purgeByField` absent + non-hard strategy: returns `path: 'unsupported'`
84
+ * with a clear error — soft/anonymize require the new primitive.
85
+ */
86
+ async function purgeResource(resourceName, tenantField, organizationId, resolved, repo, options = {}) {
87
+ const { strategy } = resolved;
88
+ if (strategy.type === "skip") return {
89
+ resource: resourceName,
90
+ tenantField,
91
+ strategy,
92
+ processed: 0,
93
+ ok: true,
94
+ path: "skipped",
95
+ skipReason: strategy.reason
96
+ };
97
+ if (typeof repo.purgeByField === "function") {
98
+ const result = await repo.purgeByField(tenantField, organizationId, strategy, {
99
+ batchSize: options.batchSize ?? resolved.batchSize,
100
+ onProgress: options.onProgress,
101
+ signal: options.signal
102
+ });
103
+ return {
104
+ resource: resourceName,
105
+ tenantField,
106
+ strategy,
107
+ processed: result.processed,
108
+ ok: result.ok,
109
+ path: "purgeByField",
110
+ durationMs: result.durationMs,
111
+ ...result.error ? { error: result.error } : {}
112
+ };
113
+ }
114
+ if (strategy.type !== "hard") return {
115
+ resource: resourceName,
116
+ tenantField,
117
+ strategy,
118
+ processed: 0,
119
+ ok: false,
120
+ path: "unsupported",
121
+ error: {
122
+ code: "arc.purge.unsupported_strategy",
123
+ message: `Resource '${resourceName}' uses strategy '${strategy.type}' but its adapter repository does not implement \`purgeByField\`. Upgrade the adapter (@classytic/mongokit ≥ 3.13.4 / @classytic/sqlitekit ≥ 0.3.4) or change the strategy to 'hard'.`
124
+ }
125
+ };
126
+ const op = repo.deleteMany ?? repo.deleteByFilter ?? repo.removeMany;
127
+ if (!op) return {
128
+ resource: resourceName,
129
+ tenantField,
130
+ strategy,
131
+ processed: 0,
132
+ ok: false,
133
+ path: "unsupported",
134
+ error: {
135
+ code: "arc.purge.no_bulk_op",
136
+ message: `Resource '${resourceName}' adapter exposes neither \`purgeByField\` nor \`deleteMany\` / \`deleteByFilter\` / \`removeMany\` — bulk cleanup is not supported.`
137
+ }
138
+ };
139
+ try {
140
+ const start = Date.now();
141
+ const result = await op.call(repo, { [tenantField]: organizationId });
142
+ return {
143
+ resource: resourceName,
144
+ tenantField,
145
+ strategy,
146
+ processed: typeof result === "number" ? result : typeof result?.deletedCount === "number" ? result.deletedCount : typeof result?.count === "number" ? result.count : -1,
147
+ ok: true,
148
+ path: "legacy-deleteMany",
149
+ durationMs: Date.now() - start
150
+ };
151
+ } catch (err) {
152
+ return {
153
+ resource: resourceName,
154
+ tenantField,
155
+ strategy,
156
+ processed: 0,
157
+ ok: false,
158
+ path: "legacy-deleteMany",
159
+ error: {
160
+ code: err?.code,
161
+ message: err instanceof Error ? err.message : String(err)
162
+ }
163
+ };
164
+ }
165
+ }
166
+ //#endregion
167
+ //#region src/registry/cascadeOrgDelete.ts
168
+ /**
169
+ * Names of resources flagged for cascade — used by audit scripts.
170
+ * A resource is cascading when its resolved strategy isn't `disabled`
171
+ * (i.e. the host declared `onTenantDelete`).
172
+ */
173
+ function getCascadingResources(registry) {
174
+ return registry.getAll().filter((r) => isResourceCascading(r)).map((r) => r.name);
175
+ }
176
+ /**
177
+ * Rich introspection — returns the resolved strategy + source per
178
+ * cascading resource. Use for audit dashboards that answer "what
179
+ * happens to this resource on org-delete?" without grepping the
180
+ * source.
181
+ *
182
+ * **Returns the full `TenantPurgeStrategy` discriminated union**, not
183
+ * just its `type` tag. The `skip` variant carries a required `reason`
184
+ * field — auditors need it to sign off on every "we deliberately don't
185
+ * purge this" entry. Anonymize carries the field map. Custom carries
186
+ * the handler reference. Narrow on `.strategy.type` for typed access.
187
+ */
188
+ function getCascadingResourcesWithMetadata(registry) {
189
+ return registry.getAll().filter((r) => isResourceCascading(r)).map((r) => {
190
+ const resolved = r.resolvedTenantPurge;
191
+ const tenantField = typeof r.tenantField === "string" && r.tenantField || "organizationId";
192
+ const r0 = resolved ?? {
193
+ strategy: { type: "hard" },
194
+ priority: 100,
195
+ source: "declared"
196
+ };
197
+ return {
198
+ name: r.name,
199
+ tenantField,
200
+ strategy: r0.strategy,
201
+ source: r0.source,
202
+ priority: r0.priority
203
+ };
204
+ });
205
+ }
206
+ /** A resource cascades iff its resolved strategy isn't `disabled`. */
207
+ function isResourceCascading(r) {
208
+ return r.resolvedTenantPurge ? r.resolvedTenantPurge.source !== "disabled" : false;
209
+ }
210
+ /**
211
+ * Cascade-cleanup every tenant-scoped resource for the given organization.
212
+ * Walks the registry in **ascending priority order** (`onTenantDelete.priority`
213
+ * — leaf data first, references last), runs each resource's resolved
214
+ * strategy via `purgeByField` when available (chunked, progress, abort),
215
+ * and returns a structured report.
216
+ *
217
+ * **Strategy resolution** lives in `resolveTenantPurge.ts` — this runner
218
+ * just reads `resource.resolvedTenantPurge` (computed once at boot).
219
+ *
220
+ * **Per-resource execution** lives in `purgeResource.ts` — preferred
221
+ * path is the kit's `purgeByField` (chunked + plugin-composed); falls
222
+ * back to legacy `deleteMany` only for `hard` strategy on adapters that
223
+ * haven't been upgraded.
224
+ *
225
+ * **Failure semantics**: continues on per-resource error, returns the
226
+ * full report. Hosts decide whether a partial cascade is a hard
227
+ * failure (re-throw) or degraded mode (log + alert).
228
+ *
229
+ * @param registry The arc resource registry (`fastify.arc.registry`).
230
+ * @param options Org id + filters + logger + progress + signal.
231
+ */
232
+ async function cascadeDeleteForOrganization(registry, options) {
233
+ const { organizationId, skip, only, logger, onProgress, signal, batchSize, concurrency = 1, checkpoint } = options;
234
+ if (!organizationId) throw new Error("cascadeDeleteForOrganization: `organizationId` is required");
235
+ if (!Number.isInteger(concurrency) || concurrency < 1) throw new Error("cascadeDeleteForOrganization: `concurrency` must be a positive integer");
236
+ const skipSet = skip ? new Set(skip) : void 0;
237
+ const onlySet = only ? new Set(only) : void 0;
238
+ const start = Date.now();
239
+ const resumeState = await checkpoint?.read();
240
+ const completedSet = resumeState ? new Set(resumeState.completedResources) : void 0;
241
+ const flagged = registry.getAll().filter((r) => {
242
+ if (!r.resolvedTenantPurge || r.resolvedTenantPurge.source === "disabled") return false;
243
+ if (skipSet?.has(r.name)) return false;
244
+ if (onlySet && !onlySet.has(r.name)) return false;
245
+ if (completedSet?.has(r.name)) return false;
246
+ return true;
247
+ }).sort((a, b) => {
248
+ return (a.resolvedTenantPurge?.priority ?? 100) - (b.resolvedTenantPurge?.priority ?? 100);
249
+ });
250
+ const results = [];
251
+ const completed = resumeState ? [...resumeState.completedResources] : [];
252
+ const groups = groupByPriority(flagged);
253
+ for (const group of groups) {
254
+ if (signal?.aborted) break;
255
+ const groupResults = await runWithConcurrency(group, concurrency, async (r) => {
256
+ if (signal?.aborted) return;
257
+ return runOneResource(r, registry, organizationId, {
258
+ onProgress,
259
+ signal,
260
+ batchSize,
261
+ logger
262
+ });
263
+ });
264
+ for (const report of groupResults) {
265
+ if (!report) continue;
266
+ results.push(report);
267
+ if (!report.error) {
268
+ completed.push(report.resource);
269
+ if (checkpoint) await checkpoint.write({ completedResources: [...completed] });
270
+ }
271
+ }
272
+ }
273
+ const successes = results.filter((row) => !row.error);
274
+ return {
275
+ organizationId,
276
+ resources: results,
277
+ successes,
278
+ failures: results.filter((row) => row.error),
279
+ totalDeleted: successes.reduce((sum, r) => sum + (r.deletedCount > 0 ? r.deletedCount : 0), 0),
280
+ durationMs: Date.now() - start
281
+ };
282
+ }
283
+ /** Process a single registry entry under its resolved strategy. */
284
+ async function runOneResource(r, registry, organizationId, ctx) {
285
+ const tenantField = typeof r.tenantField === "string" && r.tenantField || "organizationId";
286
+ const repo = registry.getAdapter(r.name)?.repository;
287
+ if (!repo) {
288
+ const report = {
289
+ resource: r.name,
290
+ tenantField,
291
+ deletedCount: 0,
292
+ path: "unsupported",
293
+ error: {
294
+ code: "arc.no_adapter",
295
+ message: `Resource '${r.name}' has no adapter repository — cascade skipped`
296
+ }
297
+ };
298
+ ctx.logger?.warn?.(report, "[Arc/Cascade] resource skipped (no adapter)");
299
+ return report;
300
+ }
301
+ const resourceProgress = ctx.onProgress ? (event) => ctx.onProgress?.({
302
+ ...event,
303
+ resource: r.name
304
+ }) : void 0;
305
+ const outcome = await purgeResource(r.name, tenantField, organizationId, r.resolvedTenantPurge ?? {
306
+ strategy: { type: "hard" },
307
+ priority: 100,
308
+ source: "declared"
309
+ }, repo, {
310
+ onProgress: resourceProgress,
311
+ signal: ctx.signal,
312
+ batchSize: ctx.batchSize
313
+ });
314
+ const report = {
315
+ resource: outcome.resource,
316
+ tenantField: outcome.tenantField,
317
+ deletedCount: outcome.processed,
318
+ strategy: outcome.strategy,
319
+ strategySource: r.resolvedTenantPurge?.source,
320
+ path: outcome.path,
321
+ ...outcome.skipReason ? { skipReason: outcome.skipReason } : {},
322
+ ...outcome.error ? { error: outcome.error } : {}
323
+ };
324
+ if (outcome.ok) ctx.logger?.info?.(report, "[Arc/Cascade] resource processed");
325
+ else ctx.logger?.error?.({ report }, "[Arc/Cascade] resource cascade failed");
326
+ return report;
327
+ }
328
+ /**
329
+ * Group a priority-sorted list of resources into priority barriers.
330
+ * Each returned sub-array contains resources of identical priority;
331
+ * the outer array preserves ascending order. The cascade runner uses
332
+ * this so all priority-N resources finish before any priority-(N+M)
333
+ * starts — leaf-before-references invariant survives concurrency.
334
+ */
335
+ function groupByPriority(sorted) {
336
+ if (sorted.length === 0) return [];
337
+ const first = sorted[0];
338
+ if (!first) return [];
339
+ const groups = [];
340
+ let current = [first];
341
+ let currentPriority = first.resolvedTenantPurge?.priority ?? 100;
342
+ for (let i = 1; i < sorted.length; i++) {
343
+ const item = sorted[i];
344
+ if (!item) continue;
345
+ const p = item.resolvedTenantPurge?.priority ?? 100;
346
+ if (p === currentPriority) current.push(item);
347
+ else {
348
+ groups.push(current);
349
+ current = [item];
350
+ currentPriority = p;
351
+ }
352
+ }
353
+ groups.push(current);
354
+ return groups;
355
+ }
356
+ /**
357
+ * Run `fn` over `items` with bounded concurrency. Order of results
358
+ * matches input order. Simpler shape than a worker-pool because the
359
+ * group sizes are small (typically <=12 resources per priority band).
360
+ */
361
+ async function runWithConcurrency(items, concurrency, fn) {
362
+ if (concurrency === 1 || items.length <= 1) {
363
+ const results = [];
364
+ for (const item of items) results.push(await fn(item));
365
+ return results;
366
+ }
367
+ const results = new Array(items.length);
368
+ let cursor = 0;
369
+ const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
370
+ while (true) {
371
+ const i = cursor++;
372
+ if (i >= items.length) break;
373
+ const item = items[i];
374
+ if (item === void 0) continue;
375
+ results[i] = await fn(item);
376
+ }
377
+ });
378
+ await Promise.all(workers);
379
+ return results;
380
+ }
381
+ //#endregion
382
+ //#region src/registry/introspectionPlugin.ts
383
+ const introspectionPlugin = async (fastify, opts = {}) => {
384
+ const { prefix = "/_resources", authRoles = ["superadmin"], enabled = true } = opts;
385
+ if (!enabled) {
386
+ fastify.log?.debug?.("Introspection plugin disabled");
387
+ return;
388
+ }
389
+ const typedFastify = fastify;
390
+ const authMiddleware = authRoles.length > 0 && typedFastify.authenticate ? [typedFastify.authenticate, typedFastify.authorize?.(...authRoles)].filter(Boolean) : [];
391
+ const getRegistry = () => fastify.arc?.registry;
392
+ await fastify.register(async (instance) => {
393
+ instance.get("/", { preHandler: authMiddleware }, async (_req, _reply) => {
394
+ return getRegistry()?.getIntrospection() ?? {
395
+ resources: [],
396
+ stats: {},
397
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString()
398
+ };
399
+ });
400
+ instance.get("/stats", { preHandler: authMiddleware }, async (_req, _reply) => {
401
+ return getRegistry()?.getStats() ?? {
402
+ totalResources: 0,
403
+ byModule: {},
404
+ presetUsage: {},
405
+ totalRoutes: 0,
406
+ totalEvents: 0
407
+ };
408
+ });
409
+ instance.get("/:name", {
410
+ schema: { params: {
411
+ type: "object",
412
+ properties: { name: { type: "string" } },
413
+ required: ["name"]
414
+ } },
415
+ preHandler: authMiddleware
416
+ }, async (req, reply) => {
417
+ const resource = getRegistry()?.get(req.params.name);
418
+ if (!resource) return reply.code(404).send({ error: `Resource '${req.params.name}' not found` });
419
+ return resource;
420
+ });
421
+ }, { prefix });
422
+ fastify.log?.debug?.(`Introspection API at ${prefix}`);
423
+ };
424
+ var introspectionPlugin_default = fp(introspectionPlugin, { name: "arc-introspection" });
425
+ //#endregion
426
+ //#region src/registry/manifest.ts
427
+ /**
428
+ * Resource manifest for frontend code generators (v2.15.5).
429
+ *
430
+ * Every host that hand-rolls a `createCrudApi('foo')` helper on the frontend
431
+ * ends up rewriting the same shim for declarative actions: `postAction(id,
432
+ * name, body)` — and forgets to add a method for every NEW action declared
433
+ * on the resource. The OpenAI-team report flagged this in fajr's
434
+ * `invoices.js`, and now matches needs the same.
435
+ *
436
+ * Arc owns the resource definition, so arc can ship the metadata FE codegen
437
+ * needs in one canonical shape:
438
+ *
439
+ * {
440
+ * name: 'invoice',
441
+ * prefix: '/invoices',
442
+ * idField: '_id',
443
+ * crudOps: ['list', 'get', 'create', 'update', 'delete'],
444
+ * actions: [
445
+ * { name: 'recordPayment', mount: '/:id/action', requiresId: true, description: '...' },
446
+ * { name: 'propose', mount: '/action', requiresId: false, description: '...' },
447
+ * ],
448
+ * }
449
+ *
450
+ * Hosts feed this into their FE generator (or a runtime `createActionsApi`
451
+ * shim) and every action is wired automatically. New actions land on the FE
452
+ * the same moment they're declared on the resource — no parallel list to
453
+ * keep in sync.
454
+ *
455
+ * The helper is BE-side only (lives under `@classytic/arc/registry`) — arc
456
+ * stays a backend framework and doesn't ship FE runtime code. The host
457
+ * decides whether the manifest powers a fetch-based helper, a TanStack
458
+ * Query factory, an RPC client, or static `.d.ts` generation.
459
+ *
460
+ * @example BE → emit JSON at build time / on a route
461
+ * ```ts
462
+ * // Build-time codegen
463
+ * import { buildResourceManifest } from '@classytic/arc/registry';
464
+ * import { invoiceResource } from './invoice.resource.js';
465
+ * writeFileSync('./fe-gen/invoice.manifest.json',
466
+ * JSON.stringify(buildResourceManifest(invoiceResource)));
467
+ *
468
+ * // Runtime introspection endpoint
469
+ * fastify.get('/manifest/:name', async (req) => {
470
+ * const r = arc.registry.get(req.params.name);
471
+ * return r ? buildResourceManifestFromRegistry(r) : reply.code(404);
472
+ * });
473
+ * ```
474
+ *
475
+ * @example FE → generate fetch methods from the manifest
476
+ * ```ts
477
+ * import manifest from './fe-gen/invoice.manifest.json';
478
+ * import { createActionsApi } from './my-fe-shim.js'; // host-owned
479
+ *
480
+ * export const invoicesApi = {
481
+ * ...createCrudApi(manifest.prefix),
482
+ * ...createActionsApi(manifest.prefix, manifest.actions),
483
+ * // → invoicesApi.recordPayment(id, body)
484
+ * // → invoicesApi.propose(body) // id-less
485
+ * };
486
+ * ```
487
+ */
488
+ /**
489
+ * Build a manifest from a `ResourceDefinition` (the value returned by
490
+ * `defineResource(...)`). Use at build time when you have direct access
491
+ * to the resource module — typically the simplest codegen path.
492
+ */
493
+ function buildResourceManifest(resource) {
494
+ const disabled = new Set(resource.disabledRoutes ?? []);
495
+ const crudOps = resource.disableDefaultRoutes ? [] : CRUD_OPERATIONS.filter((op) => !disabled.has(op));
496
+ const actions = [];
497
+ for (const [name, entry] of Object.entries(resource.actions ?? {})) {
498
+ const requiresId = typeof entry === "function" ? true : entry.id !== false;
499
+ actions.push({
500
+ name,
501
+ mount: requiresId ? "/:id/action" : "/action",
502
+ requiresId,
503
+ ...typeof entry !== "function" && entry.description ? { description: entry.description } : {}
504
+ });
505
+ }
506
+ const aggregations = [];
507
+ for (const [name, agg] of Object.entries(resource.aggregations ?? {})) aggregations.push({
508
+ name,
509
+ path: `${resource.prefix}/aggregations/${name}`,
510
+ ...agg.summary ? { summary: agg.summary } : {},
511
+ ...agg.description ? { description: agg.description } : {}
512
+ });
513
+ const customRoutes = [];
514
+ for (const route of resource.routes ?? []) customRoutes.push({
515
+ method: route.method,
516
+ path: `${resource.prefix}${route.path}`,
517
+ ...route.operation ? { operation: route.operation } : {},
518
+ ...route.summary ? { summary: route.summary } : {}
519
+ });
520
+ return {
521
+ name: resource.name,
522
+ displayName: resource.displayName,
523
+ prefix: resource.prefix,
524
+ idField: resource.idField ?? "_id",
525
+ updateMethod: resource.updateMethod ?? "PATCH",
526
+ crudOps,
527
+ actions,
528
+ aggregations,
529
+ customRoutes,
530
+ ...resource.tenantField !== void 0 ? { tenantField: resource.tenantField } : {}
531
+ };
532
+ }
533
+ /**
534
+ * Build a manifest from a `RegistryEntry` — use when the resource is
535
+ * already registered (introspection endpoint, runtime audit script).
536
+ *
537
+ * Equivalent to {@link buildResourceManifest} but reads from the projected
538
+ * registry shape, so a host that doesn't have the original `defineResource`
539
+ * value in scope (e.g. an FE-gen sidecar that scrapes a running server's
540
+ * `/_resources` endpoint) gets the same output without dragging the resource
541
+ * module into the codegen path.
542
+ */
543
+ function buildResourceManifestFromRegistry(entry) {
544
+ const disabled = new Set(entry.disabledRoutes ?? []);
545
+ const crudOps = entry.disableDefaultRoutes ? [] : CRUD_OPERATIONS.filter((op) => !disabled.has(op));
546
+ const actions = (entry.actions ?? []).map((a) => {
547
+ const requiresId = a.id !== false;
548
+ return {
549
+ name: a.name,
550
+ mount: requiresId ? "/:id/action" : "/action",
551
+ requiresId,
552
+ ...a.description ? { description: a.description } : {}
553
+ };
554
+ });
555
+ const aggregations = (entry.aggregations ?? []).map((agg) => ({
556
+ name: agg.name,
557
+ path: `${entry.prefix}/aggregations/${agg.name}`,
558
+ ...agg.summary ? { summary: agg.summary } : {},
559
+ ...agg.description ? { description: agg.description } : {}
560
+ }));
561
+ const customRoutes = (entry.customRoutes ?? []).map((r) => ({
562
+ method: r.method,
563
+ path: `${entry.prefix}${r.path}`,
564
+ ...r.operation ? { operation: r.operation } : {},
565
+ ...r.summary ? { summary: r.summary } : {}
566
+ }));
567
+ const updateMethod = entry.updateMethod ?? "PATCH";
568
+ return {
569
+ name: entry.name,
570
+ displayName: entry.displayName ?? entry.name,
571
+ prefix: entry.prefix,
572
+ idField: "_id",
573
+ updateMethod,
574
+ crudOps,
575
+ actions,
576
+ aggregations,
577
+ customRoutes,
578
+ ...entry.tenantField !== void 0 ? { tenantField: entry.tenantField } : {}
579
+ };
580
+ }
581
+ //#endregion
582
+ export { cascadeDeleteForOrganization as a, assertNoTenantData as c, introspectionPlugin_default as i, buildResourceManifestFromRegistry as n, getCascadingResources as o, introspectionPlugin as r, getCascadingResourcesWithMetadata as s, buildResourceManifest as t };
@@ -1,9 +1,9 @@
1
1
  import { f as createError } from "./errors-j4aJm1Wg.mjs";
2
- import { b as isMember, c as getOrgId, h as getUserId, n as PUBLIC_SCOPE, y as isElevated } from "./types-C_s5moIu.mjs";
2
+ import { b as isElevated, c as getOrgId, g as getUserId, n as PUBLIC_SCOPE, x as isMember } from "./types-Bi0r0vjG.mjs";
3
3
  import { t as requestContext } from "./requestContext-SSaaTgW8.mjs";
4
- import { I as evaluateAndApplyPermission, p as resolveEffectiveRoles, u as applyFieldReadPermissions } from "./permissions-ohQyv50e.mjs";
4
+ import { I as evaluateAndApplyPermission, p as resolveEffectiveRoles, u as applyFieldReadPermissions } from "./permissions-CTxMrreC.mjs";
5
5
  import { t as getUserRoles } from "./types-D57iXYb8.mjs";
6
- import { t as executePipeline } from "./pipe-Zr0KXjQe.mjs";
6
+ import { t as executePipeline } from "./pipe-DiCyvyPN.mjs";
7
7
  import { isPaginatedResult, toCanonicalList } from "@classytic/repo-core/pagination";
8
8
  //#region src/scope/projection.ts
9
9
  /**
@@ -55,9 +55,13 @@ function applyFieldMask(data, fieldMask) {
55
55
  return data;
56
56
  }
57
57
  /**
58
- * Create IRequestContext from Fastify request
59
- *
60
- * Extracts framework-agnostic context from Fastify-specific request object
58
+ * Project `FastifyRequest` into the curated `IRequestContext` shape that
59
+ * arc's controllers receive. This is a developer-facing facade, not a
60
+ * portability boundary — arc is Fastify-only. The projection lifts the
61
+ * fields tenant-scoped controllers reach for (`organizationId`,
62
+ * `teamId`, `scope`, `context`, server accessor) onto a flat, typed
63
+ * surface so handler code doesn't have to dig through Fastify's
64
+ * decorator surface or `req.metadata._scope` internals.
61
65
  */
62
66
  function createRequestContext(req) {
63
67
  const reqWithExtras = req;
@@ -145,12 +149,33 @@ function computeFieldCapabilities(fieldPerms, effectiveRoles) {
145
149
  return caps;
146
150
  }
147
151
  /**
148
- * Send IControllerResponse via Fastify reply
152
+ * Send a controller's `IControllerResponse` through Fastify's reply.
153
+ *
154
+ * The `IControllerResponse<T>` envelope is arc's internal pipeline
155
+ * contract — it's how custom `status`, `meta`, `headers`, and field
156
+ * permissions thread from a handler through interceptors to the wire
157
+ * without each step having to touch `reply` directly. Not a portability
158
+ * abstraction (arc is Fastify-only); a pipeline-internal one.
149
159
  *
150
- * Converts framework-agnostic response to Fastify response
151
- * Applies field masking if specified in request
160
+ * **Auto-envelope of bare returns.** Custom route handlers are
161
+ * expected to return the envelope shape so this function can apply
162
+ * field permissions, attach `fieldCaps`, and flatten paginated lists.
163
+ * A handler that returns raw `T` (array, plain object, primitive) used
164
+ * to silently produce a broken response — `response.data` was
165
+ * `undefined`, the body sent was `undefined`, and any declared
166
+ * `fields:` permission map was bypassed (the leak shape mentora
167
+ * flagged). 2.17 detects bare returns and wraps them into
168
+ * `{ data: response, status: 200 }` before the field-permission
169
+ * pipeline runs, so `fields:` applies to ALL custom routes whether the
170
+ * handler envelopes its return value or not. The envelope shape is
171
+ * still the canonical contract — auto-wrapping is a safety net, not
172
+ * an invitation to drop the envelope.
152
173
  */
153
174
  function sendControllerResponse(reply, response, request) {
175
+ if (response === null || typeof response !== "object" || !Object.hasOwn(response, "data")) response = {
176
+ data: response,
177
+ status: 200
178
+ };
154
179
  const reqWithExtras = request;
155
180
  const fieldMaskConfig = reqWithExtras?.fieldMask;
156
181
  const arcMeta = reqWithExtras?.arc;
@@ -207,18 +232,17 @@ function sendControllerResponse(reply, response, request) {
207
232
  reply.code(status).send(filteredData);
208
233
  }
209
234
  /**
210
- * Create Fastify route handler from IController method
211
- *
212
- * Wraps framework-agnostic controller method in Fastify-specific handler
235
+ * Bridge an `IController` method (`(ctx: IRequestContext) => Promise<IControllerResponse<T>>`)
236
+ * into a Fastify route handler. Translates between arc's curated request
237
+ * context surface and Fastify's request/reply, and routes the envelope
238
+ * through `sendControllerResponse` so the pipeline applies field
239
+ * permissions, status, and meta uniformly. Internal plumbing, not a
240
+ * portability layer — arc is Fastify-only.
213
241
  *
214
242
  * @example
215
243
  * ```typescript
216
244
  * const controller = new BaseController(repository);
217
- *
218
- * // Create Fastify handler
219
245
  * const listHandler = createFastifyHandler(controller.list.bind(controller));
220
- *
221
- * // Register route
222
246
  * fastify.get('/products', listHandler);
223
247
  * ```
224
248
  */
@@ -1,5 +1,5 @@
1
- import { C as isOrgInScope, D as requireTeamId, E as requireOrgId, O as requireUserId, S as isMember, T as requireClientId, _ as getUserId, a as getAncestorOrgIds, b as isAuthenticated, c as getMandate, d as getOrgRoles, f as getRequestScope, g as getTeamId, h as getServiceScopes, i as RequestScope, l as getOrgContext, m as getScopeContextMap, n as Mandate, o as getClientId, p as getScopeContext, r as PUBLIC_SCOPE, s as getDPoPJkt, t as AUTHENTICATED_SCOPE, u as getOrgId, v as getUserRoles, w as isService, x as isElevated, y as hasOrgAccess } from "../types-CTYvcwHe.mjs";
2
- import { i as elevationPlugin, n as ElevationOptions, r as _default, t as ElevationEvent } from "../elevation-BXOWoGCF.mjs";
1
+ import { C as isMember, D as requireOrgId, E as requireClientId, O as requireTeamId, S as isElevated, T as isService, _ as getTenantFromRequest, a as getAncestorOrgIds, b as hasOrgAccess, c as getMandate, d as getOrgRoles, f as getRequestScope, g as getTeamId, h as getServiceScopes, i as RequestScope, k as requireUserId, l as getOrgContext, m as getScopeContextMap, n as Mandate, o as getClientId, p as getScopeContext, r as PUBLIC_SCOPE, s as getDPoPJkt, t as AUTHENTICATED_SCOPE, u as getOrgId, v as getUserId, w as isOrgInScope, x as isAuthenticated, y as getUserRoles } from "../types-CfapW2t8.mjs";
2
+ import { i as elevationPlugin, n as ElevationOptions, r as _default, t as ElevationEvent } from "../elevation-DvwtjDwu.mjs";
3
3
  import { FastifyReply, FastifyRequest } from "fastify";
4
4
 
5
5
  //#region src/scope/rateLimitKey.d.ts
@@ -30,4 +30,4 @@ interface ResolveOrgFromHeaderOptions {
30
30
  */
31
31
  declare function resolveOrgFromHeader(options: ResolveOrgFromHeaderOptions): (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
32
32
  //#endregion
33
- export { AUTHENTICATED_SCOPE, type ElevationEvent, type ElevationOptions, type Mandate, PUBLIC_SCOPE, type RateLimitKeyContext, type RequestScope, type ResolveOrgFromHeaderOptions, type TenantKeyGeneratorOptions, createTenantKeyGenerator, _default as elevationPlugin, elevationPlugin as elevationPluginFn, getAncestorOrgIds, getClientId, getDPoPJkt, getMandate, getOrgContext, getOrgId, getOrgRoles, getRequestScope, getScopeContext, getScopeContextMap, getServiceScopes, getTeamId, getUserId, getUserRoles, hasOrgAccess, isAuthenticated, isElevated, isMember, isOrgInScope, isService, requireClientId, requireOrgId, requireTeamId, requireUserId, resolveOrgFromHeader };
33
+ export { AUTHENTICATED_SCOPE, type ElevationEvent, type ElevationOptions, type Mandate, PUBLIC_SCOPE, type RateLimitKeyContext, type RequestScope, type ResolveOrgFromHeaderOptions, type TenantKeyGeneratorOptions, createTenantKeyGenerator, _default as elevationPlugin, elevationPlugin as elevationPluginFn, getAncestorOrgIds, getClientId, getDPoPJkt, getMandate, getOrgContext, getOrgId, getOrgRoles, getRequestScope, getScopeContext, getScopeContextMap, getServiceScopes, getTeamId, getTenantFromRequest, getUserId, getUserRoles, hasOrgAccess, isAuthenticated, isElevated, isMember, isOrgInScope, isService, requireClientId, requireOrgId, requireTeamId, requireUserId, resolveOrgFromHeader };