@classytic/arc 2.32.0 → 2.33.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 (192) hide show
  1. package/README.md +1 -1
  2. package/bin/arc.js +58 -17
  3. package/dist/{BaseController-Dk34dxhI.mjs → BaseController-CaQC5EXX.mjs} +293 -33
  4. package/dist/{EventTransport-2uVV34WU.d.mts → EventTransport-BEVP50ka.d.mts} +16 -0
  5. package/dist/{EventTransport-BV10D0oG.mjs → EventTransport-tq4CEB-q.mjs} +39 -2
  6. package/dist/{HookSystem-vimw5D7Z.mjs → HookSystem-DqXkMXkB.mjs} +2 -2
  7. package/dist/{ResourceRegistry-BVr7PgA0.mjs → ResourceRegistry-DoALUlOO.mjs} +3 -2
  8. package/dist/{actionPermissions-Dti7ebyv.mjs → actionPermissions-CUeCmpUd.mjs} +1 -1
  9. package/dist/audit/index.d.mts +2 -2
  10. package/dist/audit/index.mjs +2 -2
  11. package/dist/auth/audit.mjs +1 -1
  12. package/dist/auth/index.d.mts +2 -2
  13. package/dist/auth/index.mjs +39 -7
  14. package/dist/auth/redis-session.mjs +1 -1
  15. package/dist/{authorizationDecision-__T3EJDA.mjs → authorizationDecision-B3mP_QTy.mjs} +2 -2
  16. package/dist/{types-B7XeLEgK.d.mts → base-Csjc2oF-.d.mts} +74 -74
  17. package/dist/{buildHandler-Dn7rzUJo.mjs → buildHandler-DLE9xX-V.mjs} +4 -4
  18. package/dist/cache/index.mjs +4 -4
  19. package/dist/{caching-DFJqHyFs.mjs → caching-BcGmvnHh.mjs} +2 -2
  20. package/dist/cleanup/index.d.mts +17 -5
  21. package/dist/cleanup/index.mjs +240 -7
  22. package/dist/cli/commands/describe.d.mts +1 -1
  23. package/dist/cli/commands/describe.mjs +3 -3
  24. package/dist/cli/commands/docs.mjs +3 -3
  25. package/dist/cli/commands/doctor.mjs +1 -1
  26. package/dist/cli/commands/generate.mjs +2 -2
  27. package/dist/cli/commands/init.mjs +2 -2
  28. package/dist/cli/commands/introspect.mjs +2 -2
  29. package/dist/cli/index.mjs +3 -3
  30. package/dist/{constants-J99LGTJI.mjs → constants-BxCoxmiN.mjs} +1 -1
  31. package/dist/context/index.mjs +3 -3
  32. package/dist/core/index.d.mts +2 -2
  33. package/dist/core/index.mjs +7 -7
  34. package/dist/{core-BwwmNSp7.mjs → core-dxhcHhUD.mjs} +3 -3
  35. package/dist/{createActionRouter-DkJZWo0m.mjs → createActionRouter-DVpoA0do.mjs} +8 -8
  36. package/dist/{createAggregationRouter-B1exOVD8.mjs → createAggregationRouter-B_B48JZ-.mjs} +7 -7
  37. package/dist/{createApp-Bv0m-Q6_.mjs → createApp-CLGegqiG.mjs} +128 -24
  38. package/dist/{defineAggregation-gX4No1gt.mjs → defineAggregation-Nv-DwfOf.mjs} +1 -1
  39. package/dist/{defineResource-XFPPUu2c.mjs → defineResource-DR9vsUYy.mjs} +297 -23
  40. package/dist/discovery/index.mjs +1 -1
  41. package/dist/docs/index.d.mts +1 -1
  42. package/dist/docs/index.mjs +3 -3
  43. package/dist/{elevation-DNXXxgjl.mjs → elevation-B0tlY8y1.mjs} +4 -4
  44. package/dist/encryption/index.mjs +3 -3
  45. package/dist/{errorHandler-B2AZUJUj.mjs → errorHandler-C7xBqHsI.mjs} +42 -5
  46. package/dist/{errorHandler-CpYUJmPA.d.mts → errorHandler-DI5AN2b5.d.mts} +14 -3
  47. package/dist/{errors-Bh1KK5ps.mjs → errors-Cd_yy7aW.mjs} +1 -1
  48. package/dist/{eventPlugin-GF5nnBIq.d.mts → eventPlugin-DSNEZWfV.d.mts} +29 -1
  49. package/dist/{eventPlugin-Dk_TyXeE.mjs → eventPlugin-Dar8qRaj.mjs} +65 -9
  50. package/dist/events/index.d.mts +5 -351
  51. package/dist/events/index.mjs +5 -780
  52. package/dist/events/transports/redis-stream-entry.d.mts +1 -1
  53. package/dist/events/transports/redis-stream-entry.mjs +1 -1
  54. package/dist/events/transports/redis.d.mts +1 -1
  55. package/dist/events/transports/redis.mjs +1 -1
  56. package/dist/factory/index.d.mts +20 -5
  57. package/dist/factory/index.mjs +7 -7
  58. package/dist/{fastify-augmentation-BppG5bMT.d.mts → fastify-augmentation-DVsTAy6X.d.mts} +3 -3
  59. package/dist/{fields-DEi65pGj.mjs → fields-CojeUkS4.mjs} +1 -1
  60. package/dist/{generate-CRSG0jTv.mjs → generate-DiB0nHPR.mjs} +2 -2
  61. package/dist/{headers-BXjQYbeC.mjs → headers-CveNO9gR.mjs} +2 -2
  62. package/dist/{health-5XnTa2uo.mjs → health-CasG2A28.mjs} +1 -1
  63. package/dist/hooks/index.d.mts +1 -1
  64. package/dist/hooks/index.mjs +2 -2
  65. package/dist/idempotency/index.d.mts +45 -1
  66. package/dist/idempotency/index.mjs +91 -9
  67. package/dist/idempotency/redis.mjs +1 -1
  68. package/dist/{index-zq6VrjaB.d.mts → index-B-DvUzsr.d.mts} +12 -2
  69. package/dist/{index-CpzxqePp.d.mts → index-BcR2bFZQ.d.mts} +707 -373
  70. package/dist/{index-DXlHwvuE.d.mts → index-Brin0ODU.d.mts} +4 -4
  71. package/dist/{index-ZWhLTp_s.d.mts → index-C5NnMEKF.d.mts} +1 -1
  72. package/dist/{index-C-481Zf2.d.mts → index-ugr1tQlW.d.mts} +17 -1
  73. package/dist/index.d.mts +6 -6
  74. package/dist/index.mjs +11 -11
  75. package/dist/{init-BbfP8aky.mjs → init-mdJ-v4Xp.mjs} +18 -12
  76. package/dist/integrations/event-gateway.d.mts +1 -1
  77. package/dist/integrations/event-gateway.mjs +2 -2
  78. package/dist/integrations/index.d.mts +2 -2
  79. package/dist/integrations/index.mjs +1 -1
  80. package/dist/integrations/jobs/index.d.mts +1 -1
  81. package/dist/integrations/jobs/index.mjs +99 -97
  82. package/dist/integrations/mcp/index.d.mts +12 -3
  83. package/dist/integrations/mcp/index.mjs +2 -2
  84. package/dist/integrations/mcp/testing.d.mts +1 -1
  85. package/dist/integrations/mcp/testing.mjs +2 -2
  86. package/dist/integrations/streamline.mjs +3 -3
  87. package/dist/integrations/webhooks.d.mts +124 -1
  88. package/dist/integrations/webhooks.mjs +180 -2
  89. package/dist/integrations/websocket-pushref-redis.mjs +1 -1
  90. package/dist/integrations/websocket-redis.mjs +1 -1
  91. package/dist/integrations/websocket.mjs +1 -1
  92. package/dist/{interface-BP4IJCBO.mjs → interface-DznJrm9R.mjs} +1 -1
  93. package/dist/{jsonBody-DDSR2HFl.mjs → jsonBody-CfGH-GxO.mjs} +2 -2
  94. package/dist/{keys-B-HBTGXl.mjs → keys-WeKvY1dE.mjs} +1 -1
  95. package/dist/{loadResources-DisEAmkm.mjs → loadResources-Bj-aSsXi.mjs} +2 -2
  96. package/dist/{loadResourcesFromEntry-D1MJp13f.mjs → loadResourcesFromEntry-DwaxNcE2.mjs} +2 -2
  97. package/dist/logger/index.mjs +1 -1
  98. package/dist/{mcpPlugin-CpfVpKHE.mjs → mcpPlugin-aJg5TqHi.mjs} +60 -21
  99. package/dist/{memory-DsuWnj4T.mjs → memory-qYSB7rMQ.mjs} +2 -2
  100. package/dist/{metrics-DdQL8syd.mjs → metrics-D7uchLjN.mjs} +2 -2
  101. package/dist/middleware/index.d.mts +1 -1
  102. package/dist/middleware/index.mjs +3 -3
  103. package/dist/migrations/index.mjs +2 -2
  104. package/dist/{module-Br81am6m.mjs → module-CwKsgZXp.mjs} +1 -1
  105. package/dist/{multipartBody-BnkBLN7v.mjs → multipartBody-4w-S3-PU.mjs} +1 -1
  106. package/dist/{openapi-DIRzvxC0.mjs → openapi-BN5cQaO2.mjs} +6 -6
  107. package/dist/outbox-admin/index.d.mts +2 -2
  108. package/dist/outbox-admin/index.mjs +4 -4
  109. package/dist/outbox-module-CXB79bvt.mjs +839 -0
  110. package/dist/outbox-module-D-VFq37T.d.mts +368 -0
  111. package/dist/permissions/index.d.mts +2 -2
  112. package/dist/permissions/index.mjs +5 -5
  113. package/dist/{permissions-Dl_m0NCe.mjs → permissions-BAfwgBxc.mjs} +25 -6
  114. package/dist/{pipe-ELHCP3zj.mjs → pipe-BzzoEhHj.mjs} +2 -2
  115. package/dist/pipeline/index.d.mts +1 -1
  116. package/dist/pipeline/index.mjs +2 -2
  117. package/dist/{pipeline-CPA-jx0M.mjs → pipeline-q69DZzgG.mjs} +6 -6
  118. package/dist/plugins/index.d.mts +6 -6
  119. package/dist/plugins/index.mjs +16 -16
  120. package/dist/plugins/response-cache.mjs +2 -2
  121. package/dist/plugins/tracing-entry.mjs +2 -2
  122. package/dist/{pluralize-C2ZkKbsn.mjs → pluralize-B7xf8qZF.mjs} +1 -1
  123. package/dist/{preloadResources-CDs87fTw.mjs → preloadResources-BkyqeTDz.mjs} +1 -1
  124. package/dist/{preloadResources-T1hmlTEZ.d.mts → preloadResources-g07ldtzh.d.mts} +30 -7
  125. package/dist/presets/filesUpload.d.mts +2 -2
  126. package/dist/presets/filesUpload.mjs +5 -5
  127. package/dist/presets/index.d.mts +2 -2
  128. package/dist/presets/index.mjs +2 -2
  129. package/dist/presets/multiTenant.d.mts +1 -1
  130. package/dist/presets/multiTenant.mjs +3 -3
  131. package/dist/presets/search.d.mts +2 -2
  132. package/dist/presets/search.mjs +2 -2
  133. package/dist/{presets-Dntd_pUO.mjs → presets-D-h0uWtP.mjs} +4 -4
  134. package/dist/projection-BKM0Ui-s.mjs +42 -0
  135. package/dist/{queryCachePlugin-Dhps-Ldt.mjs → queryCachePlugin-DhsZ0ZIc.mjs} +49 -7
  136. package/dist/{rateLimit-C7mmAGs3.mjs → rateLimit-D4-ASwsI.mjs} +2 -2
  137. package/dist/{rateLimitKey-CccEFBfk.mjs → rateLimitKey-XngqVNEV.mjs} +1 -1
  138. package/dist/{realtime-DVvVZZFz.mjs → realtime-CTkv3O59.mjs} +9 -9
  139. package/dist/{redis-stream-qN6A-RFv.d.mts → redis-stream-mwLWlfHG.d.mts} +1 -1
  140. package/dist/{registerRoute-B_QrjYZS.mjs → registerRoute-DNnd3vkz.mjs} +4 -4
  141. package/dist/registry/index.d.mts +3 -3
  142. package/dist/registry/index.mjs +3 -3
  143. package/dist/{registry-DqUmzA9W.mjs → registry-CLxCR5LF.mjs} +2 -2
  144. package/dist/{renewingLease-BpP9wwj7.mjs → renewingLease-BnWtbA2U.mjs} +1 -1
  145. package/dist/{replyHelpers-DS-P0b32.mjs → replyHelpers-BSBVCwL3.mjs} +2 -2
  146. package/dist/{repositoryFilter-DotlxG3L.mjs → repositoryFilter-DKny7PDn.mjs} +1 -1
  147. package/dist/{requestContext-B6vQF-sy.mjs → requestContext-uYE35JxI.mjs} +1 -1
  148. package/dist/{rolldown-runtime-BhflRrBs.mjs → rolldown-runtime-DNg-KNCk.mjs} +1 -1
  149. package/dist/runtimeCapabilities-Dm88DaTW.mjs +49 -0
  150. package/dist/{schedules-C2Q3uv2d.mjs → schedules-CAcZgia0.mjs} +3 -3
  151. package/dist/{schemaConverter-Crjvczi_.mjs → schemaConverter-XH1RZRyu.mjs} +1 -1
  152. package/dist/{schemaIR-CnyKK_1j.mjs → schemaIR-CDvclAew.mjs} +2 -2
  153. package/dist/schemas/index.mjs +1 -1
  154. package/dist/scim/index.mjs +2 -2
  155. package/dist/scope/index.mjs +5 -5
  156. package/dist/{scope-afz_Ieek.mjs → scope-BaWcqBvr.mjs} +4 -4
  157. package/dist/{simpleEqualityMatcher-C4KiMWUa.mjs → simpleEqualityMatcher-Se8VrsaP.mjs} +1 -1
  158. package/dist/{sse-B9P4Y-UI.mjs → sse-Cf0jUHsh.mjs} +5 -5
  159. package/dist/{sseStream-B3zb_d1h.mjs → sseStream-Bvpi-UXQ.mjs} +2 -2
  160. package/dist/{store-helpers-DmEMHlPQ.mjs → store-helpers-B88p99Z2.mjs} +1 -1
  161. package/dist/{streaming-DPZdQmDz.mjs → streaming-DsT1B5zJ.mjs} +1 -1
  162. package/dist/{subscribe-helpers-sp5xTDVA.mjs → subscribe-helpers-CLIOHsOK.mjs} +2 -2
  163. package/dist/sync/changelog.mjs +1 -1
  164. package/dist/testing/cleanupStoreContract.d.mts +1 -1
  165. package/dist/testing/cleanupStoreContract.mjs +3 -1
  166. package/dist/testing/index.d.mts +24 -12
  167. package/dist/testing/index.mjs +24 -12
  168. package/dist/testing/outboxStoreContract.mjs +1 -1
  169. package/dist/testing/storageContract.mjs +1 -1
  170. package/dist/{transactionContext-BvLKvOIL.mjs → transactionContext-BYtWnwAS.mjs} +1 -1
  171. package/dist/{typeGuards-B_5-hKjJ.mjs → typeGuards-jjKJDN13.mjs} +1 -1
  172. package/dist/types/index.d.mts +3 -3
  173. package/dist/types/index.mjs +1 -1
  174. package/dist/types/storage.mjs +1 -1
  175. package/dist/{types-CT5vu6M0.d.mts → types-BcTumAz_.d.mts} +41 -7
  176. package/dist/{types-CwcUQ610.d.mts → types-BfC83KsP.d.mts} +15 -1
  177. package/dist/{types-DXYX7dIR.mjs → types-CoqIRrkY.mjs} +4 -4
  178. package/dist/{types-C4QE8qnm.d.mts → types-DVLddu2q.d.mts} +144 -1
  179. package/dist/usage/index.d.mts +1 -1
  180. package/dist/usage/index.mjs +0 -0
  181. package/dist/{userHelpers-BNsOXzOd.mjs → userHelpers-CWF5pbZD.mjs} +1 -1
  182. package/dist/utils/index.d.mts +57 -5
  183. package/dist/utils/index.mjs +12 -11
  184. package/dist/{utils-BtOha_Sp.mjs → utils-h1V_sT-t.mjs} +38 -7
  185. package/dist/{validate-PMCPR9El.mjs → validate-CdQlz52F.mjs} +2 -2
  186. package/dist/{versioning-D_rhHfZm.d.mts → versioning-DEs-_hFi.d.mts} +1 -1
  187. package/dist/{versioning-CKv2jz_U.mjs → versioning-rZbyLVD-.mjs} +3 -3
  188. package/package.json +17 -16
  189. package/skills/arc/SKILL.md +96 -4
  190. package/skills/arc/references/api-reference.md +19 -0
  191. package/skills/arc/references/production.md +2 -2
  192. package/dist/projection-CjHhM5W1.mjs +0 -18
