@classytic/arc 2.37.0 → 2.37.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 (162) hide show
  1. package/dist/{BaseController-B5GZfUBy.mjs → BaseController-Chkf0Aiw.mjs} +16 -15
  2. package/dist/{EventTransport-pGNNDQ__.mjs → EventTransport-CFdtK25P.mjs} +1 -1
  3. package/dist/{HookSystem-CuhHvcyZ.mjs → HookSystem-Csy31WS-.mjs} +2 -2
  4. package/dist/{ResourceRegistry-DFOmazXg.mjs → ResourceRegistry-OQA13slI.mjs} +2 -2
  5. package/dist/{actionPermissions-CQQrR4tu.mjs → actionPermissions-DGGqqSIV.mjs} +6 -6
  6. package/dist/audit/index.d.mts +1 -1
  7. package/dist/audit/index.mjs +2 -2
  8. package/dist/auth/audit.mjs +1 -1
  9. package/dist/auth/index.d.mts +1 -1
  10. package/dist/auth/index.mjs +79 -28
  11. package/dist/auth/redis-session.mjs +1 -1
  12. package/dist/{authorizationDecision-DgyWdav8.mjs → authorizationDecision-BzZi2bsf.mjs} +2 -2
  13. package/dist/{buildHandler-D2DkqtiY.mjs → buildHandler-CIz0NsGS.mjs} +4 -4
  14. package/dist/cache/index.mjs +4 -4
  15. package/dist/{caching-BmC2T-pX.mjs → caching-DldVgnC9.mjs} +2 -2
  16. package/dist/cleanup/index.d.mts +1 -1
  17. package/dist/cleanup/index.mjs +4 -4
  18. package/dist/cli/commands/describe.d.mts +1 -1
  19. package/dist/cli/commands/describe.mjs +3 -3
  20. package/dist/cli/commands/docs.mjs +3 -3
  21. package/dist/cli/commands/doctor.mjs +1 -1
  22. package/dist/cli/commands/generate.mjs +2 -2
  23. package/dist/cli/commands/init.mjs +2 -2
  24. package/dist/cli/commands/introspect.mjs +2 -2
  25. package/dist/cli/index.mjs +3 -3
  26. package/dist/{constants-CUpamQMJ.mjs → constants-DDKf25s4.mjs} +1 -1
  27. package/dist/context/index.mjs +3 -3
  28. package/dist/core/index.d.mts +2 -2
  29. package/dist/core/index.mjs +7 -7
  30. package/dist/{core-C8GWw-Iu.mjs → core-uHQonaHf.mjs} +3 -3
  31. package/dist/{createActionRouter-CwDU_j2S.mjs → createActionRouter-C22Xn11N.mjs} +7 -7
  32. package/dist/{createAggregationRouter-d057xXX1.mjs → createAggregationRouter-DgyksLHH.mjs} +7 -7
  33. package/dist/{defineAggregation-cCitY2FA.mjs → defineAggregation-B1m-45Fe.mjs} +1 -1
  34. package/dist/{defineResource-WcbE1kJL.mjs → defineResource-DS3Uj2-8.mjs} +18 -18
  35. package/dist/discovery/index.mjs +1 -1
  36. package/dist/docs/index.d.mts +1 -1
  37. package/dist/docs/index.mjs +3 -3
  38. package/dist/{elevation-Bc6Gafv9.mjs → elevation-9X0lGzTQ.mjs} +4 -4
  39. package/dist/encryption/index.mjs +3 -3
  40. package/dist/{errorHandler-CB75oY5i.mjs → errorHandler-BmEGKyO9.mjs} +4 -4
  41. package/dist/{errors-DsdZc1Xv.mjs → errors-CbxCF9M0.mjs} +1 -1
  42. package/dist/{eventPlugin-DEIfV1iC.mjs → eventPlugin-B1ahehZH.mjs} +5 -5
  43. package/dist/events/index.mjs +5 -5
  44. package/dist/events/transports/redis-stream-entry.mjs +1 -1
  45. package/dist/events/transports/redis.mjs +1 -1
  46. package/dist/factory/index.d.mts +2 -2
  47. package/dist/factory/index.mjs +5 -5
  48. package/dist/{fastify-augmentation-CdY8FINU.d.mts → fastify-augmentation-CRsNNhuo.d.mts} +1 -1
  49. package/dist/{fields-DvGz47Ul.mjs → fields-f_MdWDl_.mjs} +1 -1
  50. package/dist/{generate-D7CIIWhS.mjs → generate-DxWn-XnM.mjs} +2 -2
  51. package/dist/{headers-BFu1st_i.mjs → headers-DivLPMHe.mjs} +2 -2
  52. package/dist/{health-Cc_sb94d.mjs → health-W_BjgWf0.mjs} +1 -1
  53. package/dist/hooks/index.d.mts +1 -1
  54. package/dist/hooks/index.mjs +2 -2
  55. package/dist/idempotency/index.mjs +4 -4
  56. package/dist/idempotency/redis.mjs +1 -1
  57. package/dist/{index-C01wWGJt.d.mts → index-BqRfP1kT.d.mts} +10 -1
  58. package/dist/{index-du18bJjH.d.mts → index-CEmPspAB.d.mts} +2 -4
  59. package/dist/index.d.mts +3 -3
  60. package/dist/index.mjs +11 -11
  61. package/dist/{init-BMgU1wYR.mjs → init-BJ6fdYnp.mjs} +1 -1
  62. package/dist/integrations/event-gateway.mjs +2 -2
  63. package/dist/integrations/index.d.mts +1 -1
  64. package/dist/integrations/index.mjs +1 -1
  65. package/dist/integrations/jobs/index.mjs +2 -2
  66. package/dist/integrations/mcp/index.d.mts +2 -2
  67. package/dist/integrations/mcp/index.mjs +2 -2
  68. package/dist/integrations/mcp/testing.d.mts +1 -1
  69. package/dist/integrations/mcp/testing.mjs +2 -2
  70. package/dist/integrations/streamline.mjs +3 -3
  71. package/dist/integrations/webhooks.mjs +3 -3
  72. package/dist/integrations/websocket-pushref-redis.mjs +1 -1
  73. package/dist/integrations/websocket-redis.mjs +1 -1
  74. package/dist/integrations/websocket.mjs +1 -1
  75. package/dist/{interface-CaPVHoVJ.mjs → interface-DeLndJ8G.mjs} +1 -1
  76. package/dist/{jsonBody-BuzxOZV8.mjs → jsonBody-kApktjTc.mjs} +2 -2
  77. package/dist/{keys-DoUI9-yP.mjs → keys-B1MgfDWi.mjs} +1 -1
  78. package/dist/{loadResources-CPlRWJCv.mjs → loadResources-ma-Aaxp1.mjs} +2 -2
  79. package/dist/{loadResourcesFromEntry-CPCZiX9n.mjs → loadResourcesFromEntry-xszlyXTs.mjs} +2 -2
  80. package/dist/logger/index.mjs +1 -1
  81. package/dist/{mcpPlugin-dxMxBPIg.mjs → mcpPlugin-97HuFA46.mjs} +15 -15
  82. package/dist/{memory-BnafVem_.mjs → memory-DvASzOn3.mjs} +2 -2
  83. package/dist/{metrics-CeAIcVMY.mjs → metrics-Dx-nkDE5.mjs} +2 -2
  84. package/dist/middleware/index.d.mts +1 -1
  85. package/dist/middleware/index.mjs +3 -3
  86. package/dist/migrations/index.mjs +2 -2
  87. package/dist/{module-DIvG8qOg.mjs → module-CNKk_twm.mjs} +1 -1
  88. package/dist/{multipartBody-BVCoaTwu.mjs → multipartBody-TDaYOHGZ.mjs} +1 -1
  89. package/dist/{openapi-D_i6CifU.mjs → openapi-473gZ-8x.mjs} +6 -6
  90. package/dist/outbox-admin/index.mjs +4 -4
  91. package/dist/{outbox-module-CKaLL5BH.mjs → outbox-module-DOAmyXOb.mjs} +4 -4
  92. package/dist/permissions/index.mjs +5 -5
  93. package/dist/{permissions-DWkl60hx.mjs → permissions-DnAc6zoZ.mjs} +6 -6
  94. package/dist/{pipe-Dbs3GhOZ.mjs → pipe-BoV-z4db.mjs} +2 -2
  95. package/dist/pipeline/index.d.mts +1 -1
  96. package/dist/pipeline/index.mjs +2 -2
  97. package/dist/plugins/index.d.mts +2 -2
  98. package/dist/plugins/index.mjs +16 -16
  99. package/dist/plugins/response-cache.mjs +2 -2
  100. package/dist/plugins/tracing-entry.mjs +2 -2
  101. package/dist/{pluralize-DgaYX91M.mjs → pluralize-BiU5ocWw.mjs} +1 -1
  102. package/dist/{preloadResources-BYFfYnjZ.d.mts → preloadResources-DHkyk3QN.d.mts} +1 -1
  103. package/dist/{preloadResources-BUoGKPaS.mjs → preloadResources-DJ7Lli0a.mjs} +21 -21
  104. package/dist/presets/filesUpload.d.mts +1 -1
  105. package/dist/presets/filesUpload.mjs +5 -5
  106. package/dist/presets/index.d.mts +1 -1
  107. package/dist/presets/index.mjs +2 -2
  108. package/dist/presets/multiTenant.d.mts +1 -1
  109. package/dist/presets/multiTenant.mjs +3 -3
  110. package/dist/presets/search.d.mts +1 -1
  111. package/dist/presets/search.mjs +2 -2
  112. package/dist/{presets-Gbv9mNKD.mjs → presets-BMxH-Bb7.mjs} +4 -4
  113. package/dist/{projection-BunxoeON.mjs → projection-CRCM5S9T.mjs} +2 -2
  114. package/dist/{queryCachePlugin-BYqmg89O.mjs → queryCachePlugin-Cl3zLG7g.mjs} +5 -5
  115. package/dist/{rateLimitKey-DlqmDHA5.mjs → rateLimitKey-D4_rjP1K.mjs} +1 -1
  116. package/dist/{realtime-CiAp2-LH.mjs → realtime-BWuHi-UI.mjs} +9 -9
  117. package/dist/{registerRoute-dnf-JJf-.mjs → registerRoute-mvm6zY2H.mjs} +5 -5
  118. package/dist/registry/index.d.mts +2 -2
  119. package/dist/registry/index.mjs +3 -3
  120. package/dist/{registry-ByC0UFNS.mjs → registry-3maEt__c.mjs} +2 -2
  121. package/dist/{renewingLease-C5Ta_ImD.mjs → renewingLease-C1-GAUpr.mjs} +1 -1
  122. package/dist/{replyHelpers-D1k0wHoO.mjs → replyHelpers-DVcUoled.mjs} +2 -2
  123. package/dist/{requestContext-DGM7EZRI.mjs → requestContext-KzIdSmN2.mjs} +1 -1
  124. package/dist/{rolldown-runtime-DrZrabOW.mjs → rolldown-runtime-NVzJrC-o.mjs} +1 -1
  125. package/dist/{runtimeCapabilities-c4Sc85X-.mjs → runtimeCapabilities-OrGatf7r.mjs} +1 -1
  126. package/dist/{schedules-CVtolEa-.mjs → schedules-DeRI03eo.mjs} +3 -3
  127. package/dist/{schemaConverter-C8zbW7tJ.mjs → schemaConverter-cXTayNOW.mjs} +1 -1
  128. package/dist/{schemaIR-BfI425RH.mjs → schemaIR-Cc9ZPVs2.mjs} +2 -2
  129. package/dist/schemas/index.mjs +1 -1
  130. package/dist/scim/index.mjs +2 -2
  131. package/dist/scope/index.mjs +5 -5
  132. package/dist/{scope-DFSOKc4T.mjs → scope-C2r7_WGs.mjs} +4 -4
  133. package/dist/{simpleEqualityMatcher-Bhp7WrRF.mjs → simpleEqualityMatcher-Cr1F8jKZ.mjs} +1 -1
  134. package/dist/{sse-vRuwDd6N.mjs → sse-BmZCVRGH.mjs} +5 -5
  135. package/dist/{sseStream-cWezGOk1.mjs → sseStream-nNPqvOeK.mjs} +2 -2
  136. package/dist/{store-helpers-bU8H5c-A.mjs → store-helpers-C1r3-6M0.mjs} +1 -1
  137. package/dist/{streaming-weWcFCk4.mjs → streaming-BxZDlH01.mjs} +1 -1
  138. package/dist/{subscribe-helpers-BrcG3NlV.mjs → subscribe-helpers-Dca7Fcz2.mjs} +2 -2
  139. package/dist/sync/changelog.mjs +1 -1
  140. package/dist/testing/cleanupStoreContract.mjs +1 -1
  141. package/dist/testing/index.d.mts +2 -2
  142. package/dist/testing/index.mjs +10 -10
  143. package/dist/testing/outboxStoreContract.mjs +1 -1
  144. package/dist/testing/storageContract.mjs +1 -1
  145. package/dist/{transactionContext-Ca5peQpc.mjs → transactionContext-6F7Gkbxg.mjs} +1 -1
  146. package/dist/{typeGuards-ALWme5WB.mjs → typeGuards-BrXgbFi-.mjs} +1 -1
  147. package/dist/types/index.d.mts +1 -1
  148. package/dist/types/index.mjs +1 -1
  149. package/dist/types/storage.mjs +1 -1
  150. package/dist/{types-lDs9oF4z.mjs → types-khJmBErP.mjs} +4 -4
  151. package/dist/{types-BXnTOsXP.d.mts → types-rzNprqbE.d.mts} +1 -1
  152. package/dist/usage/index.mjs +0 -0
  153. package/dist/{userHelpers-DU_Z5OBA.mjs → userHelpers-Bi97zMEl.mjs} +1 -1
  154. package/dist/utils/index.d.mts +2 -2
  155. package/dist/utils/index.mjs +11 -11
  156. package/dist/{utils-sXGFLegw.mjs → utils-WlLDIKuw.mjs} +1230 -1230
  157. package/dist/{validate-qK_-7MVk.mjs → validate-pMke_boy.mjs} +2 -2
  158. package/dist/{versioning-Dtw0MyX4.mjs → versioning-CFK3c04-.mjs} +3 -3
  159. package/package.json +7 -5
  160. package/skills/arc/references/auth.md +5 -2
  161. package/skills/arc/references/production.md +733 -733
  162. package/skills/arc-code-review/references/migration-recipes.md +16 -7
