@classytic/arc 2.3.0 → 2.4.2

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 (175) hide show
  1. package/README.md +187 -18
  2. package/bin/arc.js +11 -3
  3. package/dist/BaseController-CkM5dUh_.mjs +1031 -0
  4. package/dist/{EventTransport-BkUDYZEb.d.mts → EventTransport-wc5hSLik.d.mts} +1 -1
  5. package/dist/{HookSystem-BsGV-j2l.mjs → HookSystem-COkyWztM.mjs} +2 -3
  6. package/dist/{ResourceRegistry-7Ic20ZMw.mjs → ResourceRegistry-DeCIFlix.mjs} +8 -5
  7. package/dist/adapters/index.d.mts +3 -5
  8. package/dist/adapters/index.mjs +2 -3
  9. package/dist/{prisma-DJbMt3yf.mjs → adapters-DTC4Ug66.mjs} +45 -12
  10. package/dist/audit/index.d.mts +4 -7
  11. package/dist/audit/index.mjs +2 -29
  12. package/dist/audit/mongodb.d.mts +1 -4
  13. package/dist/audit/mongodb.mjs +2 -3
  14. package/dist/auth/index.d.mts +7 -9
  15. package/dist/auth/index.mjs +65 -63
  16. package/dist/auth/redis-session.d.mts +1 -1
  17. package/dist/auth/redis-session.mjs +1 -2
  18. package/dist/{betterAuthOpenApi-DjWDddNc.mjs → betterAuthOpenApi-lz0IRbXJ.mjs} +4 -6
  19. package/dist/cache/index.d.mts +23 -23
  20. package/dist/cache/index.mjs +4 -6
  21. package/dist/{caching-GSDJcA6-.mjs → caching-BSXB-Xr7.mjs} +2 -24
  22. package/dist/chunk-BpYLSNr0.mjs +14 -0
  23. package/dist/circuitBreaker-BOBOpN2w.mjs +284 -0
  24. package/dist/circuitBreaker-JP2GdJ4b.d.mts +206 -0
  25. package/dist/cli/commands/describe.mjs +24 -7
  26. package/dist/cli/commands/docs.mjs +6 -7
  27. package/dist/cli/commands/doctor.d.mts +10 -0
  28. package/dist/cli/commands/doctor.mjs +156 -0
  29. package/dist/cli/commands/generate.mjs +66 -17
  30. package/dist/cli/commands/init.mjs +315 -45
  31. package/dist/cli/commands/introspect.mjs +2 -4
  32. package/dist/cli/index.d.mts +1 -10
  33. package/dist/cli/index.mjs +4 -153
  34. package/dist/{constants-DdXFXQtN.mjs → constants-Cxde4rpC.mjs} +1 -2
  35. package/dist/core/index.d.mts +3 -5
  36. package/dist/core/index.mjs +5 -4
  37. package/dist/core-C1XCMtqM.mjs +185 -0
  38. package/dist/{createApp-CgKOPhA4.mjs → createApp-ByWNRsZj.mjs} +64 -35
  39. package/dist/{defineResource-DWbpJYtm.mjs → defineResource-D9aY5Cy6.mjs} +108 -1157
  40. package/dist/discovery/index.mjs +37 -5
  41. package/dist/docs/index.d.mts +6 -9
  42. package/dist/docs/index.mjs +3 -21
  43. package/dist/dynamic/index.d.mts +93 -0
  44. package/dist/dynamic/index.mjs +122 -0
  45. package/dist/{elevation-DSTbVvYj.mjs → elevation-BEdACOLB.mjs} +5 -36
  46. package/dist/{elevation-DGo5shaX.d.mts → elevation-Ca_yveIO.d.mts} +41 -7
  47. package/dist/{errorHandler-C3GY3_ow.mjs → errorHandler--zp54tGc.mjs} +3 -5
  48. package/dist/errorHandler-Do4vVQ1f.d.mts +139 -0
  49. package/dist/{errors-DBANPbGr.mjs → errors-rxhfP7Hf.mjs} +1 -2
  50. package/dist/{eventPlugin-BEOvaDqo.mjs → eventPlugin-Ba00swHF.mjs} +25 -27
  51. package/dist/{eventPlugin-H6wDDjGO.d.mts → eventPlugin-iGrSEmwJ.d.mts} +105 -5
  52. package/dist/events/index.d.mts +72 -7
  53. package/dist/events/index.mjs +216 -4
  54. package/dist/events/transports/redis-stream-entry.d.mts +1 -1
  55. package/dist/events/transports/redis-stream-entry.mjs +19 -7
  56. package/dist/events/transports/redis.d.mts +1 -1
  57. package/dist/events/transports/redis.mjs +3 -4
  58. package/dist/factory/index.d.mts +23 -9
  59. package/dist/factory/index.mjs +48 -3
  60. package/dist/{fields-Bi_AVKSo.d.mts → fields-DFwdaWCq.d.mts} +1 -1
  61. package/dist/{fields-CTd_CrKr.mjs → fields-ipsbIRPK.mjs} +1 -2
  62. package/dist/hooks/index.d.mts +1 -3
  63. package/dist/hooks/index.mjs +2 -3
  64. package/dist/idempotency/index.d.mts +5 -5
  65. package/dist/idempotency/index.mjs +3 -7
  66. package/dist/idempotency/mongodb.d.mts +1 -1
  67. package/dist/idempotency/mongodb.mjs +4 -5
  68. package/dist/idempotency/redis.d.mts +1 -1
  69. package/dist/idempotency/redis.mjs +2 -5
  70. package/dist/{fastifyAdapter-6b_eRDBw.d.mts → index-BL8CaQih.d.mts} +56 -57
  71. package/dist/index-Diqcm14c.d.mts +369 -0
  72. package/dist/{prisma-Dy5S5F5i.d.mts → index-yhxyjqNb.d.mts} +4 -5
  73. package/dist/index.d.mts +100 -105
  74. package/dist/index.mjs +85 -58
  75. package/dist/integrations/event-gateway.d.mts +1 -1
  76. package/dist/integrations/event-gateway.mjs +8 -4
  77. package/dist/integrations/index.d.mts +4 -2
  78. package/dist/integrations/index.mjs +1 -1
  79. package/dist/integrations/jobs.d.mts +2 -2
  80. package/dist/integrations/jobs.mjs +63 -14
  81. package/dist/integrations/mcp/index.d.mts +219 -0
  82. package/dist/integrations/mcp/index.mjs +572 -0
  83. package/dist/integrations/mcp/testing.d.mts +53 -0
  84. package/dist/integrations/mcp/testing.mjs +104 -0
  85. package/dist/integrations/streamline.mjs +39 -19
  86. package/dist/integrations/webhooks.d.mts +56 -0
  87. package/dist/integrations/webhooks.mjs +139 -0
  88. package/dist/integrations/websocket-redis.d.mts +46 -0
  89. package/dist/integrations/websocket-redis.mjs +50 -0
  90. package/dist/integrations/websocket.d.mts +68 -2
  91. package/dist/integrations/websocket.mjs +96 -13
  92. package/dist/{interface-CSNjltAc.d.mts → interface-B4awm1RJ.d.mts} +2 -2
  93. package/dist/interface-DGmPxakH.d.mts +2213 -0
  94. package/dist/{keys-DhqDRxv3.mjs → keys-qcD-TVJl.mjs} +3 -4
  95. package/dist/{logger-ByrvQWZO.mjs → logger-Dz3j1ItV.mjs} +2 -4
  96. package/dist/{memory-B2v7KrCB.mjs → memory-Cb_7iy9e.mjs} +2 -4
  97. package/dist/metrics-Csh4nsvv.mjs +224 -0
  98. package/dist/migrations/index.d.mts +113 -44
  99. package/dist/migrations/index.mjs +84 -102
  100. package/dist/{mongodb-DNKEExbf.mjs → mongodb-BuQ7fNTg.mjs} +1 -4
  101. package/dist/{mongodb-ClykrfGo.d.mts → mongodb-CUpYfxfD.d.mts} +2 -3
  102. package/dist/{mongodb-Dg8O_gvd.d.mts → mongodb-bga9AbkD.d.mts} +2 -2
  103. package/dist/{openapi-9nB_kiuR.mjs → openapi-CBmZ6EQN.mjs} +4 -21
  104. package/dist/org/index.d.mts +12 -14
  105. package/dist/org/index.mjs +92 -119
  106. package/dist/org/types.d.mts +2 -2
  107. package/dist/org/types.mjs +1 -1
  108. package/dist/permissions/index.d.mts +4 -278
  109. package/dist/permissions/index.mjs +4 -579
  110. package/dist/permissions-CA5zg0yK.mjs +751 -0
  111. package/dist/plugins/index.d.mts +104 -107
  112. package/dist/plugins/index.mjs +203 -313
  113. package/dist/plugins/response-cache.mjs +4 -69
  114. package/dist/plugins/tracing-entry.d.mts +1 -1
  115. package/dist/plugins/tracing-entry.mjs +24 -11
  116. package/dist/{pluralize-CM-jZg7p.mjs → pluralize-CcT6qF0a.mjs} +12 -13
  117. package/dist/policies/index.d.mts +2 -2
  118. package/dist/policies/index.mjs +80 -83
  119. package/dist/presets/index.d.mts +26 -19
  120. package/dist/presets/index.mjs +2 -142
  121. package/dist/presets/multiTenant.d.mts +1 -4
  122. package/dist/presets/multiTenant.mjs +4 -6
  123. package/dist/presets-C9QXJV1u.mjs +422 -0
  124. package/dist/{queryCachePlugin-B6R0d4av.mjs → queryCachePlugin-ClosZdNS.mjs} +6 -27
  125. package/dist/{queryCachePlugin-Q6SYuHZ6.d.mts → queryCachePlugin-DcmETvcB.d.mts} +3 -3
  126. package/dist/queryParser-CgCtsjti.mjs +352 -0
  127. package/dist/{redis-UwjEp8Ea.d.mts → redis-CQ5YxMC5.d.mts} +2 -2
  128. package/dist/{redis-stream-CBg0upHI.d.mts → redis-stream-BW9UKLZM.d.mts} +9 -2
  129. package/dist/registry/index.d.mts +1 -4
  130. package/dist/registry/index.mjs +3 -4
  131. package/dist/{introspectionPlugin-B3JkrjwU.mjs → registry-I-ogLgL9.mjs} +1 -8
  132. package/dist/{requestContext-xi6OKBL-.mjs → requestContext-DYtmNpm5.mjs} +1 -3
  133. package/dist/resourceToTools-PMFE8HIv.mjs +533 -0
  134. package/dist/rpc/index.d.mts +90 -0
  135. package/dist/rpc/index.mjs +248 -0
  136. package/dist/{schemaConverter-Dtg0Kt9T.mjs → schemaConverter-DjzHpFam.mjs} +1 -2
  137. package/dist/schemas/index.d.mts +30 -30
  138. package/dist/schemas/index.mjs +2 -4
  139. package/dist/scope/index.d.mts +13 -2
  140. package/dist/scope/index.mjs +18 -5
  141. package/dist/{sessionManager-D_iEHjQl.d.mts → sessionManager-wbkYj2HL.d.mts} +2 -2
  142. package/dist/{sse-DkqQ1uxb.mjs → sse-BkViJPlT.mjs} +4 -25
  143. package/dist/testing/index.d.mts +551 -567
  144. package/dist/testing/index.mjs +1744 -1799
  145. package/dist/{tracing-8CEbhF0w.d.mts → tracing-bz_U4EM1.d.mts} +6 -1
  146. package/dist/{typeGuards-DwxA1t_L.mjs → typeGuards-Cj5Rgvlg.mjs} +1 -2
  147. package/dist/types/index.d.mts +4 -946
  148. package/dist/types/index.mjs +2 -4
  149. package/dist/types-BJmgxNbF.d.mts +275 -0
  150. package/dist/{types-RLkFVgaw.d.mts → types-BNUccdcf.d.mts} +2 -2
  151. package/dist/{types-Beqn1Un7.mjs → types-C6TQjtdi.mjs} +30 -2
  152. package/dist/{types-tKwaViYB.d.mts → types-Dt0-AI6E.d.mts} +68 -27
  153. package/dist/{types-DelU6kln.mjs → types-ZUu_h0jp.mjs} +1 -2
  154. package/dist/utils/index.d.mts +254 -351
  155. package/dist/utils/index.mjs +7 -6
  156. package/dist/utils-Dc0WhlIl.mjs +594 -0
  157. package/dist/versioning-BzfeHmhj.mjs +37 -0
  158. package/package.json +44 -10
  159. package/skills/arc/SKILL.md +518 -0
  160. package/skills/arc/references/auth.md +250 -0
  161. package/skills/arc/references/events.md +272 -0
  162. package/skills/arc/references/integrations.md +385 -0
  163. package/skills/arc/references/mcp.md +431 -0
  164. package/skills/arc/references/production.md +610 -0
  165. package/skills/arc/references/testing.md +183 -0
  166. package/dist/audited-CGdLiSlE.mjs +0 -140
  167. package/dist/chunk-C7Uep-_p.mjs +0 -20
  168. package/dist/circuitBreaker-CSS2VvL6.mjs +0 -1109
  169. package/dist/errorHandler-CW3OOeYq.d.mts +0 -72
  170. package/dist/interface-BtdYtQUA.d.mts +0 -1114
  171. package/dist/presets-BTeYbw7h.d.mts +0 -57
  172. package/dist/presets-CeFtfDR8.mjs +0 -119
  173. /package/dist/{errors-DAWRdiYP.d.mts → errors-CPpvPHT0.d.mts} +0 -0
  174. /package/dist/{externalPaths-SyPF2tgK.d.mts → externalPaths-DpO-s7r8.d.mts} +0 -0
  175. /package/dist/{interface-DTbsvIWe.d.mts → interface-D_BWALyZ.d.mts} +0 -0