package/README.md CHANGED
@@ -147,7 +147,7 @@ defineResource({
147
147
  defineResource({ name: 'pipeline', adapter, defaultLimit: 200, maxLimit: 500 });
148
148
  ```
149
149
 
150
- `?limit=` violations respond 400 with a cap-aware envelope: `message: "Query parameter 'limit' must be <= 500 (got 800) (cap is 500)"` plus `meta: { field: 'limit', cap: 500 }` so callers can self-correct without scraping the message.
150
+ A `?limit=` above the cap is **clamped to it** and served, not rejected (changed in 2.33). Rejecting made the documented clamp unreachable and turned a benign over-ask into a 400 that callers commonly render as an empty list. The effective cap is carried in the OpenAPI parameter description, and the response's own `limit` reports what was applied.
151
151
 
152
152
  ---
153
153
 
package/bin/arc.js CHANGED
@@ -42,17 +42,11 @@ const VERSION = getPackageVersion();
42
42
 
43
43
  const args = process.argv.slice(2);
44
44
 
45
- // Version flag
46
- if (args.includes('--version') || args.includes('-v')) {
47
- console.log(`Arc CLI v${VERSION}`);
48
- process.exit(0);
49
- }
50
-
51
- // Help flag or no args
52
- if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
53
- printHelp();
54
- process.exit(0);
55
- }
45
+ // Version / help are GLOBAL only in the leading position. Scanning the whole
46
+ // argv made `arc init --help` print the global help and exit, which left the
47
+ // per-command usage in COMMAND_HELP unreachable — a user asking how to use
48
+ // one command got the manual for all of them.
49
+ const leading = args[0];
56
50
 
57
51
  // ============================================================================
58
52
  // Command Routing
@@ -64,7 +58,7 @@ const [command, subcommand, ...rest] = args;
64
58
  // through to the command (where `--help` was mistaken for an argument).
65
59
  const COMMAND_ALIASES = { new: 'init', g: 'generate', i: 'introspect', desc: 'describe', d: 'docs' };
66
60
  const COMMAND_HELP = {
67
- init: 'Usage: arc init [name] [--jwt|--better-auth] [--mongokit|--custom] [--api-key]\n [--multi|--single] [--js] [--edge] [--docker|--no-docker] [--skip-install] [--force]\n Scaffold a new arc project (prompts when run in a TTY without a name).',
61
+ init: 'Usage: arc init [name] [--jwt|--better-auth] [--mongokit|--custom] [--api-key|--no-api-key] [--session cookie|bearer|both]\n [--multi|--single] [--js] [--edge] [--docker|--no-docker] [--skip-install] [--force]\n Scaffold a new arc project (prompts when run in a TTY without a name).',
68
62
  generate: 'Usage: arc generate <resource|controller|model|repository|schemas|mcp> <name> [--mcp]\n Aliases: g; types r/c/m/repo/s. Name must be kebab-case (letters, digits, hyphens).',
69
63
  introspect: 'Usage: arc introspect <entry-file>\n Print a human-readable summary of the resources an entry file exports.',
70
64
  describe: 'Usage: arc describe <entry-file> [--json] [--pretty] [--entry <file>]\n Describe resources; --json emits machine output for AI agents.',
@@ -72,8 +66,27 @@ const COMMAND_HELP = {
72
66
  doctor: 'Usage: arc doctor\n Diagnose common arc setup issues (exit 1 on hard failures).',
73
67
  };
74
68
 
69
+ /**
70
+ * `process.exitCode` + return, never `process.exit()`.
71
+ *
72
+ * `process.exit()` tears the process down immediately; on POSIX a `stdout`
73
+ * that is a PIPE (`arc --help | less`, `arc describe --json > out.json`)
74
+ * writes asynchronously, so an exit racing the flush TRUNCATES output. Arc's
75
+ * own `doctor` already sets `exitCode`; this file is the one that did not.
76
+ * Setting the code and returning lets Node drain stdout and exit on its own.
77
+ */
75
78
  async function main() {
76
79
  try {
80
+ // Global help/version are the LEADING argument only — see `leading`.
81
+ if (leading === '--version' || leading === '-v') {
82
+ console.log(`Arc CLI v${VERSION}`);
83
+ return;
84
+ }
85
+ if (args.length === 0 || leading === '--help' || leading === '-h') {
86
+ printHelp();
87
+ return;
88
+ }
89
+
77
90
  // `arc <command> --help` / `-h` → command-specific usage, exit 0.
78
91
  const canonicalCommand = COMMAND_ALIASES[command] ?? command;
79
92
  if (
@@ -117,7 +130,8 @@ async function main() {
117
130
  default:
118
131
  console.error(`Unknown command: ${command}`);
119
132
  console.error('Run "arc --help" for usage');
120
- process.exit(1);
133
+ process.exitCode = 1;
134
+ return;
121
135
  }
122
136
  } catch (err) {
123
137
  const message = err instanceof Error ? err.message : String(err);
@@ -125,7 +139,7 @@ async function main() {
125
139
  if (process.env.DEBUG) {
126
140
  console.error(err instanceof Error ? err.stack : err);
127
141
  }
128
- process.exit(1);
142
+ process.exitCode = 1;
129
143
  }
130
144
  }
131
145
 
@@ -148,7 +162,8 @@ async function handleGenerate(type, args) {
148
162
  console.log(' arc generate resource product --mcp');
149
163
  console.log(' arc generate mcp product');
150
164
  console.log(' arc g r invoice');
151
- process.exit(1);
165
+ process.exitCode = 1;
166
+ return;
152
167
  }
153
168
 
154
169
  // Normalize type shortcuts
@@ -170,14 +185,16 @@ async function handleGenerate(type, args) {
170
185
  if (!normalizedType) {
171
186
  console.error(`Unknown type: ${type}`);
172
187
  console.log('Available types: resource (r), controller (c), model (m), repository (repo), schemas (s)');
173
- process.exit(1);
188
+ process.exitCode = 1;
189
+ return;
174
190
  }
175
191
 
176
192
  const name = args[0];
177
193
  if (!name) {
178
194
  console.error('Missing name argument');
179
195
  console.log(`\nUsage: arc generate ${normalizedType} <name>`);
180
- process.exit(1);
196
+ process.exitCode = 1;
197
+ return;
181
198
  }
182
199
 
183
200
  // Import and run
@@ -250,6 +267,11 @@ function parseInitOptions(rawArgs) {
250
267
  adapter: undefined,
251
268
  auth: undefined,
252
269
  tenant: undefined,
270
+ // Better Auth-specific. `undefined` (not a default) is load-bearing:
271
+ // options.ts prompts ONLY when the field is undefined, so defaulting
272
+ // here would silently skip the interactive question.
273
+ apiKey: undefined,
274
+ session: undefined,
253
275
  typescript: undefined,
254
276
  edge: undefined,
255
277
  skipInstall: false,
@@ -309,6 +331,21 @@ function parseInitOptions(rawArgs) {
309
331
  opts.auth = 'jwt';
310
332
  break;
311
333
 
334
+ case '--api-key':
335
+ opts.apiKey = true;
336
+ break;
337
+
338
+ case '--no-api-key':
339
+ opts.apiKey = false;
340
+ break;
341
+
342
+ case '--session':
343
+ // cookie | bearer | both — options.ts falls back to the documented
344
+ // default for anything else.
345
+ opts.session = next;
346
+ i++;
347
+ break;
348
+
312
349
  case '--edge':
313
350
  case '--serverless':
314
351
  opts.edge = true;
@@ -380,6 +417,7 @@ GLOBAL OPTIONS
380
417
  --help, -h Show this help
381
418
 
382
419
  INIT OPTIONS
420
+ --name, -n <name> Project name (alternative to the positional argument)
383
421
  --mongokit Use MongoKit adapter (default, recommended)
384
422
  --custom Use custom / Drizzle-ready adapter template
385
423
  --better-auth Use Better Auth (default, recommended)
@@ -388,6 +426,9 @@ INIT OPTIONS
388
426
  --single-tenant, --single Single-tenant mode (default)
389
427
  --ts, --typescript Generate TypeScript (default)
390
428
  --js, --javascript Generate JavaScript
429
+ --api-key Enable Better Auth's apiKey plugin (machine-to-machine)
430
+ --no-api-key Skip it without being prompted
431
+ --session <mode> Better Auth session strategy: cookie | bearer | both
391
432
  --edge, --serverless Target Edge/Serverless environments
392
433
  --docker Emit Dockerfile + docker-compose.yml (opt-in, 2.16)
393
434
  --no-docker Skip Docker scaffolding even in interactive mode
@@ -1,15 +1,17 @@
1
- /*! @classytic/arc v2.32.0 | MIT | https://classytic.com */
2
- import { h as SYSTEM_FIELDS, o as DEFAULT_TENANT_FIELD } from "./constants-J99LGTJI.mjs";
1
+ /*! @classytic/arc v2.34.0 | MIT | https://classytic.com */
2
+ import { h as SYSTEM_FIELDS, o as DEFAULT_TENANT_FIELD } from "./constants-BxCoxmiN.mjs";
3
3
  import { arcLog } from "./logger/index.mjs";
4
- import { a as conjoinPolicyFilters } from "./authorizationDecision-__T3EJDA.mjs";
5
- import { d as createDomainError, f as createError, i as NotFoundError, p as isArcError, r as ForbiddenError } from "./errors-Bh1KK5ps.mjs";
6
- import { n as getUserRoles, t as getUserId } from "./userHelpers-BNsOXzOd.mjs";
7
- import { b as isElevated, c as getOrgId, n as PUBLIC_SCOPE, x as isMember } from "./types-DXYX7dIR.mjs";
8
- import { t as simpleEqualityMatcher } from "./simpleEqualityMatcher-C4KiMWUa.mjs";
9
- import { t as toRepositoryFilter } from "./repositoryFilter-DotlxG3L.mjs";
10
- import { i as resolveEffectiveRoles, n as applyFieldWritePermissions } from "./fields-DEi65pGj.mjs";
11
- import { g as ArcQueryParser, n as scheduleBackground } from "./utils-BtOha_Sp.mjs";
12
- import { t as buildQueryKey } from "./keys-B-HBTGXl.mjs";
4
+ import { a as conjoinPolicyFilters } from "./authorizationDecision-B3mP_QTy.mjs";
5
+ import { d as createDomainError, f as createError, i as NotFoundError, p as isArcError, r as ForbiddenError } from "./errors-Cd_yy7aW.mjs";
6
+ import { n as getUserRoles, t as getUserId } from "./userHelpers-CWF5pbZD.mjs";
7
+ import { b as isElevated, c as getOrgId, n as PUBLIC_SCOPE, x as isMember } from "./types-CoqIRrkY.mjs";
8
+ import { t as simpleEqualityMatcher } from "./simpleEqualityMatcher-Se8VrsaP.mjs";
9
+ import { t as toRepositoryFilter } from "./repositoryFilter-DKny7PDn.mjs";
10
+ import { i as resolveEffectiveRoles, n as applyFieldWritePermissions } from "./fields-CojeUkS4.mjs";
11
+ import { g as ArcQueryParser, n as scheduleBackground } from "./utils-h1V_sT-t.mjs";
12
+ import { t as buildQueryKey } from "./keys-WeKvY1dE.mjs";
13
+ import { retryingTransaction } from "@classytic/repo-core/repository";
14
+ import { isProductionEnv } from "@classytic/primitives/environment";
13
15
  //#region src/core/AccessControl.ts
14
16
  const log$1 = arcLog("access-control");
15
17
  var AccessControl = class {
@@ -259,9 +261,11 @@ const DEFAULT_FIELD_WRITE_DENIAL_POLICY = "reject";
259
261
  var BodySanitizer = class {
260
262
  schemaOptions;
261
263
  onFieldWriteDenied;
264
+ onImmutableWrite;
262
265
  constructor(config) {
263
266
  this.schemaOptions = config.schemaOptions;
264
267
  this.onFieldWriteDenied = config.onFieldWriteDenied ?? "reject";
268
+ this.onImmutableWrite = config.onImmutableWrite ?? (process.env.ARC_STRICT_IMMUTABLE_WRITES === "true" ? "reject" : "strip");
265
269
  }
266
270
  /**
267
271
  * Strip readonly and system-managed fields from request body.
@@ -276,11 +280,16 @@ var BodySanitizer = class {
276
280
  const scopeForRules = req ? (meta ?? req.metadata)?._scope ?? PUBLIC_SCOPE : void 0;
277
281
  const scopeIsElevated = scopeForRules ? isElevated(scopeForRules) : false;
278
282
  const fieldRules = this.schemaOptions.fieldRules ?? {};
283
+ const immutableAttempts = [];
279
284
  for (const [field, rules] of Object.entries(fieldRules)) {
280
285
  const bypass = Boolean(rules.preserveForElevated) && scopeIsElevated;
281
286
  if ((rules.systemManaged || rules.readonly) && !bypass) delete sanitized[field];
282
- if (_operation === "update" && (rules.immutable || rules.immutableAfterCreate) && !bypass) delete sanitized[field];
287
+ if (_operation === "update" && (rules.immutable || rules.immutableAfterCreate) && !bypass) {
288
+ if (Object.hasOwn(sanitized, field)) immutableAttempts.push(field);
289
+ delete sanitized[field];
290
+ }
283
291
  }
292
+ if (immutableAttempts.length > 0 && this.onImmutableWrite === "reject") throw new ForbiddenError(`Cannot modify immutable field${immutableAttempts.length === 1 ? "" : "s"}: ${immutableAttempts.join(", ")}`);
284
293
  if (req) {
285
294
  const arcContext = meta ?? req.metadata;
286
295
  const scope = arcContext?._scope ?? PUBLIC_SCOPE;
@@ -363,6 +372,73 @@ function buildTenantRepoOptions(req, tenantField, arcContext) {
363
372
  return out;
364
373
  }
365
374
  /**
375
+ * Marks a write method ARC installed.
376
+ *
377
+ * The constructor binds `create`/`update`/`delete`, so EVERY controller owns
378
+ * all three and the bound function is a fresh object per instance —
379
+ * `Object.hasOwn` and identity comparison both say nothing about overrides.
380
+ * Provenance does: a class field initialises after `super()` and REPLACES the
381
+ * bound method, mark and all.
382
+ *
383
+ * `Symbol.for` so it still matches across two arc copies in one graph.
384
+ */
385
+ const ARC_BOUND_WRITE = Symbol.for("arc.boundWriteMethod");
386
+ /**
387
+ * Marks a controller PROTOTYPE whose write methods dispatch declared verbs.
388
+ * `defineResource` refuses `writes` without it: a duck-typed `configure()`
389
+ * proves an options channel exists, not that anything dispatches from it.
390
+ * Lives on the prototype — one mark per class, zero per-instance cost.
391
+ */
392
+ const ARC_WRITE_VERB_CAPABLE = Symbol.for("arc.writeVerbCapable");
393
+ /** Tag a bound write method as arc's own. Returns the same function. */
394
+ function markArcBoundWrite(fn) {
395
+ Object.defineProperty(fn, ARC_BOUND_WRITE, {
396
+ value: true,
397
+ enumerable: false,
398
+ configurable: true
399
+ });
400
+ return fn;
401
+ }
402
+ /** Did arc install this write method, as opposed to the host replacing it? */
403
+ function isArcBoundWrite(fn) {
404
+ return typeof fn === "function" && Reflect.get(fn, ARC_BOUND_WRITE) === true;
405
+ }
406
+ /** Tag a controller class's prototype as write-verb capable. */
407
+ function markWriteVerbCapable(prototype) {
408
+ Object.defineProperty(prototype, ARC_WRITE_VERB_CAPABLE, {
409
+ value: true,
410
+ enumerable: false,
411
+ configurable: true
412
+ });
413
+ }
414
+ /** Do this controller's write methods dispatch declared write verbs? */
415
+ function isWriteVerbCapable(controller) {
416
+ return typeof controller === "object" && controller !== null && Reflect.get(controller, ARC_WRITE_VERB_CAPABLE) === true;
417
+ }
418
+ /**
419
+ * Which of `ops` did the host override?
420
+ *
421
+ * THE single implementation — the boot-fatal reachability check and the
422
+ * field-protection warn both consume it, so they cannot disagree.
423
+ *
424
+ * Two shapes: a prototype method (found by walking to the capability-marked
425
+ * prototype — every layer between is host-authored) and a class field / ctor
426
+ * assignment (found by provenance, since arc owns an own-property for all
427
+ * three regardless). Arc's own mixins define no write methods; a test pins it.
428
+ */
429
+ function detectOverriddenWriteSlots(controller, ops) {
430
+ const instance = controller;
431
+ return ops.filter((op) => {
432
+ if (typeof instance[op] === "function" && !isArcBoundWrite(instance[op])) return true;
433
+ let proto = Object.getPrototypeOf(controller);
434
+ while (proto && !Object.hasOwn(proto, ARC_WRITE_VERB_CAPABLE)) {
435
+ if (Object.hasOwn(proto, op)) return true;
436
+ proto = Object.getPrototypeOf(proto);
437
+ }
438
+ return false;
439
+ });
440
+ }
441
+ /**
366
442
  * Resolve the repository primary key for mutation calls.
367
443
  *
368
444
  * When the resource declares a custom `idField` (slug, jobId, UUID), the
@@ -634,23 +710,44 @@ const log = arcLog("query");
634
710
  */
635
711
  let notedDeniedJoins = false;
636
712
  function noteDeniedJoins(kind) {
637
- if (notedDeniedJoins || process.env.NODE_ENV === "production") return;
713
+ if (notedDeniedJoins || isProductionEnv(process.env.NODE_ENV)) return;
638
714
  notedDeniedJoins = true;
639
715
  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)`);
640
716
  }