@@ -1,733 +1,733 @@
1
- # Arc Production Features
2
-
3
- Health checks, audit trail, idempotency, tracing, SSE, caching, graceful shutdown.
4
-
5
- ## v2.9 Security Defaults
6
-
7
- - **Field-write perms reject by default** — `defineResource({ fields, onFieldWriteDenied })`. Default `'reject'` → 403 with denied field list. Legacy silent-strip: `onFieldWriteDenied: 'strip'`.
8
- - **Elevation always emits `arc.scope.elevated`** — subscribe via `fastify.events.subscribe('arc.scope.elevated', handler)` for audit. `onElevation` callback still supported.
9
- - **multiTenant injects org on UPDATE** — closes cross-tenant hop vector. Body-supplied `organizationId` overwritten with caller's scope.
10
- - **`verifySignature(body, secret, sig)` throws TypeError** if body isn't string/Buffer — register `@fastify/raw-body` before webhook routes; pass `req.rawBody`.
11
- - **Upload `sanitizeFilename` (preset)** — strict by default; `false` / `'*'` / custom fn to relax per adapter needs.
12
- - **Idempotency `namespace`** — fold an env key into the fingerprint when multiple deployments share a Redis (prod + canary, api + jobs).
13
-
14
- ## Health Plugin
15
-
16
- Kubernetes-ready liveness/readiness probes:
17
-
18
- ```typescript
19
- import { healthPlugin } from '@classytic/arc/plugins';
20
-
21
- await fastify.register(healthPlugin, {
22
- prefix: '/_health',
23
- version: '1.0.0',
24
- checks: [
25
- { name: 'mongodb', check: () => mongoose.connection.readyState === 1, critical: true },
26
- { name: 'redis', check: async () => await redis.ping() === 'PONG', timeout: 3000 },
27
- ],
28
- });
29
-
30
- // GET /_health/live → { status: 'ok', timestamp, version }
31
- // GET /_health/ready → { status: 'ok', checks: { mongodb: { status: 'ok', latency: 5 } } }
32
- // Returns 503 if critical check fails
33
- ```
34
-
35
- ## Request ID Plugin
36
-
37
- ```typescript
38
- import { requestIdPlugin } from '@classytic/arc/plugins';
39
-
40
- await fastify.register(requestIdPlugin, {
41
- header: 'x-request-id',
42
- setResponseHeader: true,
43
- generator: () => crypto.randomUUID(),
44
- });
45
- ```
46
-
47
- ## Graceful Shutdown Plugin
48
-
49
- ```typescript
50
- import { gracefulShutdownPlugin } from '@classytic/arc/plugins';
51
-
52
- await fastify.register(gracefulShutdownPlugin, {
53
- timeout: 30000,
54
- signals: ['SIGTERM', 'SIGINT'],
55
- logEvents: true,
56
- onShutdown: async () => {
57
- await mongoose.disconnect();
58
- await redis.quit();
59
- },
60
- });
61
-
62
- // Sequence: receive signal → stop accepting connections → wait for in-flight
63
- // → run onShutdown → close Fastify → exit process
64
- ```
65
-
66
- ## Audit Plugin
67
-
68
- Change tracking. Pass a `RepositoryLike` for persistence, or omit for in-memory dev:
69
-
70
- ```typescript
71
- import { auditPlugin } from '@classytic/arc/audit';
72
- import { Repository } from '@classytic/mongokit';
73
-
74
- // Development (in-memory)
75
- await fastify.register(auditPlugin, { enabled: true });
76
-
77
- // Production — any kit (mongokit / prismakit / custom)
78
- await fastify.register(auditPlugin, {
79
- enabled: true,
80
- repository: new Repository(AuditModel),
81
- });
82
- // ── Retention (audit rows ARE `history: true` timelines — prune together) ──
83
- // Option A (zero code, Mongo): TTL index — the DB does it server-side.
84
- // AuditSchema.index({ timestamp: 1 }, { expireAfterSeconds: 60 * 60 * 24 * 365 });
85
- // Option B (built-in, single instance): retention: { maxAgeMs, purgeIntervalMs }.
86
- // Option C (multi-replica, RECOMMENDED): purgeIntervalMs: 0 + a schedulesPlugin
87
- // entry calling fastify.audit.purge(cutoff) — leader-safe, jittered, observable.
88
- // (The built-in timer runs on EVERY replica — idempotent but wasteful.)
89
- //
90
- // ── Indexes (kit territory — the audit model is yours) ──
91
- // Mongo: TTL index above + compound for the history timeline:
92
- // AuditSchema.index({ resource: 1, documentId: 1, timestamp: -1 });
93
- // SQL kits: index (resource, document_id, timestamp) + the kit's ttl/sweep plugin.
94
- //
95
- // ── Rich search (admin audit-log UI) ──
96
- // audit.query() is deliberately narrow. For full repo-core filter/sort/keyset
97
- // power + MCP tools, mount the audit repo AS a read-only resource:
98
- // defineResource({ name: 'audit-log', crud: { list: true, get: true },
99
- // adapter: { repository: auditRepo },
100
- // queryParser: new QueryParser({ allowedFilterFields: ['resource','documentId','userId','action','organizationId'] }),
101
- // permissions: { list: requireRoles(['auditor']), get: requireRoles(['auditor']) } });
102
-
103
- // Usage
104
- await fastify.audit.create('product', product._id, product, request.auditContext);
105
- await fastify.audit.update('product', id, beforeDoc, afterDoc, request.auditContext);
106
- await fastify.audit.delete('product', id, deletedDoc, request.auditContext);
107
- await fastify.audit.custom('product', id, 'price_changed', { oldPrice: 100, newPrice: 150 }, ctx);
108
-
109
- // Query
110
- const entries = await fastify.audit.query({
111
- resource: 'product', documentId: 'prod-123',
112
- action: 'update', from: new Date('2024-01-01'), limit: 100,
113
- });
114
- ```
115
-
116
- **Audit entry:**
117
-
118
- ```typescript
119
- interface AuditEntry {
120
- id: string; timestamp: Date;
121
- action: 'create' | 'update' | 'delete' | 'restore' | 'custom';
122
- resource: string; documentId: string;
123
- before?: Record<string, unknown>;
124
- after?: Record<string, unknown>;
125
- metadata?: Record<string, unknown>;
126
- context: { user?; organizationId?; requestId?; ipAddress?; userAgent?; };
127
- }
128
- ```
129
-
130
- **Custom store:** Implement `AuditStore` interface (`log`, `query`, `close`).
131
-
132
- ## Idempotency Plugin
133
-
134
- Exactly-once semantics for mutating operations:
135
-
136
- ```typescript
137
- import { idempotencyPlugin } from '@classytic/arc/idempotency';
138
-
139
- await fastify.register(idempotencyPlugin, {
140
- enabled: true,
141
- headerName: 'idempotency-key', // Default
142
- ttlMs: 86400000, // 24 hours
143
- lockTimeoutMs: 30000,
144
- methods: ['POST', 'PUT', 'PATCH'],
145
- include: [/\/orders/], // Only these routes
146
- exclude: [/\/health/],
147
- retryAfterSeconds: 1,
148
- });
149
- ```
150
-
151
- **Client:**
152
-
153
- ```typescript
154
- fetch('/api/orders', {
155
- method: 'POST',
156
- headers: { 'Idempotency-Key': 'order-abc123-uuid' },
157
- body: JSON.stringify({ items: [...] }),
158
- });
159
- // First request: processes, caches response
160
- // Retry: returns cached response + x-idempotency-replayed: true
161
- // Concurrent: returns 409 Conflict + Retry-After: 1
162
- ```
163
-
164
- **Storage backends:**
165
-
166
- ```typescript
167
- // Memory (default, dev) — omit both `repository` and `store`
168
- import { MemoryIdempotencyStore } from '@classytic/arc/idempotency';
169
-
170
- // Redis (production, multi-instance)
171
- import { RedisIdempotencyStore } from '@classytic/arc/idempotency/redis';
172
- store: new RedisIdempotencyStore({ client: redis, prefix: 'idem:', ttlMs: 86400000 })
173
-
174
- // DB-backed via RepositoryLike (mongokit / prismakit / custom)
175
- import { Repository, methodRegistryPlugin, batchOperationsPlugin, mongoOperationsPlugin } from '@classytic/mongokit';
176
- repository: new Repository(IdempotencyModel, [
177
- methodRegistryPlugin(), batchOperationsPlugin(), mongoOperationsPlugin(),
178
- ])
179
- ```
180
-
181
- **IdempotencyStore interface:**
182
-
183
- ```typescript
184
- interface IdempotencyStore {
185
- readonly name: string;
186
- get(key: string): Promise<IdempotencyResult | undefined>;
187
- set(key: string, result): Promise<void>;
188
- tryLock(key: string, requestId: string, ttlMs: number): Promise<boolean>;
189
- unlock(key: string, requestId: string): Promise<void>;
190
- isLocked(key: string): Promise<boolean>;
191
- delete(key: string): Promise<void>;
192
- close?(): Promise<void>;
193
- }
194
- ```
195
-
196
- ## OpenTelemetry Tracing
197
-
198
- ```typescript
199
- import { tracingPlugin } from '@classytic/arc/plugins/tracing';
200
-
201
- await fastify.register(tracingPlugin, {
202
- serviceName: 'my-api',
203
- exporterUrl: 'http://localhost:4318/v1/traces',
204
- sampleRate: 0.1, // Trace 10% of requests
205
- });
206
-
207
- // Custom spans
208
- import { createSpan } from '@classytic/arc/plugins/tracing';
209
-
210
- return createSpan(req, 'processPayment', async (span) => {
211
- span.setAttribute('orderId', order._id);
212
- return await processPayment(order);
213
- });
214
- ```
215
-
216
- ## QueryCache (Server Cache)
217
-
218
- TanStack Query-inspired server cache with stale-while-revalidate and auto-invalidation on mutations.
219
-
220
- ```typescript
221
- // Enable globally
222
- const app = await createApp({
223
- arcPlugins: { queryCache: true }, // Memory store, zero config
224
- });
225
-
226
- // Per-resource config
227
- defineResource({
228
- name: 'product',
229
- cache: {
230
- staleTime: 30, // seconds fresh (no revalidation)
231
- gcTime: 300, // seconds stale data kept (SWR window)
232
- tags: ['catalog'], // cross-resource grouping
233
- invalidateOn: { 'category.*': ['catalog'] }, // event → tag invalidation
234
- },
235
- });
236
- ```
237
-
238
- **How it works:**
239
- - `GET` → cached with `x-cache: HIT | STALE | MISS` header
240
- - `POST/PATCH/DELETE` → auto-bumps resource version, invalidating cached queries
241
- - Cross-resource: category mutation bumps `catalog` tag → products cache invalidated
242
- - Multi-tenant safe: cache keys scoped by userId + orgId
243
-
244
- **Runtime modes:**
245
-
246
- | Mode | Store | Config |
247
- |------|-------|--------|
248
- | `memory` (default) | `MemoryCacheStore` (50 MiB budget) | Zero config |
249
- | `distributed` | `RedisCacheStore` | `stores: { queryCache: new RedisCacheStore({ client: redis }) }` |
250
-
251
- ## Response Cache Plugin
252
-
253
- ```typescript
254
- import { responseCachePlugin } from '@classytic/arc/plugins/response-cache';
255
-
256
- await fastify.register(responseCachePlugin, {
257
- // ETag generation + Cache-Control headers per route
258
- });
259
- ```
260
-
261
- **Note:** When QueryCache is active for a resource, response-cache is automatically skipped for that resource's GET routes.
262
-
263
- ## SSE Plugin (Server-Sent Events)
264
-
265
- Bridges Arc domain events to SSE streams. Requires `eventPlugin` (auto-registered by factory).
266
-
267
- ```typescript
268
- // Via factory (recommended)
269
- const app = await createApp({
270
- arcPlugins: {
271
- sse: {
272
- path: '/events/stream', // SSE endpoint (default: '/events/stream')
273
- requireAuth: true, // Fail-closed auth (default: true)
274
- patterns: ['order.*', 'product.*'], // Event patterns to stream (default: ['*'])
275
- orgScoped: false, // Filter events by org from request.scope (default: false)
276
- heartbeat: 30000, // Heartbeat interval in ms (default: 30000)
277
- },
278
- },
279
- });
280
-
281
- // Manual registration
282
- import { ssePlugin } from '@classytic/arc/plugins';
283
- await fastify.register(ssePlugin, { requireAuth: true, orgScoped: true });
284
- ```
285
-
286
- ### Realtime resource change feed (2.22)
287
-
288
- `arcPlugins: { realtime: true }` → `GET /realtime/:resource` (resource NAME) and
289
- multiplexed `GET /realtime?resources=a,b,c` (cap 20): SSE change feed gated by the
290
- resource's own `list` permission with per-event row filtering, tenant guard, and
291
- field masking. Client = plain `EventSource` with `?token=`. No replay — reconnect
292
- refetches the list. Choosing: per-user resource views → realtime; bidirectional/geo
293
- → `integrations/websocket` (its CRUD bridge is org-coarse — never expose
294
- row-permissioned resources through it); event firehose → sse. Massive fan-out
295
- (viral feeds, live scores) → publish once to a dedicated edge fan-out layer
296
- via `app.events.subscribe(...)`; full tiering guide: docs/production-ops/realtime.mdx.
297
-
298
- ```typescript
299
- ```
300
-
301
- **Fail-closed auth:** When `requireAuth: true` (default), throws at registration if `fastify.authenticate` is missing — prevents exposing SSE without auth.
302
-
303
- **Org-scoped:** When `orgScoped: true`, events with `organizationId` are only sent to clients whose `request.scope` matches. Prevents cross-tenant leakage.
304
-
305
- ## Error Handler Plugin
306
-
307
- ```typescript
308
- import { errorHandlerPlugin } from '@classytic/arc/plugins';
309
-
310
- // Global error handling — catches all errors, logs, formats response
311
- // Auto-registered by createApp()
312
- ```
313
-
314
- ### Error Mappers (v2.7.3)
315
-
316
- Class-based domain error → HTTP response mapping. Register once, handlers just throw:
317
-
318
- ```typescript
319
- class AccountingError extends Error {
320
- constructor(message: string, public status: number, public code: string) { super(message); }
321
- }
322
-
323
- const app = await createApp({
324
- errorHandler: {
325
- errorMappers: [{
326
- type: AccountingError,
327
- toResponse: (err) => ({ status: err.status, code: err.code, message: err.message }),
328
- }],
329
- },
330
- });
331
- // Now handlers just throw — Arc catches and maps automatically
332
- ```
333
-
334
- Mappers are checked via `instanceof` before all other error classification. Multiple mappers supported — first match wins.
335
-
336
- ## Reply Helpers
337
-
338
- Opt-in `sendList` + `stream` decorators: `createApp({ replyHelpers: true })`. Arc emits raw data on success — no `{ success, data }` envelope; HTTP status discriminates — so single-doc handlers just `return doc` (or `reply.send(doc)`) and errors throw `ArcError` (the global handler serialises to `ErrorContract`). The two decorators below cover the cases that DO need framework support:
339
-
340
- ```typescript
341
- // List response — any kit-shaped paginated result OR a bare array → canonical wire shape
342
- return reply.sendList({ method: 'offset', data, total, page, limit, pages, hasNext, hasPrev });
343
- return reply.sendList(rows); // bare array endpoints
344
- ```
345
-
346
- ### Streaming Responses
347
-
348
- ```typescript
349
- // CSV export
350
- return reply.stream(csvReadableStream, { contentType: 'text/csv', filename: 'export.csv' });
351
- // PDF download
352
- return reply.stream(pdfBuffer, { contentType: 'application/pdf', filename: 'report.pdf' });
353
- // Raw stream (Fastify native — works without reply helpers too)
354
- return reply.header('content-type', 'text/csv').send(readableStream);
355
- ```
356
-
357
- ### BigInt Serialization
358
-
359
- Opt-in: `createApp({ serializeBigInt: true })` — auto-converts BigInt → Number in all JSON responses.
360
-
361
- ## Multipart Body Middleware (v2.7.3)
362
-
363
- Opt-in file upload support for CRUD routes — parses multipart form fields into `req.body`, attaches files to `req.body._files`:
364
-
365
- ```typescript
366
- import { multipartBody } from '@classytic/arc/middleware';
367
-
368
- defineResource({
369
- middlewares: { create: [multipartBody({ allowedMimeTypes: ['image/png'], maxFileSize: 5_000_000 })] },
370
- hooks: {
371
- 'before:create': async (data) => {
372
- if (data._files?.image) { data.imageUrl = await uploadToS3(data._files.image); delete data._files; }
373
- return data;
374
- },
375
- },
376
- });
377
- ```
378
-
379
- No-op for JSON requests — safe to always add. Options: `maxFileSize`, `maxFiles`, `allowedMimeTypes`, `filesKey`.
380
-
381
- ## Migrations
382
-
383
- ```typescript
384
- import { defineMigration, MigrationRunner } from '@classytic/arc/migrations';
385
-
386
- const v2 = defineMigration({
387
- version: 2,
388
- resource: 'product',
389
- up: async (db) => {
390
- await db.collection('products').updateMany({}, { $rename: { oldField: 'newField' } });
391
- },
392
- down: async (db) => {
393
- await db.collection('products').updateMany({}, { $rename: { newField: 'oldField' } });
394
- },
395
- });
396
-
397
- const runner = new MigrationRunner(mongoose.connection.db);
398
- await runner.up([v2]);
399
- ```
400
-
401
- ## OpenAPI & External Paths
402
-
403
- Arc auto-generates OpenAPI 3.0 specs from resource definitions. External integrations (auth adapters, custom routes) inject their paths via `ExternalOpenApiPaths`.
404
-
405
- ```typescript
406
- import type { ExternalOpenApiPaths } from '@classytic/arc/docs';
407
-
408
- const externalPaths: ExternalOpenApiPaths = {
409
- paths: { '/api/auth/sign-in': { post: { summary: 'Sign in', ... } } },
410
- schemas: { User: { type: 'object', properties: { ... } } },
411
- securitySchemes: {
412
- cookieAuth: { type: 'apiKey', in: 'cookie', name: 'session_token' },
413
- },
414
- tags: [{ name: 'Authentication' }],
415
- // Declare additional security alternatives for Arc resource paths
416
- resourceSecurity: [{ apiKeyAuth: [], orgHeader: [] }],
417
- };
418
- ```
419
-
420
- **`resourceSecurity`** — declarative registration of auth alternatives for resource paths:
421
- - Each array item is **OR**'d with `bearerAuth` (the default)
422
- - Keys within the same object are **AND**'d (all required together)
423
- - Example: `[{ apiKeyAuth: [], orgHeader: [] }]` → "bearer OR (api-key AND org-header)"
424
-
425
- Arc's Better Auth adapter (`extractBetterAuthOpenApi`) auto-populates `resourceSecurity` when the `apiKey()` plugin is detected — no manual configuration needed.
426
-
427
- **Security scheme definitions:**
428
-
429
- | Scheme | Type | Source | Always present? |
430
- |--------|------|--------|-----------------|
431
- | `bearerAuth` | HTTP Bearer | Arc core | Yes |
432
- | `orgHeader` | API Key (`x-organization-id`) | Arc core | Yes (multi-tenant) |
433
- | `cookieAuth` | API Key (cookie) | Better Auth adapter | When Better Auth active |
434
- | `apiKeyAuth` | API Key (`x-api-key`) | Better Auth adapter | When `apiKey()` plugin active |
435
-
436
- ## Deployment
437
-
438
- Arc requires Node.js APIs (`node:crypto`, `AsyncLocalStorage`). Use `toFetchHandler()` for serverless/edge.
439
-
440
- | Environment | Preset | Handler | Notes |
441
- |-------------|--------|---------|-------|
442
- | Docker/K8s | `production` | `app.listen()` | Full production |
443
- | Google Cloud Run | `production` | `app.listen()` | Set min-instances > 0 for WebSocket |
444
- | Railway/Render/Fly.io | `production` | `app.listen()` | Works with zero config |
445
- | AWS Lambda | `edge` | `toFetchHandler()` | Node.js runtime |
446
- | Vercel Serverless | `edge` | `toFetchHandler()` | Node.js runtime |
447
- | Cloudflare Workers | `edge` | `toFetchHandler()` | Enable `nodejs_compat` in wrangler.toml |
448
-
449
- **Edge handler:**
450
- ```typescript
451
- import { createApp, toFetchHandler } from '@classytic/arc/factory';
452
- const app = await createApp({ preset: 'edge', auth: { type: 'jwt', jwt: { secret } } });
453
- export default { fetch: toFetchHandler(app) }; // Cloudflare Workers / any fetch-based runtime
454
- ```
455
-
456
- **Production checklist:**
457
-
458
- ```typescript
459
- // Validate env vars at startup
460
- if (!process.env.JWT_SECRET || process.env.JWT_SECRET.length < 32) throw new Error('...');
461
-
462
- const app = await createApp({
463
- preset: 'production',
464
- auth: { type: 'jwt', jwt: { secret: process.env.JWT_SECRET } },
465
- cors: { origin: process.env.ALLOWED_ORIGINS?.split(',') ?? [], credentials: true },
466
- rateLimit: { max: 100, timeWindow: '1 minute' },
467
- arcPlugins: { queryCache: true },
468
- });
469
-
470
- process.on('SIGTERM', () => app.close());
471
- process.on('SIGINT', () => app.close());
472
- ```
473
-
474
- ## Distributed Runtime
475
-
476
- `runtime: 'distributed'` only validates stores you actually enable:
477
-
478
- - `stores.events` — always required
479
- - `stores.cache` — only when `arcPlugins.caching` enabled
480
- - `stores.queryCache` — only when `arcPlugins.queryCache` enabled
481
- - `stores.idempotency` — never validated (per-resource opt-in)
482
-
483
- ## Under-Pressure
484
-
485
- Production preset: `maxEventLoopDelay: 3000` (avoids false 503s on Render/Railway/Fly.io). Override: `underPressure: { maxEventLoopDelay: 500 }`. Disable: `underPressure: false`.
486
-
487
- ## CORS
488
-
489
- Production warns (not throws) when origin missing. `origin: '*'` from env is allowed. Smart CORS auto-converts `credentials: true` + `origin: '*'` to `origin: true`, and (2.22) auto-merges arc's protocol headers (`x-organization-id`, `x-arc-scope`, `x-request-id`) into any host-declared `allowedHeaders` allow-list — hosts never enumerate framework internals.
490
-
491
- ### Mobile apps (Capacitor / Ionic / React Native webviews)
492
-
493
- Arc needs no special mode — mobile is a CORS + auth-transport recipe:
494
-
495
- ```typescript
496
- cors: {
497
- // Webview origins alongside your web domains. Also add these to
498
- // Better Auth's trustedOrigins when using BA.
499
- origin: [...webOrigins, 'https://localhost', 'capacitor://localhost'],
500
- credentials: true,
501
- // Optional allow-list — arc's own headers auto-merge (2.22); you only
502
- // list YOUR custom ones. Omit entirely to use reflection mode.
503
- allowedHeaders: ['Content-Type', 'Authorization'],
504
- // BA bearer plugin returns the fresh session token in this RESPONSE
505
- // header — without exposing it, cross-origin JS cannot READ it and
506
- // app login dies silently. The one mobile knob arc can't infer.
507
- exposedHeaders: ['set-auth-token'],
508
- },
509
- trustProxy: true, // webviews arrive through your LB/tunnel
510
- ```
511
-
512
- Native (non-webview) HTTP clients send no `Origin` header — CORS doesn't apply to them at all; bearer auth just works.
513
-
514
- ## Metrics Plugin
515
-
516
- Prometheus-compatible metrics endpoint — zero external dependencies:
517
-
518
- ```typescript
519
- import { metricsPlugin } from '@classytic/arc/plugins';
520
-
521
- await fastify.register(metricsPlugin, {
522
- path: '/_metrics', // default
523
- prefix: 'arc', // metric name prefix (default: 'arc')
524
- onCollect: (metrics) => pushToOTLP(metrics), // optional OTLP push
525
- });
526
-
527
- // GET /_metrics → Prometheus text format
528
- ```
529
-
530
- **Built-in counters**: `arc_http_requests_total`, `arc_http_request_duration_seconds`, `arc_crud_operations_total`, `arc_cache_hits_total`, `arc_cache_misses_total`, `arc_events_published_total`, `arc_events_consumed_total`, `arc_circuit_breaker_state`.
531
-
532
- **Programmatic access**: `fastify.metrics.recordOperation(resource, op, status, durationMs)`, `.recordCacheHit(resource)`, `.recordEventPublish(type)`, `.reset()`.
533
-
534
- ## API Versioning Plugin
535
-
536
- Header-based or URL prefix-based versioning with deprecation warnings:
537
-
538
- ```typescript
539
- import { versioningPlugin } from '@classytic/arc/plugins';
540
-
541
- // Header-based: clients send Accept-Version: 2
542
- await fastify.register(versioningPlugin, {
543
- type: 'header',
544
- deprecated: ['1'],
545
- sunset: '2025-12-01',
546
- });
547
-
548
- // Prefix-based: /v2/products
549
- await fastify.register(versioningPlugin, { type: 'prefix' });
550
- ```
551
-
552
- **Headers set**: `x-api-version` on every response. `Deprecation: true` + `Sunset` for deprecated versions. Access via `request.apiVersion`.
553
-
554
- ## Per-Tenant Rate Limiting
555
-
556
- Scope-aware rate limit key generator — isolates rate limits by org, user, or IP:
557
-
558
- ```typescript
559
- import { createTenantKeyGenerator } from '@classytic/arc/scope';
560
-
561
- const app = await createApp({
562
- rateLimit: {
563
- max: 100,
564
- timeWindow: '1 minute',
565
- keyGenerator: createTenantKeyGenerator(),
566
- },
567
- });
568
- ```
569
-
570
- **Key resolution**: `member` → `organizationId`, `authenticated` → `userId`, `elevated` → `organizationId ?? userId`, `public` → IP. Custom strategy: `createTenantKeyGenerator({ strategy: (ctx) => ctx.ip })`.
571
-
572
- ## Event Outbox
573
-
574
- Transactional outbox pattern — at-least-once delivery even if transport is down:
575
-
576
- ```typescript
577
- import { EventOutbox, MemoryOutboxStore } from '@classytic/arc/events';
578
- import { Repository } from '@classytic/mongokit';
579
-
580
- // Dev
581
- const outbox = new EventOutbox({ store: new MemoryOutboxStore(), transport: redisTransport });
582
-
583
- // Production — any RepositoryLike
584
- const outbox = new EventOutbox({
585
- repository: new Repository(OutboxModel),
586
- transport: redisTransport,
587
- });
588
-
589
- // In business logic (same DB transaction via `{ session }`)
590
- await outbox.store(event, { session });
591
-
592
- // Relay cron (runs every few seconds)
593
- const relayed = await outbox.relay(); // publishes pending → transport
594
- ```
595
-
596
- **OutboxStore interface**: `save(event)`, `getPending(limit)`, `acknowledge(eventId)` (+ optional `claimPending`, `fail`, `getDeadLettered`, `purge`). When you pass `repository`, arc adapts it internally.
597
-
598
- ## Bulk Operations Preset
599
-
600
- Adds bulk CRUD routes — repository must provide `createMany`, `updateMany`, `deleteMany`:
601
-
602
- ```typescript
603
- import { createMongooseAdapter } from '@classytic/mongokit/adapter';
604
- import { buildCrudSchemasFromModel } from '@classytic/mongokit';
605
-
606
- defineResource({
607
- name: 'product',
608
- adapter: createMongooseAdapter({ model, repository, schemaGenerator: buildCrudSchemasFromModel }),
609
- presets: ['bulk'], // adds POST/PATCH/DELETE /{resource}/bulk
610
- });
611
-
612
- // Or with options
613
- presets: [bulkPreset({ operations: ['createMany', 'updateMany'], maxCreateItems: 500 })]
614
- ```
615
-
616
- **Routes**: `POST /bulk` (body: `{ items }`) → `createMany`, `PATCH /bulk` (body: `{ filter, data }`) → `updateMany`, `DELETE /bulk` (body: `{ filter }`) → `deleteMany`. Permissions inherit from `create`/`update`/`delete`.
617
-
618
- ## Compensating Transaction
619
-
620
- In-process rollback primitive — runs steps in order, compensates in reverse on failure.
621
- For distributed sagas across services, use `@classytic/streamline` or a dedicated workflow orchestrator.
622
-
623
- ```typescript
624
- import { withCompensation } from '@classytic/arc/utils';
625
-
626
- const result = await withCompensation('place-order', [
627
- {
628
- name: 'reserve-inventory',
629
- execute: async (ctx) => {
630
- const res = await inventoryService.reserve(ctx.items);
631
- ctx.reservationId = res.id;
632
- return res;
633
- },
634
- compensate: async (_ctx, result) => {
635
- await inventoryService.release(result.id);
636
- },
637
- },
638
- {
639
- name: 'charge-payment',
640
- execute: async (ctx) => await paymentService.charge(ctx.total),
641
- compensate: async (_ctx, result) => await paymentService.refund(result.chargeId),
642
- },
643
- {
644
- name: 'send-confirmation',
645
- execute: async (ctx) => await emailService.send(ctx.email),
646
- // No compensate — emails can't be unsent
647
- },
648
- ], { items: cart.items, total: cart.total, email: user.email });
649
-
650
- if (!result.success) {
651
- console.error(`Saga failed at ${result.failedStep}: ${result.error}`);
652
- // Compensation already ran for completed steps
653
- }
654
- ```
655
-
656
- **`defineCompensation()`** — reusable definition:
657
-
658
- ```typescript
659
- const placeOrder = defineCompensation('place-order', steps);
660
- await placeOrder.execute({ items, total, email });
661
- ```
662
-
663
- **Fire-and-forget steps** — don't block, don't compensate, errors swallowed:
664
-
665
- ```typescript
666
- await withCompensation('checkout', [
667
- { name: 'save-order', execute: saveOrder, compensate: cancelOrder },
668
- { name: 'send-email', execute: sendEmail, fireAndForget: true }, // non-blocking
669
- { name: 'charge', execute: chargeCard, compensate: refundCard },
670
- ]);
671
- // 'charge' runs immediately after 'save-order' — doesn't wait for email
672
- ```
673
-
674
- **Lifecycle hooks** — wire to Arc events, logging, or metrics:
675
-
676
- ```typescript
677
- await withCompensation('checkout', steps, { orderId }, {
678
- onStepComplete: (stepName, result) => {
679
- fastify.events.publish(`checkout.${stepName}.completed`, result);
680
- },
681
- onStepFailed: (stepName, error) => {
682
- fastify.events.publish(`checkout.${stepName}.failed`, { error: error.message });
683
- },
684
- onCompensate: (stepName) => {
685
- fastify.log.warn(`Compensated: ${stepName}`);
686
- },
687
- });
688
- ```
689
-
690
- **In a custom route with Arc auth:**
691
-
692
- ```typescript
693
- defineResource({
694
- name: 'order',
695
- routes: [{
696
- method: 'POST',
697
- path: '/:id/checkout',
698
- permissions: requireAuth(),
699
- rawHandler: async (request, reply) => {
700
- const result = await withCompensation('checkout', steps, { orderId: request.params.id });
701
- if (!result.success) return reply.code(422).send({ error: result.error });
702
- return reply.send({ data: result.results });
703
- },
704
- }],
705
- });
706
- ```
707
-
708
- **Compensation errors**: collected in `result.compensationErrors[]` without stopping rollback. **Context**: mutable `Record<string, unknown>` shared across all steps.
709
-
710
- **Scope**: In-process primitive. Process crash = no compensation. For durable distributed workflows, use Temporal, Inngest, or `@classytic/streamline`.
711
-
712
- ## Multi-Domain Deployment
713
-
714
- Arc supports multi-domain applications through COMPOSITION — it deliberately owns no domain routing, domain databases, certificate provisioning, or wildcard-domain management. The deployment shape:
715
-
716
- ```
717
- api.example.com → arc API
718
- auth.example.com → Better Auth (or auth module)
719
- events.example.com → SSE/WebSocket edge tier
720
- admin.example.com → frontend
721
- customer-domain.com → gateway/domain resolver → tenant context
722
- ```
723
-
724
- The primitives are already there: CORS allowlists, Better Auth trusted origins, tenant scope, request context, `trustProxy` configuration, resource/module prefixes.
725
-
726
- **Custom tenant domains**: resolve the domain at a gateway or an early host plugin, then convert it into a trusted `RequestScope` — a small host-provided resolver is enough:
727
-
728
- ```typescript
729
- // onRequest, before auth: map host → tenant hint
730
- const tenant = await resolveTenantDomain(request.hostname); // → { organizationId } | null
731
- ```
732
-
733
- Then validate membership or service scope through the NORMAL permission path. **Never treat raw `Host` / `X-Forwarded-Host` as tenant authorization by itself** — behind a misconfigured proxy those are attacker-controlled (see the `trustProxy` boot warning). The header selects the tenant *candidate*; membership resolution authorizes it — same rule `resolveOrgFromHeader` follows for `x-organization-id`.
1
+ # Arc Production Features
2
+
3
+ Health checks, audit trail, idempotency, tracing, SSE, caching, graceful shutdown.
4
+
5
+ ## v2.9 Security Defaults
6
+
7
+ - **Field-write perms reject by default** — `defineResource({ fields, onFieldWriteDenied })`. Default `'reject'` → 403 with denied field list. Legacy silent-strip: `onFieldWriteDenied: 'strip'`.
8
+ - **Elevation always emits `arc.scope.elevated`** — subscribe via `fastify.events.subscribe('arc.scope.elevated', handler)` for audit. `onElevation` callback still supported.
9
+ - **multiTenant injects org on UPDATE** — closes cross-tenant hop vector. Body-supplied `organizationId` overwritten with caller's scope.
10
+ - **`verifySignature(body, secret, sig)` throws TypeError** if body isn't string/Buffer — register `@fastify/raw-body` before webhook routes; pass `req.rawBody`.
11
+ - **Upload `sanitizeFilename` (preset)** — strict by default; `false` / `'*'` / custom fn to relax per adapter needs.
12
+ - **Idempotency `namespace`** — fold an env key into the fingerprint when multiple deployments share a Redis (prod + canary, api + jobs).
13
+
14
+ ## Health Plugin
15
+
16
+ Kubernetes-ready liveness/readiness probes:
17
+
18
+ ```typescript
19
+ import { healthPlugin } from '@classytic/arc/plugins';
20
+
21
+ await fastify.register(healthPlugin, {
22
+ prefix: '/_health',
23
+ version: '1.0.0',
24
+ checks: [
25
+ { name: 'mongodb', check: () => mongoose.connection.readyState === 1, critical: true },
26
+ { name: 'redis', check: async () => await redis.ping() === 'PONG', timeout: 3000 },
27
+ ],
28
+ });
29
+
30
+ // GET /_health/live → { status: 'ok', timestamp, version }
31
+ // GET /_health/ready → { status: 'ok', checks: { mongodb: { status: 'ok', latency: 5 } } }
32
+ // Returns 503 if critical check fails
33
+ ```
34
+
35
+ ## Request ID Plugin
36
+
37
+ ```typescript
38
+ import { requestIdPlugin } from '@classytic/arc/plugins';
39
+
40
+ await fastify.register(requestIdPlugin, {
41
+ header: 'x-request-id',
42
+ setResponseHeader: true,
43
+ generator: () => crypto.randomUUID(),
44
+ });
45
+ ```
46
+
47
+ ## Graceful Shutdown Plugin
48
+
49
+ ```typescript
50
+ import { gracefulShutdownPlugin } from '@classytic/arc/plugins';
51
+
52
+ await fastify.register(gracefulShutdownPlugin, {
53
+ timeout: 30000,
54
+ signals: ['SIGTERM', 'SIGINT'],
55
+ logEvents: true,
56
+ onShutdown: async () => {
57
+ await mongoose.disconnect();
58
+ await redis.quit();
59
+ },
60
+ });
61
+
62
+ // Sequence: receive signal → stop accepting connections → wait for in-flight
63
+ // → run onShutdown → close Fastify → exit process
64
+ ```
65
+
66
+ ## Audit Plugin
67
+
68
+ Change tracking. Pass a `RepositoryLike` for persistence, or omit for in-memory dev:
69
+
70
+ ```typescript
71
+ import { auditPlugin } from '@classytic/arc/audit';
72
+ import { Repository } from '@classytic/mongokit';
73
+
74
+ // Development (in-memory)
75
+ await fastify.register(auditPlugin, { enabled: true });
76
+
77
+ // Production — any kit (mongokit / prismakit / custom)
78
+ await fastify.register(auditPlugin, {
79
+ enabled: true,
80
+ repository: new Repository(AuditModel),
81
+ });
82
+ // ── Retention (audit rows ARE `history: true` timelines — prune together) ──
83
+ // Option A (zero code, Mongo): TTL index — the DB does it server-side.
84
+ // AuditSchema.index({ timestamp: 1 }, { expireAfterSeconds: 60 * 60 * 24 * 365 });
85
+ // Option B (built-in, single instance): retention: { maxAgeMs, purgeIntervalMs }.
86
+ // Option C (multi-replica, RECOMMENDED): purgeIntervalMs: 0 + a schedulesPlugin
87
+ // entry calling fastify.audit.purge(cutoff) — leader-safe, jittered, observable.
88
+ // (The built-in timer runs on EVERY replica — idempotent but wasteful.)
89
+ //
90
+ // ── Indexes (kit territory — the audit model is yours) ──
91
+ // Mongo: TTL index above + compound for the history timeline:
92
+ // AuditSchema.index({ resource: 1, documentId: 1, timestamp: -1 });
93
+ // SQL kits: index (resource, document_id, timestamp) + the kit's ttl/sweep plugin.
94
+ //
95
+ // ── Rich search (admin audit-log UI) ──
96
+ // audit.query() is deliberately narrow. For full repo-core filter/sort/keyset
97
+ // power + MCP tools, mount the audit repo AS a read-only resource:
98
+ // defineResource({ name: 'audit-log', crud: { list: true, get: true },
99
+ // adapter: { repository: auditRepo },
100
+ // queryParser: new QueryParser({ allowedFilterFields: ['resource','documentId','userId','action','organizationId'] }),
101
+ // permissions: { list: requireRoles(['auditor']), get: requireRoles(['auditor']) } });
102
+
103
+ // Usage
104
+ await fastify.audit.create('product', product._id, product, request.auditContext);
105
+ await fastify.audit.update('product', id, beforeDoc, afterDoc, request.auditContext);
106
+ await fastify.audit.delete('product', id, deletedDoc, request.auditContext);
107
+ await fastify.audit.custom('product', id, 'price_changed', { oldPrice: 100, newPrice: 150 }, ctx);
108
+
109
+ // Query
110
+ const entries = await fastify.audit.query({
111
+ resource: 'product', documentId: 'prod-123',
112
+ action: 'update', from: new Date('2024-01-01'), limit: 100,
113
+ });
114
+ ```
115
+
116
+ **Audit entry:**
117
+
118
+ ```typescript
119
+ interface AuditEntry {
120
+ id: string; timestamp: Date;
121
+ action: 'create' | 'update' | 'delete' | 'restore' | 'custom';
122
+ resource: string; documentId: string;
123
+ before?: Record<string, unknown>;
124
+ after?: Record<string, unknown>;
125
+ metadata?: Record<string, unknown>;
126
+ context: { user?; organizationId?; requestId?; ipAddress?; userAgent?; };
127
+ }
128
+ ```
129
+
130
+ **Custom store:** Implement `AuditStore` interface (`log`, `query`, `close`).
131
+
132
+ ## Idempotency Plugin
133
+
134
+ Exactly-once semantics for mutating operations:
135
+
136
+ ```typescript
137
+ import { idempotencyPlugin } from '@classytic/arc/idempotency';
138
+
139
+ await fastify.register(idempotencyPlugin, {
140
+ enabled: true,
141
+ headerName: 'idempotency-key', // Default
142
+ ttlMs: 86400000, // 24 hours
143
+ lockTimeoutMs: 30000,
144
+ methods: ['POST', 'PUT', 'PATCH'],
145
+ include: [/\/orders/], // Only these routes
146
+ exclude: [/\/health/],
147
+ retryAfterSeconds: 1,
148
+ });
149
+ ```
150
+
151
+ **Client:**
152
+
153
+ ```typescript
154
+ fetch('/api/orders', {
155
+ method: 'POST',
156
+ headers: { 'Idempotency-Key': 'order-abc123-uuid' },
157
+ body: JSON.stringify({ items: [...] }),
158
+ });
159
+ // First request: processes, caches response
160
+ // Retry: returns cached response + x-idempotency-replayed: true
161
+ // Concurrent: returns 409 Conflict + Retry-After: 1
162
+ ```
163
+
164
+ **Storage backends:**
165
+
166
+ ```typescript
167
+ // Memory (default, dev) — omit both `repository` and `store`
168
+ import { MemoryIdempotencyStore } from '@classytic/arc/idempotency';
169
+
170
+ // Redis (production, multi-instance)
171
+ import { RedisIdempotencyStore } from '@classytic/arc/idempotency/redis';
172
+ store: new RedisIdempotencyStore({ client: redis, prefix: 'idem:', ttlMs: 86400000 })
173
+
174
+ // DB-backed via RepositoryLike (mongokit / prismakit / custom)
175
+ import { Repository, methodRegistryPlugin, batchOperationsPlugin, mongoOperationsPlugin } from '@classytic/mongokit';
176
+ repository: new Repository(IdempotencyModel, [
177
+ methodRegistryPlugin(), batchOperationsPlugin(), mongoOperationsPlugin(),
178
+ ])
179
+ ```
180
+
181
+ **IdempotencyStore interface:**
182
+
183
+ ```typescript
184
+ interface IdempotencyStore {
185
+ readonly name: string;
186
+ get(key: string): Promise<IdempotencyResult | undefined>;
187
+ set(key: string, result): Promise<void>;
188
+ tryLock(key: string, requestId: string, ttlMs: number): Promise<boolean>;
189
+ unlock(key: string, requestId: string): Promise<void>;
190
+ isLocked(key: string): Promise<boolean>;
191
+ delete(key: string): Promise<void>;
192
+ close?(): Promise<void>;
193
+ }
194
+ ```
195
+
196
+ ## OpenTelemetry Tracing
197
+
198
+ ```typescript
199
+ import { tracingPlugin } from '@classytic/arc/plugins/tracing';
200
+
201
+ await fastify.register(tracingPlugin, {
202
+ serviceName: 'my-api',
203
+ exporterUrl: 'http://localhost:4318/v1/traces',
204
+ sampleRate: 0.1, // Trace 10% of requests
205
+ });
206
+
207
+ // Custom spans
208
+ import { createSpan } from '@classytic/arc/plugins/tracing';
209
+
210
+ return createSpan(req, 'processPayment', async (span) => {
211
+ span.setAttribute('orderId', order._id);
212
+ return await processPayment(order);
213
+ });
214
+ ```
215
+
216
+ ## QueryCache (Server Cache)
217
+
218
+ TanStack Query-inspired server cache with stale-while-revalidate and auto-invalidation on mutations.
219
+
220
+ ```typescript
221
+ // Enable globally
222
+ const app = await createApp({
223
+ arcPlugins: { queryCache: true }, // Memory store, zero config
224
+ });
225
+
226
+ // Per-resource config
227
+ defineResource({
228
+ name: 'product',
229
+ cache: {
230
+ staleTime: 30, // seconds fresh (no revalidation)
231
+ gcTime: 300, // seconds stale data kept (SWR window)
232
+ tags: ['catalog'], // cross-resource grouping
233
+ invalidateOn: { 'category.*': ['catalog'] }, // event → tag invalidation
234
+ },
235
+ });
236
+ ```
237
+
238
+ **How it works:**
239
+ - `GET` → cached with `x-cache: HIT | STALE | MISS` header
240
+ - `POST/PATCH/DELETE` → auto-bumps resource version, invalidating cached queries
241
+ - Cross-resource: category mutation bumps `catalog` tag → products cache invalidated
242
+ - Multi-tenant safe: cache keys scoped by userId + orgId
243
+
244
+ **Runtime modes:**
245
+
246
+ | Mode | Store | Config |
247
+ |------|-------|--------|
248
+ | `memory` (default) | `MemoryCacheStore` (50 MiB budget) | Zero config |
249
+ | `distributed` | `RedisCacheStore` | `stores: { queryCache: new RedisCacheStore({ client: redis }) }` |
250
+
251
+ ## Response Cache Plugin
252
+
253
+ ```typescript
254
+ import { responseCachePlugin } from '@classytic/arc/plugins/response-cache';
255
+
256
+ await fastify.register(responseCachePlugin, {
257
+ // ETag generation + Cache-Control headers per route
258
+ });
259
+ ```
260
+
261
+ **Note:** When QueryCache is active for a resource, response-cache is automatically skipped for that resource's GET routes.
262
+
263
+ ## SSE Plugin (Server-Sent Events)
264
+
265
+ Bridges Arc domain events to SSE streams. Requires `eventPlugin` (auto-registered by factory).
266
+
267
+ ```typescript
268
+ // Via factory (recommended)
269
+ const app = await createApp({
270
+ arcPlugins: {
271
+ sse: {
272
+ path: '/events/stream', // SSE endpoint (default: '/events/stream')
273
+ requireAuth: true, // Fail-closed auth (default: true)
274
+ patterns: ['order.*', 'product.*'], // Event patterns to stream (default: ['*'])
275
+ orgScoped: false, // Filter events by org from request.scope (default: false)
276
+ heartbeat: 30000, // Heartbeat interval in ms (default: 30000)
277
+ },
278
+ },
279
+ });
280
+
281
+ // Manual registration
282
+ import { ssePlugin } from '@classytic/arc/plugins';
283
+ await fastify.register(ssePlugin, { requireAuth: true, orgScoped: true });
284
+ ```
285
+
286
+ ### Realtime resource change feed (2.22)
287
+
288
+ `arcPlugins: { realtime: true }` → `GET /realtime/:resource` (resource NAME) and
289
+ multiplexed `GET /realtime?resources=a,b,c` (cap 20): SSE change feed gated by the
290
+ resource's own `list` permission with per-event row filtering, tenant guard, and
291
+ field masking. Client = plain `EventSource` with `?token=`. No replay — reconnect
292
+ refetches the list. Choosing: per-user resource views → realtime; bidirectional/geo
293
+ → `integrations/websocket` (its CRUD bridge is org-coarse — never expose
294
+ row-permissioned resources through it); event firehose → sse. Massive fan-out
295
+ (viral feeds, live scores) → publish once to a dedicated edge fan-out layer
296
+ via `app.events.subscribe(...)`; full tiering guide: docs/production-ops/realtime.mdx.
297
+
298
+ ```typescript
299
+ ```
300
+
301
+ **Fail-closed auth:** When `requireAuth: true` (default), throws at registration if `fastify.authenticate` is missing — prevents exposing SSE without auth.
302
+
303
+ **Org-scoped:** When `orgScoped: true`, events with `organizationId` are only sent to clients whose `request.scope` matches. Prevents cross-tenant leakage.
304
+
305
+ ## Error Handler Plugin
306
+
307
+ ```typescript
308
+ import { errorHandlerPlugin } from '@classytic/arc/plugins';
309
+
310
+ // Global error handling — catches all errors, logs, formats response
311
+ // Auto-registered by createApp()
312
+ ```
313
+
314
+ ### Error Mappers (v2.7.3)
315
+
316
+ Class-based domain error → HTTP response mapping. Register once, handlers just throw:
317
+
318
+ ```typescript
319
+ class AccountingError extends Error {
320
+ constructor(message: string, public status: number, public code: string) { super(message); }
321
+ }
322
+
323
+ const app = await createApp({
324
+ errorHandler: {
325
+ errorMappers: [{
326
+ type: AccountingError,
327
+ toResponse: (err) => ({ status: err.status, code: err.code, message: err.message }),
328
+ }],
329
+ },
330
+ });
331
+ // Now handlers just throw — Arc catches and maps automatically
332
+ ```
333
+
334
+ Mappers are checked via `instanceof` before all other error classification. Multiple mappers supported — first match wins.
335
+
336
+ ## Reply Helpers
337
+
338
+ Opt-in `sendList` + `stream` decorators: `createApp({ replyHelpers: true })`. Arc emits raw data on success — no `{ success, data }` envelope; HTTP status discriminates — so single-doc handlers just `return doc` (or `reply.send(doc)`) and errors throw `ArcError` (the global handler serialises to `ErrorContract`). The two decorators below cover the cases that DO need framework support:
339
+
340
+ ```typescript
341
+ // List response — any kit-shaped paginated result OR a bare array → canonical wire shape
342
+ return reply.sendList({ method: 'offset', data, total, page, limit, pages, hasNext, hasPrev });
343
+ return reply.sendList(rows); // bare array endpoints
344
+ ```
345
+
346
+ ### Streaming Responses
347
+
348
+ ```typescript
349
+ // CSV export
350
+ return reply.stream(csvReadableStream, { contentType: 'text/csv', filename: 'export.csv' });
351
+ // PDF download
352
+ return reply.stream(pdfBuffer, { contentType: 'application/pdf', filename: 'report.pdf' });
353
+ // Raw stream (Fastify native — works without reply helpers too)
354
+ return reply.header('content-type', 'text/csv').send(readableStream);
355
+ ```
356
+
357
+ ### BigInt Serialization
358
+
359
+ Opt-in: `createApp({ serializeBigInt: true })` — auto-converts BigInt → Number in all JSON responses.
360
+
361
+ ## Multipart Body Middleware (v2.7.3)
362
+
363
+ Opt-in file upload support for CRUD routes — parses multipart form fields into `req.body`, attaches files to `req.body._files`:
364
+
365
+ ```typescript
366
+ import { multipartBody } from '@classytic/arc/middleware';
367
+
368
+ defineResource({
369
+ middlewares: { create: [multipartBody({ allowedMimeTypes: ['image/png'], maxFileSize: 5_000_000 })] },
370
+ hooks: {
371
+ 'before:create': async (data) => {
372
+ if (data._files?.image) { data.imageUrl = await uploadToS3(data._files.image); delete data._files; }
373
+ return data;
374
+ },
375
+ },
376
+ });
377
+ ```
378
+
379
+ No-op for JSON requests — safe to always add. Options: `maxFileSize`, `maxFiles`, `allowedMimeTypes`, `filesKey`.
380
+
381
+ ## Migrations
382
+
383
+ ```typescript
384
+ import { defineMigration, MigrationRunner } from '@classytic/arc/migrations';
385
+
386
+ const v2 = defineMigration({
387
+ version: 2,
388
+ resource: 'product',
389
+ up: async (db) => {
390
+ await db.collection('products').updateMany({}, { $rename: { oldField: 'newField' } });
391
+ },
392
+ down: async (db) => {
393
+ await db.collection('products').updateMany({}, { $rename: { newField: 'oldField' } });
394
+ },
395
+ });
396
+
397
+ const runner = new MigrationRunner(mongoose.connection.db);
398
+ await runner.up([v2]);
399
+ ```
400
+
401
+ ## OpenAPI & External Paths
402
+
403
+ Arc auto-generates OpenAPI 3.0 specs from resource definitions. External integrations (auth adapters, custom routes) inject their paths via `ExternalOpenApiPaths`.
404
+
405
+ ```typescript
406
+ import type { ExternalOpenApiPaths } from '@classytic/arc/docs';
407
+
408
+ const externalPaths: ExternalOpenApiPaths = {
409
+ paths: { '/api/auth/sign-in': { post: { summary: 'Sign in', ... } } },
410
+ schemas: { User: { type: 'object', properties: { ... } } },
411
+ securitySchemes: {
412
+ cookieAuth: { type: 'apiKey', in: 'cookie', name: 'session_token' },
413
+ },
414
+ tags: [{ name: 'Authentication' }],
415
+ // Declare additional security alternatives for Arc resource paths
416
+ resourceSecurity: [{ apiKeyAuth: [], orgHeader: [] }],
417
+ };
418
+ ```
419
+
420
+ **`resourceSecurity`** — declarative registration of auth alternatives for resource paths:
421
+ - Each array item is **OR**'d with `bearerAuth` (the default)
422
+ - Keys within the same object are **AND**'d (all required together)
423
+ - Example: `[{ apiKeyAuth: [], orgHeader: [] }]` → "bearer OR (api-key AND org-header)"
424
+
425
+ Arc's Better Auth adapter (`extractBetterAuthOpenApi`) auto-populates `resourceSecurity` when the `apiKey()` plugin is detected — no manual configuration needed.
426
+
427
+ **Security scheme definitions:**
428
+
429
+ | Scheme | Type | Source | Always present? |
430
+ |--------|------|--------|-----------------|
431
+ | `bearerAuth` | HTTP Bearer | Arc core | Yes |
432
+ | `orgHeader` | API Key (`x-organization-id`) | Arc core | Yes (multi-tenant) |
433
+ | `cookieAuth` | API Key (cookie) | Better Auth adapter | When Better Auth active |
434
+ | `apiKeyAuth` | API Key (`x-api-key`) | Better Auth adapter | When `apiKey()` plugin active |
435
+
436
+ ## Deployment
437
+
438
+ Arc requires Node.js APIs (`node:crypto`, `AsyncLocalStorage`). Use `toFetchHandler()` for serverless/edge.
439
+
440
+ | Environment | Preset | Handler | Notes |
441
+ |-------------|--------|---------|-------|
442
+ | Docker/K8s | `production` | `app.listen()` | Full production |
443
+ | Google Cloud Run | `production` | `app.listen()` | Set min-instances > 0 for WebSocket |
444
+ | Railway/Render/Fly.io | `production` | `app.listen()` | Works with zero config |
445
+ | AWS Lambda | `edge` | `toFetchHandler()` | Node.js runtime |
446
+ | Vercel Serverless | `edge` | `toFetchHandler()` | Node.js runtime |
447
+ | Cloudflare Workers | `edge` | `toFetchHandler()` | Enable `nodejs_compat` in wrangler.toml |
448
+
449
+ **Edge handler:**
450
+ ```typescript
451
+ import { createApp, toFetchHandler } from '@classytic/arc/factory';
452
+ const app = await createApp({ preset: 'edge', auth: { type: 'jwt', jwt: { secret } } });
453
+ export default { fetch: toFetchHandler(app) }; // Cloudflare Workers / any fetch-based runtime
454
+ ```
455
+
456
+ **Production checklist:**
457
+
458
+ ```typescript
459
+ // Validate env vars at startup
460
+ if (!process.env.JWT_SECRET || process.env.JWT_SECRET.length < 32) throw new Error('...');
461
+
462
+ const app = await createApp({
463
+ preset: 'production',
464
+ auth: { type: 'jwt', jwt: { secret: process.env.JWT_SECRET } },
465
+ cors: { origin: process.env.ALLOWED_ORIGINS?.split(',') ?? [], credentials: true },
466
+ rateLimit: { max: 100, timeWindow: '1 minute' },
467
+ arcPlugins: { queryCache: true },
468
+ });
469
+
470
+ process.on('SIGTERM', () => app.close());
471
+ process.on('SIGINT', () => app.close());
472
+ ```
473
+
474
+ ## Distributed Runtime
475
+
476
+ `runtime: 'distributed'` only validates stores you actually enable:
477
+
478
+ - `stores.events` — always required
479
+ - `stores.cache` — only when `arcPlugins.caching` enabled
480
+ - `stores.queryCache` — only when `arcPlugins.queryCache` enabled
481
+ - `stores.idempotency` — never validated (per-resource opt-in)
482
+
483
+ ## Under-Pressure
484
+
485
+ Production preset: `maxEventLoopDelay: 3000` (avoids false 503s on Render/Railway/Fly.io). Override: `underPressure: { maxEventLoopDelay: 500 }`. Disable: `underPressure: false`.
486
+
487
+ ## CORS
488
+
489
+ Production warns (not throws) when origin missing. `origin: '*'` from env is allowed. Smart CORS auto-converts `credentials: true` + `origin: '*'` to `origin: true`, and (2.22) auto-merges arc's protocol headers (`x-organization-id`, `x-arc-scope`, `x-request-id`) into any host-declared `allowedHeaders` allow-list — hosts never enumerate framework internals.
490
+
491
+ ### Mobile apps (Capacitor / Ionic / React Native webviews)
492
+
493
+ Arc needs no special mode — mobile is a CORS + auth-transport recipe:
494
+
495
+ ```typescript
496
+ cors: {
497
+ // Webview origins alongside your web domains. Also add these to
498
+ // Better Auth's trustedOrigins when using BA.
499
+ origin: [...webOrigins, 'https://localhost', 'capacitor://localhost'],
500
+ credentials: true,
501
+ // Optional allow-list — arc's own headers auto-merge (2.22); you only
502
+ // list YOUR custom ones. Omit entirely to use reflection mode.
503
+ allowedHeaders: ['Content-Type', 'Authorization'],
504
+ // BA bearer plugin returns the fresh session token in this RESPONSE
505
+ // header — without exposing it, cross-origin JS cannot READ it and
506
+ // app login dies silently. The one mobile knob arc can't infer.
507
+ exposedHeaders: ['set-auth-token'],
508
+ },
509
+ trustProxy: true, // webviews arrive through your LB/tunnel
510
+ ```
511
+
512
+ Native (non-webview) HTTP clients send no `Origin` header — CORS doesn't apply to them at all; bearer auth just works.
513
+
514
+ ## Metrics Plugin
515
+
516
+ Prometheus-compatible metrics endpoint — zero external dependencies:
517
+
518
+ ```typescript
519
+ import { metricsPlugin } from '@classytic/arc/plugins';
520
+
521
+ await fastify.register(metricsPlugin, {
522
+ path: '/_metrics', // default
523
+ prefix: 'arc', // metric name prefix (default: 'arc')
524
+ onCollect: (metrics) => pushToOTLP(metrics), // optional OTLP push
525
+ });
526
+
527
+ // GET /_metrics → Prometheus text format
528
+ ```
529
+
530
+ **Built-in counters**: `arc_http_requests_total`, `arc_http_request_duration_seconds`, `arc_crud_operations_total`, `arc_cache_hits_total`, `arc_cache_misses_total`, `arc_events_published_total`, `arc_events_consumed_total`, `arc_circuit_breaker_state`.
531
+
532
+ **Programmatic access**: `fastify.metrics.recordOperation(resource, op, status, durationMs)`, `.recordCacheHit(resource)`, `.recordEventPublish(type)`, `.reset()`.
533
+
534
+ ## API Versioning Plugin
535
+
536
+ Header-based or URL prefix-based versioning with deprecation warnings:
537
+
538
+ ```typescript
539
+ import { versioningPlugin } from '@classytic/arc/plugins';
540
+
541
+ // Header-based: clients send Accept-Version: 2
542
+ await fastify.register(versioningPlugin, {
543
+ type: 'header',
544
+ deprecated: ['1'],
545
+ sunset: '2025-12-01',
546
+ });
547
+
548
+ // Prefix-based: /v2/products
549
+ await fastify.register(versioningPlugin, { type: 'prefix' });
550
+ ```
551
+
552
+ **Headers set**: `x-api-version` on every response. `Deprecation: true` + `Sunset` for deprecated versions. Access via `request.apiVersion`.
553
+
554
+ ## Per-Tenant Rate Limiting
555
+
556
+ Scope-aware rate limit key generator — isolates rate limits by org, user, or IP:
557
+
558
+ ```typescript
559
+ import { createTenantKeyGenerator } from '@classytic/arc/scope';
560
+
561
+ const app = await createApp({
562
+ rateLimit: {
563
+ max: 100,
564
+ timeWindow: '1 minute',
565
+ keyGenerator: createTenantKeyGenerator(),
566
+ },
567
+ });
568
+ ```
569
+
570
+ **Key resolution**: `member` → `organizationId`, `authenticated` → `userId`, `elevated` → `organizationId ?? userId`, `public` → IP. Custom strategy: `createTenantKeyGenerator({ strategy: (ctx) => ctx.ip })`.
571
+
572
+ ## Event Outbox
573
+
574
+ Transactional outbox pattern — at-least-once delivery even if transport is down:
575
+
576
+ ```typescript
577
+ import { EventOutbox, MemoryOutboxStore } from '@classytic/arc/events';
578
+ import { Repository } from '@classytic/mongokit';
579
+
580
+ // Dev
581
+ const outbox = new EventOutbox({ store: new MemoryOutboxStore(), transport: redisTransport });
582
+
583
+ // Production — any RepositoryLike
584
+ const outbox = new EventOutbox({
585
+ repository: new Repository(OutboxModel),
586
+ transport: redisTransport,
587
+ });
588
+
589
+ // In business logic (same DB transaction via `{ session }`)
590
+ await outbox.store(event, { session });
591
+
592
+ // Relay cron (runs every few seconds)
593
+ const relayed = await outbox.relay(); // publishes pending → transport
594
+ ```
595
+
596
+ **OutboxStore interface**: `save(event)`, `getPending(limit)`, `acknowledge(eventId)` (+ optional `claimPending`, `fail`, `getDeadLettered`, `purge`). When you pass `repository`, arc adapts it internally.
597
+
598
+ ## Bulk Operations Preset
599
+
600
+ Adds bulk CRUD routes — repository must provide `createMany`, `updateMany`, `deleteMany`:
601
+
602
+ ```typescript
603
+ import { createMongooseAdapter } from '@classytic/mongokit/adapter';
604
+ import { buildCrudSchemasFromModel } from '@classytic/mongokit';
605
+
606
+ defineResource({
607
+ name: 'product',
608
+ adapter: createMongooseAdapter({ model, repository, schemaGenerator: buildCrudSchemasFromModel }),
609
+ presets: ['bulk'], // adds POST/PATCH/DELETE /{resource}/bulk
610
+ });
611
+
612
+ // Or with options
613
+ presets: [bulkPreset({ operations: ['createMany', 'updateMany'], maxCreateItems: 500 })]
614
+ ```
615
+
616
+ **Routes**: `POST /bulk` (body: `{ items }`) → `createMany`, `PATCH /bulk` (body: `{ filter, data }`) → `updateMany`, `DELETE /bulk` (body: `{ filter }`) → `deleteMany`. Permissions inherit from `create`/`update`/`delete`.
617
+
618
+ ## Compensating Transaction
619
+
620
+ In-process rollback primitive — runs steps in order, compensates in reverse on failure.
621
+ For distributed sagas across services, use `@classytic/streamline` or a dedicated workflow orchestrator.
622
+
623
+ ```typescript
624
+ import { withCompensation } from '@classytic/arc/utils';
625
+
626
+ const result = await withCompensation('place-order', [
627
+ {
628
+ name: 'reserve-inventory',
629
+ execute: async (ctx) => {
630
+ const res = await inventoryService.reserve(ctx.items);
631
+ ctx.reservationId = res.id;
632
+ return res;
633
+ },
634
+ compensate: async (_ctx, result) => {
635
+ await inventoryService.release(result.id);
636
+ },
637
+ },
638
+ {
639
+ name: 'charge-payment',
640
+ execute: async (ctx) => await paymentService.charge(ctx.total),
641
+ compensate: async (_ctx, result) => await paymentService.refund(result.chargeId),
642
+ },
643
+ {
644
+ name: 'send-confirmation',
645
+ execute: async (ctx) => await emailService.send(ctx.email),
646
+ // No compensate — emails can't be unsent
647
+ },
648
+ ], { items: cart.items, total: cart.total, email: user.email });
649
+
650
+ if (!result.success) {
651
+ console.error(`Saga failed at ${result.failedStep}: ${result.error}`);
652
+ // Compensation already ran for completed steps
653
+ }
654
+ ```
655
+
656
+ **`defineCompensation()`** — reusable definition:
657
+
658
+ ```typescript
659
+ const placeOrder = defineCompensation('place-order', steps);
660
+ await placeOrder.execute({ items, total, email });
661
+ ```
662
+
663
+ **Fire-and-forget steps** — don't block, don't compensate, errors swallowed:
664
+
665
+ ```typescript
666
+ await withCompensation('checkout', [
667
+ { name: 'save-order', execute: saveOrder, compensate: cancelOrder },
668
+ { name: 'send-email', execute: sendEmail, fireAndForget: true }, // non-blocking
669
+ { name: 'charge', execute: chargeCard, compensate: refundCard },
670
+ ]);
671
+ // 'charge' runs immediately after 'save-order' — doesn't wait for email
672
+ ```
673
+
674
+ **Lifecycle hooks** — wire to Arc events, logging, or metrics:
675
+
676
+ ```typescript
677
+ await withCompensation('checkout', steps, { orderId }, {
678
+ onStepComplete: (stepName, result) => {
679
+ fastify.events.publish(`checkout.${stepName}.completed`, result);
680
+ },
681
+ onStepFailed: (stepName, error) => {
682
+ fastify.events.publish(`checkout.${stepName}.failed`, { error: error.message });
683
+ },
684
+ onCompensate: (stepName) => {
685
+ fastify.log.warn(`Compensated: ${stepName}`);
686
+ },
687
+ });
688
+ ```
689
+
690
+ **In a custom route with Arc auth:**
691
+
692
+ ```typescript
693
+ defineResource({
694
+ name: 'order',
695
+ routes: [{
696
+ method: 'POST',
697
+ path: '/:id/checkout',
698
+ permissions: requireAuth(),
699
+ rawHandler: async (request, reply) => {
700
+ const result = await withCompensation('checkout', steps, { orderId: request.params.id });
701
+ if (!result.success) return reply.code(422).send({ error: result.error });
702
+ return reply.send({ data: result.results });
703
+ },
704
+ }],
705
+ });
706
+ ```
707
+
708
+ **Compensation errors**: collected in `result.compensationErrors[]` without stopping rollback. **Context**: mutable `Record<string, unknown>` shared across all steps.
709
+
710
+ **Scope**: In-process primitive. Process crash = no compensation. For durable distributed workflows, use Temporal, Inngest, or `@classytic/streamline`.
711
+
712
+ ## Multi-Domain Deployment
713
+
714
+ Arc supports multi-domain applications through COMPOSITION — it deliberately owns no domain routing, domain databases, certificate provisioning, or wildcard-domain management. The deployment shape:
715
+
716
+ ```
717
+ api.example.com → arc API
718
+ auth.example.com → Better Auth (or auth module)
719
+ events.example.com → SSE/WebSocket edge tier
720
+ admin.example.com → frontend
721
+ customer-domain.com → gateway/domain resolver → tenant context
722
+ ```
723
+
724
+ The primitives are already there: CORS allowlists, Better Auth trusted origins, tenant scope, request context, `trustProxy` configuration, resource/module prefixes.
725
+
726
+ **Custom tenant domains**: resolve the domain at a gateway or an early host plugin, then convert it into a trusted `RequestScope` — a small host-provided resolver is enough:
727
+
728
+ ```typescript
729
+ // onRequest, before auth: map host → tenant hint
730
+ const tenant = await resolveTenantDomain(request.hostname); // → { organizationId } | null
731
+ ```
732
+
733
+ Then validate membership or service scope through the NORMAL permission path. **Never treat raw `Host` / `X-Forwarded-Host` as tenant authorization by itself** — behind a misconfigured proxy those are attacker-controlled (see the `trustProxy` boot warning). The header selects the tenant *candidate*; membership resolution authorizes it — same rule `resolveOrgFromHeader` follows for `x-organization-id`.