@@ -0,0 +1,250 @@
1
+ # Arc Authentication & Authorization
2
+
3
+ ## Auth Strategies (Discriminated Union)
4
+
5
+ Auth config uses `type` field to select strategy:
6
+
7
+ ### JWT (`type: 'jwt'`)
8
+
9
+ ```typescript
10
+ const app = await createApp({
11
+ auth: {
12
+ type: 'jwt',
13
+ jwt: {
14
+ secret: process.env.JWT_SECRET, // Required, 32+ chars
15
+ expiresIn: '15m', // Access token TTL
16
+ refreshSecret: process.env.JWT_REFRESH_SECRET,
17
+ refreshExpiresIn: '7d',
18
+ },
19
+ // Optional: custom authenticator with JWT helpers
20
+ authenticate: async (request, { jwt }) => {
21
+ const token = request.headers.authorization?.split(' ')[1];
22
+ if (!token) return null;
23
+ const decoded = jwt.verify(token);
24
+ return await User.findById(decoded._id);
25
+ },
26
+ },
27
+ });
28
+ // Decorates: app.authenticate, app.optionalAuthenticate, app.authorize, app.auth
29
+ ```
30
+
31
+ **Token lifecycle:**
32
+
33
+ ```typescript
34
+ // Issue tokens
35
+ const tokens = app.auth.issueTokens({ _id: user._id, email, role });
36
+ // Returns: { accessToken, refreshToken?, expiresIn, tokenType: 'Bearer' }
37
+
38
+ // Verify refresh token (rejects access tokens)
39
+ const decoded = app.auth.verifyRefreshToken(refreshToken);
40
+ ```
41
+
42
+ ### Better Auth (`type: 'betterAuth'`)
43
+
44
+ ```typescript
45
+ import { createBetterAuthAdapter } from '@classytic/arc/auth';
46
+
47
+ const adapter = createBetterAuthAdapter({
48
+ auth, // Your betterAuth() instance
49
+ basePath: '/api/auth',
50
+ orgContext: true, // Extract org membership into request.scope
51
+ });
52
+
53
+ const app = await createApp({
54
+ auth: { type: 'betterAuth', betterAuth: adapter },
55
+ });
56
+ ```
57
+
58
+ **Org context flow** (when `orgContext: true`):
59
+ 1. Gets session via Better Auth
60
+ 2. Reads `session.activeOrganizationId`, or falls back to `x-organization-id` header (needed for API key auth where synthetic sessions have no org context)
61
+ 3. Looks up org membership via `getActiveMemberRole` (with explicit `organizationId` param for header-based resolution)
62
+ 4. Splits roles: `"admin,recruiter"` → `['admin', 'recruiter']`
63
+ 5. Sets `request.scope`: `{ kind: 'member', organizationId, orgRoles: string[], teamId? }`
64
+
65
+ ### API Key Auth (Better Auth `apiKey()` plugin)
66
+
67
+ When the `apiKey()` plugin is enabled in Better Auth, Arc's adapter automatically:
68
+ - Resolves API key sessions via `enableSessionForAPIKeys: true`
69
+ - Falls back to `x-organization-id` header for org context (API key sessions have no `activeOrganizationId`)
70
+ - Generates OpenAPI security schemes dynamically — `apiKeyAuth` only appears in the spec when the plugin is active
71
+
72
+ ```
73
+ x-api-key: ak_live_... # Authentication
74
+ x-organization-id: org_abc123 # Org context (required for org-scoped resources)
75
+ ```
76
+
77
+ **OpenAPI security semantics:**
78
+ - Resource paths: `security: [{ bearerAuth: [] }, { apiKeyAuth: [], orgHeader: [] }]`
79
+ - Meaning: bearer token alone **OR** (API key **AND** org header together)
80
+ - Auth endpoints: `security: [{ cookieAuth: [] }, { bearerAuth: [] }, { apiKeyAuth: [] }]`
81
+ - No org header required for auth management endpoints
82
+
83
+ ### Custom Plugin (`type: 'custom'`)
84
+
85
+ ```typescript
86
+ auth: { type: 'custom', plugin: myAuthPlugin }
87
+ // Plugin must decorate fastify.authenticate
88
+ ```
89
+
90
+ ### Custom Function (`type: 'authenticator'`)
91
+
92
+ ```typescript
93
+ auth: {
94
+ type: 'authenticator',
95
+ authenticate: async (req, reply) => {
96
+ const session = await validateSession(req);
97
+ if (!session) reply.code(401).send({ error: 'Unauthorized' });
98
+ req.user = session.user;
99
+ },
100
+ }
101
+ ```
102
+
103
+ ### Disabled
104
+
105
+ ```typescript
106
+ auth: false
107
+ ```
108
+
109
+ ## Fastify Decorators
110
+
111
+ | Decorator | Description | JWT | Better Auth |
112
+ |-----------|-------------|-----|-------------|
113
+ | `fastify.authenticate` | Verify JWT/session, set `request.user` | Yes | Yes |
114
+ | `fastify.optionalAuthenticate` | Parse token if present, skip if absent | Yes | Yes |
115
+ | `fastify.authorize(...roles)` | Check `user.role`. `authorize('*')` = any auth user | Yes | No |
116
+
117
+ ## Permission Functions
118
+
119
+ ```typescript
120
+ import {
121
+ allowPublic, requireAuth, requireRoles, requireOwnership,
122
+ requireOrgMembership, requireOrgRole, requireTeamMembership,
123
+ allOf, anyOf, when, denyAll,
124
+ } from '@classytic/arc';
125
+ ```
126
+
127
+ | Function | Description |
128
+ |----------|-------------|
129
+ | `allowPublic()` | No authentication |
130
+ | `requireAuth()` | Any authenticated user |
131
+ | `requireRoles(['admin'])` | At least one role matches |
132
+ | `requireOwnership('userId')` | Resource owner only (elevated bypasses) |
133
+ | `requireOrgMembership()` | Must be org member |
134
+ | `requireOrgRole('admin', 'owner')` | Must have org-level role |
135
+ | `requireTeamMembership()` | Must have active team |
136
+ | `allOf(p1, p2)` | AND — all must pass |
137
+ | `anyOf(p1, p2)` | OR — any can pass |
138
+ | `denyAll(reason?)` | Always deny |
139
+
140
+ **Custom permission:**
141
+
142
+ ```typescript
143
+ const requirePro = (): PermissionCheck => async (ctx) => {
144
+ if (!ctx.user) return { granted: false, reason: 'Auth required' };
145
+ return { granted: ctx.user.plan === 'pro' };
146
+ };
147
+ ```
148
+
149
+ ## Dynamic ACL (DB-managed)
150
+
151
+ ```typescript
152
+ import { createDynamicPermissionMatrix } from '@classytic/arc/permissions';
153
+
154
+ const acl = createDynamicPermissionMatrix({
155
+ resolveRolePermissions: async ({ request }) => aclService.getRoleMatrix(orgId),
156
+ cacheStore: new RedisCacheStore({ client: redis, prefix: 'acl:' }),
157
+ });
158
+
159
+ permissions: { list: acl.canAction('product', 'read') }
160
+ ```
161
+
162
+ - Reads org roles from `request.scope.orgRoles`
163
+ - Elevated scope bypasses all checks
164
+ - Supports `*` wildcard for resource/action
165
+ - Cache failures fail open to resolver
166
+
167
+ ## Org Guards
168
+
169
+ ```typescript
170
+ import { orgGuard, requireOrg, requireOrgRole } from '@classytic/arc/org';
171
+
172
+ // Require org context
173
+ fastify.get('/invoices', { preHandler: [fastify.authenticate, requireOrg()] }, handler);
174
+
175
+ // Require specific org role
176
+ fastify.post('/invoices', { preHandler: [fastify.authenticate, requireOrgRole('admin')] }, handler);
177
+ ```
178
+
179
+ ## Request Scope
180
+
181
+ ```typescript
182
+ import type { RequestScope } from '@classytic/arc/scope';
183
+ import { getUserId, getUserRoles, getOrgId, getOrgRoles, getTeamId } from '@classytic/arc/scope';
184
+
185
+ // Variants (userId/userRoles on all authenticated variants):
186
+ // { kind: 'public' }
187
+ // { kind: 'authenticated', userId?, userRoles? }
188
+ // { kind: 'member', userId?, userRoles, organizationId, orgRoles: string[], teamId? }
189
+ // { kind: 'elevated', userId?, organizationId?, elevatedBy }
190
+
191
+ const userId = getUserId(request.scope); // string | undefined
192
+ const globalRoles = getUserRoles(request.scope); // string[]
193
+ const orgId = getOrgId(request.scope); // string | undefined
194
+ const orgRoles = getOrgRoles(request.scope); // string[]
195
+ ```
196
+
197
+ ## Two Role Layers
198
+
199
+ | Layer | Source | Scope Field | Checked By |
200
+ |-------|--------|-------------|------------|
201
+ | Global roles | `user.role` | `scope.userRoles` | `requireRoles()`, `authorize()` |
202
+ | Org roles | Org membership | `scope.orgRoles` | `requireOrgRole()`, `requireOrgMembership()` |
203
+
204
+ ## Role Hierarchy
205
+
206
+ ```typescript
207
+ import { createRoleHierarchy } from '@classytic/arc/permissions';
208
+
209
+ const hierarchy = createRoleHierarchy({
210
+ superadmin: ['admin'],
211
+ admin: ['branch_manager'],
212
+ });
213
+
214
+ hierarchy.expand(['superadmin']); // → ['superadmin', 'admin', 'branch_manager']
215
+ hierarchy.includes(['admin'], 'branch_manager'); // → true
216
+ ```
217
+
218
+ ## Token Extraction & Revocation
219
+
220
+ ```typescript
221
+ auth: {
222
+ type: 'jwt', jwt: { secret },
223
+ tokenExtractor: (req) => req.cookies?.['auth-token'] ?? null,
224
+ isRevoked: async (decoded) => redis.sismember('revoked', decoded.jti),
225
+ }
226
+ ```
227
+
228
+ ## Microservice Gateway Pattern
229
+
230
+ ```
231
+ Frontend → API Gateway (Arc + Better Auth) → Downstream Services
232
+
233
+ Forwards: X-User-Id, X-Org-Id, X-User-Roles
234
+ ```
235
+
236
+ Gateway verifies session, downstream services trust headers:
237
+
238
+ ```typescript
239
+ // Downstream — no Better Auth needed
240
+ const app = await createApp({
241
+ auth: {
242
+ type: 'authenticator',
243
+ authenticate: async (req, reply) => {
244
+ const userId = req.headers['x-user-id'];
245
+ if (!userId) return reply.code(401).send({ error: 'Unauthorized' });
246
+ req.user = { id: userId, role: JSON.parse(req.headers['x-user-roles'] || '[]') };
247
+ },
248
+ },
249
+ });
250
+ ```
@@ -0,0 +1,272 @@
1
+ # Arc Events System
2
+
3
+ Domain event pub/sub with pluggable transports. Auto-emits events on CRUD operations.
4
+
5
+ ## Hooks vs Events
6
+
7
+ | Aspect | Hooks | Events |
8
+ |--------|-------|--------|
9
+ | Purpose | Internal lifecycle callbacks | External integration |
10
+ | Scope | Same process, synchronous flow | Cross-service, async |
11
+ | Use when | Validating, transforming, auditing | Notifying services, event-driven systems |
12
+ | Transport | In-process only | Pluggable (Memory → Redis → Kafka) |
13
+ | Pattern | `beforeCreate`, `afterUpdate` | `product.created`, `order.updated` |
14
+
15
+ ## Setup
16
+
17
+ The `createApp()` factory auto-registers `eventPlugin` — no manual registration needed:
18
+
19
+ ```typescript
20
+ import { createApp } from '@classytic/arc/factory';
21
+
22
+ // Development (in-memory, default — zero config)
23
+ const app = await createApp({ preset: 'development' });
24
+ // app.events is ready to use
25
+
26
+ // Production (Redis transport, with retry)
27
+ import { RedisEventTransport } from '@classytic/arc/events/redis';
28
+
29
+ const app = await createApp({
30
+ stores: { events: new RedisEventTransport(redis) },
31
+ arcPlugins: {
32
+ events: {
33
+ logEvents: true,
34
+ failOpen: true, // default: suppress transport failures
35
+ retry: { maxRetries: 3, backoffMs: 1000 },
36
+ },
37
+ },
38
+ });
39
+
40
+ // Disable event plugin entirely
41
+ const app = await createApp({ arcPlugins: { events: false } });
42
+ ```
43
+
44
+ **Manual registration** (for apps not using `createApp`):
45
+
46
+ ```typescript
47
+ import { eventPlugin } from '@classytic/arc/events';
48
+
49
+ await fastify.register(eventPlugin); // Memory transport
50
+
51
+ // Redis Pub/Sub
52
+ import { RedisEventTransport } from '@classytic/arc/events/redis';
53
+ await fastify.register(eventPlugin, {
54
+ transport: new RedisEventTransport(redisClient, { channel: 'arc-events' }),
55
+ logEvents: true,
56
+ });
57
+
58
+ // Redis Streams (ordered, persistent, consumer groups)
59
+ import { RedisStreamTransport } from '@classytic/arc/events/redis-stream';
60
+ await fastify.register(eventPlugin, {
61
+ transport: new RedisStreamTransport(redisClient, {
62
+ stream: 'arc:events',
63
+ group: 'api-service',
64
+ consumer: 'worker-1',
65
+ }),
66
+ });
67
+ ```
68
+
69
+ `failOpen` behavior:
70
+ - `true` (default): publish/subscribe/close transport errors are logged and suppressed.
71
+ - `false`: transport errors are thrown to caller.
72
+
73
+ ## Auto-Emitted Events
74
+
75
+ BaseController automatically emits events when eventPlugin is registered:
76
+
77
+ | Operation | Event Type | Payload |
78
+ |-----------|------------|---------|
79
+ | `create()` | `{resource}.created` | Created document |
80
+ | `update()` | `{resource}.updated` | Updated document |
81
+ | `delete()` | `{resource}.deleted` | Deleted document |
82
+ | `restore()` | `{resource}.restored` | Restored document |
83
+
84
+ Disable per-controller: `super({ disableEvents: true })`
85
+
86
+ ## Publishing & Subscribing
87
+
88
+ ```typescript
89
+ // Publish
90
+ await fastify.events.publish('order.created', {
91
+ orderId: 'order-123',
92
+ total: 99.99,
93
+ }, {
94
+ userId: request.user._id,
95
+ organizationId: getOrgId(request.scope),
96
+ correlationId: request.id,
97
+ });
98
+
99
+ // Subscribe to specific event
100
+ await fastify.events.subscribe('order.created', async (event) => {
101
+ await sendConfirmation(event.payload);
102
+ });
103
+
104
+ // Subscribe to pattern (wildcard)
105
+ await fastify.events.subscribe('order.*', async (event) => {
106
+ await updateAnalytics(event.type, event.payload);
107
+ });
108
+
109
+ // Subscribe to all
110
+ await fastify.events.subscribe('*', async (event) => {
111
+ await auditLog.create(event);
112
+ });
113
+
114
+ // Unsubscribe
115
+ const unsub = await fastify.events.subscribe('order.created', handler);
116
+ unsub();
117
+ ```
118
+
119
+ ## Event Structure
120
+
121
+ ```typescript
122
+ interface DomainEvent<T> {
123
+ type: string; // e.g., 'order.created'
124
+ payload: T;
125
+ meta: {
126
+ id: string; // Unique event ID
127
+ timestamp: Date;
128
+ source?: string;
129
+ resource?: string;
130
+ resourceId?: string;
131
+ userId?: string;
132
+ organizationId?: string;
133
+ correlationId?: string;
134
+ };
135
+ }
136
+ ```
137
+
138
+ ## Custom Transport
139
+
140
+ Implement `EventTransport` for RabbitMQ, Kafka, etc.:
141
+
142
+ ```typescript
143
+ import type { EventTransport, DomainEvent } from '@classytic/arc/events';
144
+
145
+ class KafkaTransport implements EventTransport {
146
+ readonly name = 'kafka';
147
+
148
+ async publish(event: DomainEvent): Promise<void> {
149
+ await this.producer.send({ topic: event.type, messages: [{ value: JSON.stringify(event) }] });
150
+ }
151
+
152
+ async subscribe(pattern: string, handler: EventHandler): Promise<() => void> {
153
+ // Subscribe to Kafka topic matching pattern
154
+ return () => { /* unsubscribe */ };
155
+ }
156
+
157
+ async close(): Promise<void> {
158
+ await this.producer.disconnect();
159
+ }
160
+ }
161
+ ```
162
+
163
+ ## Built-in Transports
164
+
165
+ | Transport | Import | Use Case |
166
+ |-----------|--------|----------|
167
+ | Memory | `@classytic/arc/events` (default) | Development, testing, single-instance |
168
+ | Redis Pub/Sub | `@classytic/arc/events/redis` | Multi-instance, real-time |
169
+ | Redis Streams | `@classytic/arc/events/redis-stream` | Ordered, persistent, consumer groups |
170
+
171
+ ## Injectable Logger
172
+
173
+ All transports and retry accept a `logger` option — defaults to `console`, compatible with pino/fastify.log:
174
+
175
+ ```typescript
176
+ import type { EventLogger } from '@classytic/arc/events';
177
+
178
+ // Interface: { warn(msg, ...args): void; error(msg, ...args): void }
179
+
180
+ // Use Fastify's logger
181
+ await fastify.register(eventPlugin, {
182
+ transport: new RedisEventTransport(redisClient, {
183
+ logger: fastify.log, // pino logger
184
+ }),
185
+ });
186
+
187
+ // Use with Memory transport
188
+ new MemoryEventTransport({ logger: fastify.log });
189
+
190
+ // Use with Redis Streams
191
+ new RedisStreamTransport(redisClient, { logger: fastify.log });
192
+
193
+ // Use with retry wrapper
194
+ import { withRetry } from '@classytic/arc/events';
195
+ const retried = withRetry(handler, { maxRetries: 3, logger: fastify.log });
196
+ ```
197
+
198
+ | Component | File | `logger` option |
199
+ |-----------|------|-----------------|
200
+ | `MemoryEventTransport` | `EventTransport.ts` | `MemoryEventTransportOptions.logger` |
201
+ | `withRetry()` | `retry.ts` | `RetryOptions.logger` |
202
+ | `RedisEventTransport` | `transports/redis.ts` | `RedisEventTransportOptions.logger` |
203
+ | `RedisStreamTransport` | `transports/redis-stream.ts` | `RedisStreamTransportOptions.logger` |
204
+
205
+ ## Typed Events — defineEvent & Event Registry
206
+
207
+ Declare events with schemas for runtime validation and introspection:
208
+
209
+ ```typescript
210
+ import { defineEvent, createEventRegistry } from '@classytic/arc/events';
211
+
212
+ const OrderCreated = defineEvent({
213
+ name: 'order.created',
214
+ version: 1,
215
+ description: 'Emitted when an order is placed',
216
+ schema: {
217
+ type: 'object',
218
+ properties: {
219
+ orderId: { type: 'string' },
220
+ total: { type: 'number' },
221
+ },
222
+ required: ['orderId', 'total'],
223
+ },
224
+ });
225
+
226
+ // Type-safe event creation
227
+ const event = OrderCreated.create({ orderId: 'o-1', total: 100 }, { userId: 'user-1' });
228
+ await app.events.publish(event.type, event.payload, event.meta);
229
+ ```
230
+
231
+ **Event Registry** — catalog + auto-validation on publish:
232
+
233
+ ```typescript
234
+ const registry = createEventRegistry();
235
+ registry.register(OrderCreated);
236
+
237
+ const app = await createApp({
238
+ arcPlugins: {
239
+ events: { registry, validateMode: 'warn' },
240
+ // 'warn' (default): log warning, still publish
241
+ // 'reject': throw error, do NOT publish
242
+ // 'off': registry is introspection-only
243
+ },
244
+ });
245
+
246
+ // Introspect at runtime
247
+ app.events.registry?.catalog();
248
+ // → [{ name: 'order.created', version: 1, schema: {...} }, ...]
249
+ ```
250
+
251
+ ## QueryCache Integration
252
+
253
+ QueryCache uses events for auto-invalidation. When `arcPlugins.queryCache` is enabled, all CRUD events automatically bump resource versions, invalidating cached queries — zero config required.
254
+
255
+ ## Retry Logic
256
+
257
+ Events module includes retry with exponential backoff for failed handlers:
258
+
259
+ ```typescript
260
+ import { withRetry } from '@classytic/arc/events';
261
+
262
+ const retriedHandler = withRetry(async (event) => {
263
+ await processEvent(event);
264
+ }, {
265
+ maxRetries: 3, // default: 3
266
+ backoffMs: 1000, // initial delay, doubles each retry (default: 1000)
267
+ maxBackoffMs: 30000, // cap (default: 30000)
268
+ logger: fastify.log, // default: console
269
+ });
270
+
271
+ await fastify.events.subscribe('order.created', retriedHandler);
272
+ ```