641
717
  var QueryResolver = class {
642
718
  queryParser;
643
719
  maxLimit;
720
+ /** Set only when the HOST passed one — it outranks any parser's cap, at any time. */
721
+ explicitMaxLimit;
644
722
  defaultLimit;
645
723
  /** `undefined` means "no default sort" (caller passed `false`). */
646
724
  defaultSort;
725
+ explicitDefaultSort;
647
726
  schemaOptions;
648
727
  tenantField;
649
728
  constructor(config = {}) {
650
729
  this.queryParser = config.queryParser ?? getDefaultQueryParser();
651
- this.maxLimit = config.maxLimit ?? 100;
730
+ /**
731
+ * Precedence: explicit config → the PARSER's own cap → framework default.
732
+ *
733
+ * The middle term is the fix. A resource that writes
734
+ * `new QueryParser({ maxLimit: 1000 })` has already answered "how large may a page
735
+ * be?", and arc previously ignored that and applied 100 anyway. Three layers each
736
+ * capping independently, lowest wins, no signal — a chart-of-accounts picker
737
+ * returned 100 of 696 rows and rendered "No accounts found", while the resource
738
+ * AND the repository were both configured for 1000.
739
+ *
740
+ * Note it reads `config.queryParser`, NOT `this.queryParser`. The fallback default
741
+ * parser declares its own generous cap, so deferring to it would silently raise the
742
+ * ceiling from 100 to 1000 on every endpoint that never configured one — a widening
743
+ * nobody asked for. Only a parser the resource EXPLICITLY supplied counts as an
744
+ * answer; a test pins exactly this.
745
+ */
746
+ this.explicitMaxLimit = config.maxLimit;
747
+ this.maxLimit = config.maxLimit ?? config.queryParser?.maxLimit ?? 100;
652
748
  this.defaultLimit = config.defaultLimit ?? 20;
653
749
  this.defaultSort = config.defaultSort === false ? void 0 : config.defaultSort ?? "-createdAt";
750
+ this.explicitDefaultSort = config.defaultSort !== void 0 && config.defaultSort !== false;
654
751
  this.schemaOptions = config.schemaOptions ?? {};
655
752
  this.tenantField = config.tenantField !== void 0 ? config.tenantField : DEFAULT_TENANT_FIELD;
656
753
  }
@@ -662,6 +759,42 @@ var QueryResolver = class {
662
759
  */
663
760
  setParser(parser) {
664
761
  this.queryParser = parser;
762
+ /**
763
+ * The swapped-in parser's cap applies too — swapping is how most resources supply
764
+ * theirs.
765
+ *
766
+ * Deferring only in the constructor was not enough: `setQueryParser()` mutates the
767
+ * resolver in place so captured references stay valid, so a resource that declares
768
+ * `new QueryParser({ maxLimit: 1000 })` arrives HERE, not through `config`. The
769
+ * constructor-only version of this fix looked right, passed its unit tests, and
770
+ * changed nothing at runtime — the chart of accounts still served 100 of 696.
771
+ *
772
+ * An explicit `config.maxLimit` still wins; that is a host decision and outranks a
773
+ * package default either way.
774
+ */
775
+ if (this.explicitMaxLimit === void 0 && parser.maxLimit !== void 0) this.maxLimit = parser.maxLimit;
776
+ }
777
+ /**
778
+ * The default sort, but only when the parser permits the field.
779
+ *
780
+ * A resource declaring `allowedSortFields` has stated exactly what may be
781
+ * sorted on. Arc then applied its OWN default of `-createdAt` on top, and
782
+ * mongokit 3.31 REJECTS a sort outside the allowlist rather than dropping it —
783
+ * so every list call on such a resource answered 400, `Blocked sort field not
784
+ * in allowlist: createdAt`. REST and MCP alike; the endpoint was unusable, and
785
+ * nothing the caller sent could avoid it.
786
+ *
787
+ * A general default must never outrank a specific declaration, so the
788
+ * FRAMEWORK default withdraws. A HOST-declared `defaultSort` is left alone —
789
+ * that contradiction is its author's to resolve, and `defineResource` reports
790
+ * it as a boot diagnostic instead of arc silently picking a winner.
791
+ */
792
+ permittedDefaultSort() {
793
+ const sort = this.defaultSort;
794
+ if (sort === void 0 || this.explicitDefaultSort) return sort;
795
+ const allowed = this.queryParser.allowedSortFields;
796
+ if (!allowed || allowed.length === 0) return sort;
797
+ return sort.split(",").map((s) => s.trim().replace(/^-/, "")).filter(Boolean).every((f) => allowed.includes(f)) ? sort : void 0;
665
798
  }
666
799
  /**
667
800
  * Resolve a request into parsed query options -- ONE parse per request.
@@ -673,7 +806,7 @@ var QueryResolver = class {
673
806
  delete parsed.filters?._policyFilters;
674
807
  const limit = Math.min(Math.max(1, parsed.limit || this.defaultLimit), this.maxLimit);
675
808
  const page = parsed.after ? void 0 : parsed.page ? Math.max(1, parsed.page) : 1;
676
- const sortString = parsed.sort ? Object.entries(parsed.sort).map(([k, v]) => v === -1 ? `-${k}` : k).join(",") : this.defaultSort;
809
+ const sortString = parsed.sort ? Object.entries(parsed.sort).map(([k, v]) => v === -1 ? `-${k}` : k).join(",") : this.permittedDefaultSort();
677
810
  const rawSelect = parsed.select ?? req.query?.select;
678
811
  const policyFilters = arcContext?._policyFilters;
679
812
  let filters = conjoinPolicyFilters(parsed.filters, policyFilters);
@@ -838,11 +971,38 @@ var BaseCrudController = class {
838
971
  */
839
972
  _defaultSort;
840
973
  _onFieldWriteDenied;
974
+ _onImmutableWrite;
975
+ /**
976
+ * Domain commands bound to the write slots (`ResourceWrites`). Consulted at
977
+ * the PERSISTENCE step only — everything upstream of it (sanitizer, tenant
978
+ * injection, actor stamp, hook sandwich) runs exactly as it does without
979
+ * one, which is the entire point of the seam.
980
+ */
981
+ _writes;
982
+ /** Transactional write envelope — see `ResourceConfig.transactional`. */
983
+ _transactional = false;
841
984
  constructor(repository, options = {}) {
842
985
  this.repository = repository;
843
986
  this.schemaOptions = options.schemaOptions ?? {};
844
987
  this.queryParser = options.queryParser ?? getDefaultQueryParser();
845
- this.maxLimit = options.maxLimit ?? 100;
988
+ /**
989
+ * The parser's cap is consulted BEFORE the framework default.
990
+ *
991
+ * A resource that hands us a parser has already stated how large a page it
992
+ * can serve — a bounded catalog like a chart of accounts declares 1000 so
993
+ * pickers can read it whole. Defaulting straight to 100 here silently
994
+ * overrode that: the resource asked for 1000, `QueryResolver` received a
995
+ * `maxLimit` of 100 that was indistinguishable from a host deliberately
996
+ * choosing 100, and the list came back truncated with a `200`. be-prod
997
+ * served 100 of 696 accounts that way, and every account picker in the
998
+ * product was short by 85% with nothing anywhere to report it.
999
+ *
1000
+ * Order matters and is the whole point: an explicit `options.maxLimit` from
1001
+ * the host still wins over the parser, and the parser wins over this
1002
+ * framework floor. A general default must only ever fill a gap — never
1003
+ * outrank something specific that was actually stated.
1004
+ */
1005
+ this.maxLimit = options.maxLimit ?? this.queryParser?.maxLimit ?? 100;
846
1006
  this.defaultLimit = options.defaultLimit ?? 20;
847
1007
  this.resourceName = options.resourceName;
848
1008
  this.tenantField = options.tenantField !== void 0 ? options.tenantField : DEFAULT_TENANT_FIELD;
@@ -852,6 +1012,9 @@ var BaseCrudController = class {
852
1012
  if (options.presetFields) this._presetFields = options.presetFields;
853
1013
  this._defaultSort = options.defaultSort;
854
1014
  this._onFieldWriteDenied = options.onFieldWriteDenied;
1015
+ this._onImmutableWrite = options.onImmutableWrite;
1016
+ this._writes = options.writes;
1017
+ this._transactional = options.transactional === true;
855
1018
  this.accessControl = new AccessControl({
856
1019
  tenantField: this.tenantField,
857
1020
  idField: this.idField,
@@ -859,7 +1022,8 @@ var BaseCrudController = class {
859
1022
  });
860
1023
  this.bodySanitizer = new BodySanitizer({
861
1024
  schemaOptions: this.schemaOptions,
862
- onFieldWriteDenied: this._onFieldWriteDenied
1025
+ onFieldWriteDenied: this._onFieldWriteDenied,
1026
+ onImmutableWrite: this._onImmutableWrite
863
1027
  });
864
1028
  this.queryResolver = new QueryResolver({
865
1029
  queryParser: this.queryParser,
@@ -871,9 +1035,21 @@ var BaseCrudController = class {
871
1035
  });
872
1036
  this.list = this.list.bind(this);
873
1037
  this.get = this.get.bind(this);
874
- this.create = this.create.bind(this);
875
- this.update = this.update.bind(this);
876
- this.delete = this.delete.bind(this);
1038
+ /**
1039
+ * The write methods are bound here, which means EVERY instance carries an
1040
+ * own `create`/`update`/`delete` property — so "does it have an own
1041
+ * property" cannot distinguish arc's binding from a host's override.
1042
+ *
1043
+ * Marking what arc installed makes the difference observable. A class-field
1044
+ * override (`update = async (req) => …`) initialises AFTER `super()`
1045
+ * returns and therefore REPLACES this bound method outright — the mark goes
1046
+ * with it, which is exactly the signal `warnOnWriteMethodOverride` needs.
1047
+ * A prototype-method override binds through here and stays marked; that
1048
+ * shape is caught by the prototype comparison instead.
1049
+ */
1050
+ this.create = markArcBoundWrite(this.create.bind(this));
1051
+ this.update = markArcBoundWrite(this.update.bind(this));
1052
+ this.delete = markArcBoundWrite(this.delete.bind(this));
877
1053
  }
878
1054
  /**
879
1055
  * Swap the controller's query parser. Mutates the existing `QueryResolver`
@@ -939,6 +1115,10 @@ var BaseCrudController = class {
939
1115
  rebuildBodySanitizer = true;
940
1116
  rebuildQueryResolver = true;
941
1117
  }
1118
+ if (options.onImmutableWrite !== void 0) {
1119
+ this._onImmutableWrite = options.onImmutableWrite;
1120
+ rebuildBodySanitizer = true;
1121
+ }
942
1122
  if (options.onFieldWriteDenied !== void 0) {
943
1123
  this._onFieldWriteDenied = options.onFieldWriteDenied;
944
1124
  rebuildBodySanitizer = true;
@@ -958,6 +1138,8 @@ var BaseCrudController = class {
958
1138
  }
959
1139
  if (options.cache !== void 0) this._cacheConfig = options.cache;
960
1140
  if (options.presetFields !== void 0) this._presetFields = options.presetFields;
1141
+ if (options.writes !== void 0) this._writes = options.writes;
1142
+ if (options.transactional !== void 0) this._transactional = options.transactional === true;
961
1143
  if (rebuildAccessControl) this.accessControl = new AccessControl({
962
1144
  tenantField: this.tenantField,
963
1145
  idField: this.idField,
@@ -965,7 +1147,8 @@ var BaseCrudController = class {
965
1147
  });
966
1148
  if (rebuildBodySanitizer) this.bodySanitizer = new BodySanitizer({
967
1149
  schemaOptions: this.schemaOptions,
968
- onFieldWriteDenied: this._onFieldWriteDenied
1150
+ onFieldWriteDenied: this._onFieldWriteDenied,
1151
+ onImmutableWrite: this._onImmutableWrite
969
1152
  });
970
1153
  if (rebuildQueryResolver) this.queryResolver = new QueryResolver({
971
1154
  queryParser: this.queryParser,
@@ -1105,6 +1288,52 @@ var BaseCrudController = class {
1105
1288
  return executeHookedOp(this.hookOpContext(req), args, executor);
1106
1289
  }
1107
1290
  /**
1291
+ * Run a write's PERSISTENCE step, transactionally when the resource asked.
1292
+ *
1293
+ * Plain mode hands `fn` the live repository. Transactional mode runs it
1294
+ * through repo-core's `retryingTransaction`: the callback receives the
1295
+ * TX-BOUND repository (so declared verbs and repository calls join the same
1296
+ * transaction), transient conflicts re-run `fn` with jittered bounded
1297
+ * backoff, everything else — including `VersionConflictError` — surfaces
1298
+ * once. Hooks stay OUTSIDE: before/around ran already and must not re-run;
1299
+ * after-hooks run post-commit, which they already do by running after this.
1300
+ */
1301
+ runWritePersistence(fn) {
1302
+ if (!this._transactional) return fn(this.repository);
1303
+ return retryingTransaction(this.repository, (txRepo, uow) => fn(txRepo, uow));
1304
+ }
1305
+ /** Assemble the `WriteContext` a `create` verb receives. */
1306
+ writeContext(req, repo = this.repository, uow) {
1307
+ return {
1308
+ req,
1309
+ repository: repo,
1310
+ ...uow !== void 0 ? { uow } : {}
1311
+ };
1312
+ }
1313
+ /**
1314
+ * Assemble the `MutationWriteContext` an `update` / `delete` verb receives.
1315
+ *
1316
+ * `existing` is the document arc ALREADY loaded to run its permission and
1317
+ * tenant checks — `loadMutableTarget` throws before any verb runs when it
1318
+ * cannot produce one, which is why the context types it required. Handing
1319
+ * it over rather than letting the verb re-fetch is deliberate: a second
1320
+ * read can observe a different document than the one the request was
1321
+ * authorised against.
1322
+ *
1323
+ * `id` is the REPOSITORY PRIMARY KEY (`repoId`), never the raw route param —
1324
+ * see the note on `MutationWriteContext.id`. Callers pass `resolveRepoId`'s
1325
+ * output.
1326
+ */
1327
+ mutationWriteContext(req, id, existing, repo = this.repository, uow) {
1328
+ return {
1329
+ req,
1330
+ repository: repo,
1331
+ id,
1332
+ existing,
1333
+ ...uow !== void 0 ? { uow } : {}
1334
+ };
1335
+ }
1336
+ /**
1108
1337
  * Run `executeAfter` for the given op + data. No-op when hooks aren't
1109
1338
  * wired or `resourceName` isn't set. Caller passes the data shape it
1110
1339
  * wants downstream after-handlers to receive — typically the result for
@@ -1340,11 +1569,21 @@ var BaseCrudController = class {
1340
1569
  const item = await this.runHookedOpUntilResult(req, {
1341
1570
  op: "create",
1342
1571
  input: data
1343
- }, async (processed) => this.repository.create(processed, {
1572
+ }, async (processed) => this.runWritePersistence((repo, uow) => this._writes?.create ? this._writes.create(processed, this.writeContext(req, repo, uow)) : repo.create(processed, {
1344
1573
  user,
1345
1574
  context: arcContext,
1346
1575
  ...this.tenantRepoOptions(req)
1347
- }));
1576
+ })));
1577
+ /**
1578
+ * A create COMMAND must return the created document — enforced, because
1579
+ * the alternative is quietly worse than any failure: a `201` carrying
1580
+ * `undefined` data, after-hooks (audit, events, cache invalidation) fed a
1581
+ * non-document, and a client that believes the write happened with no way
1582
+ * to address what it created. TypeScript types the contract, but arc is a
1583
+ * JavaScript runtime framework and a JS command returning nothing is one
1584
+ * missing `return` away.
1585
+ */
1586
+ if (this._writes?.create && item == null) throw createError(500, `writes.create for resource "${this.resourceName ?? "unknown"}" returned ${item === null ? "null" : "undefined"} — a create command must return the created document. Signal failure by throwing a typed error; a nullish return would otherwise answer 201 with no document.`, { code: "WRITE_VERB_CONTRACT_VIOLATION" });
1348
1587
  await this.runAfterHook(req, "create", item);
1349
1588
  return {
1350
1589
  data: item,
@@ -1367,15 +1606,27 @@ var BaseCrudController = class {
1367
1606
  op: "update",
1368
1607
  input: data,
1369
1608
  meta: hookMeta
1370
- }, async (processed) => this.repository.update(repoId, processed, {
1609
+ }, async (processed) => this.runWritePersistence((repo, uow) => this._writes?.update ? this._writes.update(repoId, processed, this.mutationWriteContext(req, repoId, existing, repo, uow)) : repo.update(repoId, processed, {
1371
1610
  user,
1372
1611
  context: arcContext,
1373
1612
  ...this.tenantRepoOptions(req)
1374
- }));
1375
- if (!item) this.throwNotFound("NOT_FOUND");
1376
- await this.runAfterHook(req, "update", item, hookMeta);
1613
+ })));
1614
+ /**
1615
+ * A declared verb signals "not found" by THROWING, so reaching this line
1616
+ * means the command succeeded. Translating its `void` return into a 404
1617
+ * would report failure for a write that happened — the repository's
1618
+ * `null`-means-miss contract is the repository's, not a domain command's.
1619
+ * Re-read so the response still carries the document.
1620
+ */
1621
+ const resolved = this._writes?.update ? item ?? await this.repository.getById(repoId, {
1622
+ user,
1623
+ context: arcContext,
1624
+ ...this.tenantRepoOptions(req)
1625
+ }) : item;
1626
+ if (!resolved) this.throwNotFound("NOT_FOUND");
1627
+ await this.runAfterHook(req, "update", resolved, hookMeta);
1377
1628
  return {
1378
- data: item,
1629
+ data: resolved,
1379
1630
  status: 200,
1380
1631
  meta: { message: "Updated successfully" }
1381
1632
  };
@@ -1391,15 +1642,15 @@ var BaseCrudController = class {
1391
1642
  input: existing,
1392
1643
  meta: hookMeta,
1393
1644
  pipeProcessedData: false
1394
- }, async () => this.repository.delete(repoId, {
1645
+ }, async () => this.runWritePersistence((repo, uow) => this._writes?.delete ? this._writes.delete(repoId, this.mutationWriteContext(req, repoId, existing, repo, uow)) : repo.delete(repoId, {
1395
1646
  user,
1396
1647
  context: arcContext,
1397
1648
  ...this.tenantRepoOptions(req),
1398
1649
  ...deleteMode ? { mode: deleteMode } : {}
1399
- }));
1400
- if (!result) this.throwNotFound("NOT_FOUND");
1650
+ })));
1651
+ if (!result && !this._writes?.delete) this.throwNotFound("NOT_FOUND");
1401
1652
  await this.runAfterHook(req, "delete", existing, hookMeta);
1402
- const deleteResult = result;
1653
+ const deleteResult = result ?? {};
1403
1654
  return {
1404
1655
  data: {
1405
1656
  message: deleteResult.message || "Deleted successfully",
@@ -1410,6 +1661,15 @@ var BaseCrudController = class {
1410
1661
  };
1411
1662
  }
1412
1663
  };
1664
+ /**
1665
+ * This class's write methods dispatch declared write verbs (`_writes`), so
1666
+ * `defineResource` may accept a `writes` declaration for controllers built on
1667
+ * it. One mark on the prototype — inherited by every subclass and mixin
1668
+ * composition, zero per-instance cost. A duck-typed `configure()` is NOT this
1669
+ * proof: it shows an options channel exists, not that anything dispatches
1670
+ * `writes` out of it.
1671
+ */
1672
+ markWriteVerbCapable(BaseCrudController.prototype);
1413
1673
  //#endregion
1414
1674
  //#region src/core/mixins/bulk.ts
1415
1675
  /**
@@ -1752,4 +2012,4 @@ function TreeMixin(Base) {
1752
2012
  */
1753
2013
  var BaseController = class extends SoftDeleteMixin(TreeMixin(SlugMixin(BulkMixin(BaseCrudController)))) {};
1754
2014
  //#endregion
1755
- export { BulkMixin as a, collectReadBlockedFields as c, DEFAULT_FIELD_WRITE_DENIAL_POLICY as d, AccessControl as f, SlugMixin as i, isFieldReadable as l, TreeMixin as n, BaseCrudController as o, SoftDeleteMixin as r, QueryResolver as s, BaseController as t, BodySanitizer as u };
2015
+ export { BulkMixin as a, collectReadBlockedFields as c, isWriteVerbCapable as d, BodySanitizer as f, SlugMixin as i, isFieldReadable as l, AccessControl as m, TreeMixin as n, BaseCrudController as o, DEFAULT_FIELD_WRITE_DENIAL_POLICY as p, SoftDeleteMixin as r, QueryResolver as s, BaseController as t, detectOverriddenWriteSlots as u };