@classytic/arc 2.11.1 → 2.11.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 (35) hide show
  1. package/README.md +146 -670
  2. package/dist/adapters/index.d.mts +2 -2
  3. package/dist/audit/index.d.mts +1 -1
  4. package/dist/auth/index.d.mts +1 -1
  5. package/dist/core/index.d.mts +2 -2
  6. package/dist/docs/index.d.mts +1 -1
  7. package/dist/events/index.d.mts +1 -1
  8. package/dist/factory/index.d.mts +1 -1
  9. package/dist/hooks/index.d.mts +1 -1
  10. package/dist/idempotency/index.d.mts +1 -1
  11. package/dist/{index-C_bgx9o4.d.mts → index-6u4_Gg6G.d.mts} +34 -0
  12. package/dist/{index-CvM1e09j.d.mts → index-BbMrcvGp.d.mts} +1 -1
  13. package/dist/{index-pUczGjO0.d.mts → index-BdXnTPRj.d.mts} +1 -1
  14. package/dist/{index-smCAoA5W.d.mts → index-DdQ3O9Pg.d.mts} +1 -1
  15. package/dist/index.d.mts +4 -4
  16. package/dist/index.mjs +1 -1
  17. package/dist/integrations/index.d.mts +1 -1
  18. package/dist/integrations/mcp/index.d.mts +2 -2
  19. package/dist/integrations/mcp/testing.d.mts +1 -1
  20. package/dist/middleware/index.d.mts +1 -1
  21. package/dist/org/index.d.mts +1 -1
  22. package/dist/pipeline/index.d.mts +1 -1
  23. package/dist/plugins/index.d.mts +1 -1
  24. package/dist/plugins/tracing-entry.mjs +1 -1
  25. package/dist/presets/filesUpload.d.mts +1 -1
  26. package/dist/presets/index.d.mts +1 -1
  27. package/dist/presets/multiTenant.d.mts +1 -1
  28. package/dist/presets/search.d.mts +1 -1
  29. package/dist/registry/index.d.mts +1 -1
  30. package/dist/testing/index.d.mts +2 -2
  31. package/dist/types/index.d.mts +1 -1
  32. package/dist/{types-Bh_gEJBi.d.mts → types-9beEMe25.d.mts} +1 -1
  33. package/dist/{types-BdA4uMBV.d.mts → types-BH7dEGvU.d.mts} +1 -1
  34. package/dist/utils/index.d.mts +1 -1
  35. package/package.json +3 -1
package/README.md CHANGED
@@ -1,17 +1,31 @@
1
1
  # @classytic/arc
2
2
 
3
- Database-agnostic resource framework for Fastify. Define resources, get CRUD routes, permissions, presets, caching, events, OpenAPI, and MCP tools — without boilerplate.
3
+ Database-agnostic resource framework for Fastify. One `defineResource()` call REST + auth + permissions + events + caching + OpenAPI + MCP tools — without boilerplate.
4
4
 
5
- **v2.10** | Fastify 5+ | Node.js 22+ | ESM only
6
-
7
- ## Install
5
+ **v2.11** · Fastify 5+ · Node.js 22+ · ESM only
8
6
 
9
7
  ```bash
10
8
  npm install @classytic/arc fastify
11
- npm install @classytic/mongokit mongoose # MongoDB adapter
9
+ npm install @classytic/mongokit mongoose # MongoDB (most common)
10
+ # OR @classytic/sqlitekit drizzle-orm better-sqlite3 (sqlite)
11
+ # OR bring your own: implement RepositoryLike from @classytic/repo-core
12
12
  ```
13
13
 
14
- ## Quick Start
14
+ ---
15
+
16
+ ## Why arc
17
+
18
+ | | |
19
+ |---|---|
20
+ | **One call, full REST** | `defineResource({ name, adapter, presets, permissions })` → `GET /`, `GET /:id`, `POST /`, `PATCH /:id`, `DELETE /:id` + custom routes + actions |
21
+ | **DB-agnostic** | Mongoose, Prisma, Drizzle, or any `RepositoryLike` impl. Swap backends without rewriting routes. |
22
+ | **Multi-tenant by default** | Tenant-field auto-injected, scope-aware queries, per-org cache keys, elevation events. |
23
+ | **Tree-shakable subpaths** | `@classytic/arc/auth`, `/events`, `/cache`, `/mcp`, `/integrations/jobs` — pay only for what you import. |
24
+ | **MCP tools, free** | Resources auto-generate Model Context Protocol tools for AI agents. Same permissions, same field rules. |
25
+
26
+ ---
27
+
28
+ ## Quick start
15
29
 
16
30
  ```typescript
17
31
  import mongoose from 'mongoose';
@@ -22,7 +36,7 @@ await mongoose.connect(process.env.DB_URI);
22
36
  const app = await createApp({
23
37
  preset: 'production',
24
38
  resourcePrefix: '/api/v1',
25
- resources: await loadResources(import.meta.url), // auto-discovers *.resource.ts
39
+ resources: await loadResources(import.meta.url), // auto-discover *.resource.ts
26
40
  auth: { type: 'jwt', jwt: { secret: process.env.JWT_SECRET } },
27
41
  cors: { origin: process.env.ALLOWED_ORIGINS?.split(',') },
28
42
  });
@@ -30,182 +44,89 @@ const app = await createApp({
30
44
  await app.listen({ port: 8040, host: '0.0.0.0' });
31
45
  ```
32
46
 
33
- Three ways to register resources:
47
+ Resources can be a static array, an async factory (engine-bound), or auto-discovered from disk:
34
48
 
35
49
  ```typescript
36
- // Auto-discover from directory (recommended)
37
- resources: await loadResources(import.meta.url), // dev/prod parity
50
+ // Auto-discover (recommended for >5 resources)
51
+ resources: await loadResources(import.meta.url),
38
52
 
39
- // Explicit array
53
+ // Explicit list
40
54
  resources: [productResource, orderResource],
41
55
 
42
- // Via plugins callback (full Fastify control)
43
- plugins: async (f) => { await f.register(productResource.toPlugin()); },
44
- ```
45
-
46
- `loadResources()` discovers `default` exports, `export const resource`, OR any named export with `toPlugin()` (e.g. `export const userResource`). Per-resource opt-out of `resourcePrefix` via `skipGlobalPrefix: true` for webhooks/admin routes.
47
-
48
- > **Import compatibility:** Works with relative imports and Node.js `#` subpath imports. Does **not** support tsconfig path aliases (`@/*`, `~/`) — use explicit `resources: [...]` instead.
49
-
50
- ## Boot Sequence
51
-
52
- ```typescript
53
- const app = await createApp({
54
- resourcePrefix: '/api/v1',
55
- plugins: async (f) => { await connectDB(); }, // 1. infra (DB, docs)
56
- bootstrap: [inventoryInit, accountingInit], // 2. domain init
57
- resources: await loadResources(import.meta.url), // 3. routes
58
- afterResources: async (f) => { subscribeEvents(f); }, // 4. post-wiring
59
- onReady: async (f) => { logger.info('ready'); },
60
- });
56
+ // Async factory runs after `bootstrap[]`, before route wiring
57
+ resources: async () => {
58
+ const [catalog, flow] = await Promise.all([ensureCatalogEngine(), ensureFlowEngine()]);
59
+ return loadResources(import.meta.url, { context: { catalog, flow } });
60
+ },
61
61
  ```
62
62
 
63
- ## Audit (per-resource opt-in)
64
-
65
- Clean DX without growing exclude lists:
66
-
67
- ```typescript
68
- import { Repository, methodRegistryPlugin, batchOperationsPlugin } from '@classytic/mongokit';
69
-
70
- // app.ts — pass any RepositoryLike (mongokit / prismakit / custom)
71
- await fastify.register(auditPlugin, {
72
- autoAudit: { perResource: true },
73
- // batchOperationsPlugin enables deleteMany, required for purgeOlderThan()
74
- repository: new Repository(AuditModel, [methodRegistryPlugin(), batchOperationsPlugin()]),
75
- // or omit `repository` for in-memory dev
76
- });
77
-
78
- // order.resource.ts — opt in
79
- defineResource({ name: 'order', audit: true });
80
-
81
- // payment.resource.ts — only audit deletes
82
- defineResource({ name: 'payment', audit: { operations: ['delete'] } });
63
+ `loadResources({ context })` (2.11.1+) threads engine handles into resources whose default export is `(ctx) => defineResource(...)`. No parallel factory files, no `exclude: [...]` bookkeeping.
83
64
 
84
- // Manual logging from MCP tools or custom routes
85
- app.post('/orders/:id/refund', async (req) => {
86
- await app.audit.custom('order', req.params.id, 'refund', { reason }, { user });
87
- });
88
- ```
65
+ ---
89
66
 
90
- ## defineResource
91
-
92
- Single API for a full REST resource with routes, permissions, and behaviors:
67
+ ## Define a resource
93
68
 
94
69
  ```typescript
95
- import { defineResource, createMongooseAdapter, allowPublic, roles } from '@classytic/arc';
70
+ import { defineResource, createMongooseAdapter } from '@classytic/arc';
71
+ import { allowPublic, requireRoles, requireAuth } from '@classytic/arc/permissions';
72
+ import { buildCrudSchemasFromModel } from '@classytic/mongokit';
73
+ import ProductModel from './product.model.js';
74
+ import productRepository from './product.repository.js';
96
75
 
97
- const productResource = defineResource({
76
+ export default defineResource({
98
77
  name: 'product',
99
- adapter: createMongooseAdapter({ model: ProductModel, repository: productRepo }),
100
- presets: ['softDelete', 'slugLookup', { name: 'multiTenant', tenantField: 'orgId' }],
78
+ adapter: createMongooseAdapter({
79
+ model: ProductModel,
80
+ repository: productRepository,
81
+ schemaGenerator: buildCrudSchemasFromModel, // auto-derives CRUD schemas
82
+ }),
83
+ presets: ['softDelete', 'slugLookup', { name: 'multiTenant', tenantField: 'organizationId' }],
101
84
  permissions: {
102
85
  list: allowPublic(),
103
86
  get: allowPublic(),
104
- create: roles('admin', 'editor'), // checks platform + org roles
105
- update: roles('admin', 'editor'),
106
- delete: roles('admin'),
87
+ create: requireRoles(['admin']),
88
+ update: requireRoles(['admin']),
89
+ delete: requireRoles(['admin']),
107
90
  },
108
- cache: { staleTime: 30, gcTime: 300, tags: ['catalog'] }, // QueryCache (opt-in)
91
+ schemaOptions: {
92
+ fieldRules: {
93
+ name: { minLength: 2, maxLength: 200 },
94
+ sku: { pattern: '^[A-Z]{3}-\\d{3}$' },
95
+ status: { enum: ['draft', 'active', 'archived'] },
96
+ priceMode: { nullable: true }, // accept null for round-trips
97
+ organizationId: { systemManaged: true, preserveForElevated: true },
98
+ },
99
+ query: {
100
+ allowedPopulate: ['category', 'createdBy'], // populate whitelist
101
+ filterableFields: { status: { type: 'string' } },
102
+ },
103
+ },
104
+ cache: { staleTime: 30, gcTime: 300, tags: ['catalog'] },
109
105
  routes: [
110
106
  { method: 'GET', path: '/featured', handler: 'getFeatured', permissions: allowPublic() },
111
107
  ],
108
+ actions: {
109
+ approve: { handler: approveOrder, permissions: requireRoles(['admin']) },
110
+ },
112
111
  });
113
-
114
- // Auto-generates: GET /, GET /:id, POST /, PATCH /:id, DELETE /:id
115
- // Plus preset routes: GET /deleted, POST /:id/restore, GET /slug/:slug
116
- ```
117
-
118
- **Custom primary key?** Use `idField` for resources keyed by UUIDs, slugs, or business identifiers:
119
-
120
- ```typescript
121
- defineResource({
122
- name: 'job',
123
- adapter: createMongooseAdapter(JobModel, jobRepository),
124
- idField: 'jobId', // routes + BaseController lookups + OpenAPI + MCP tools all use this
125
- });
126
- // GET /jobs/job-5219f346-a4d → 200 (no ObjectId pattern enforcement)
127
- ```
128
-
129
- ## Authentication
130
-
131
- Auth uses a discriminated union — pick a `type`:
132
-
133
- ```typescript
134
- // Arc JWT
135
- auth: { type: 'jwt', jwt: { secret: process.env.JWT_SECRET, expiresIn: '15m' } }
136
-
137
- // Better Auth (recommended for SaaS with orgs)
138
- import { createBetterAuthAdapter } from '@classytic/arc/auth';
139
- auth: { type: 'betterAuth', betterAuth: createBetterAuthAdapter({ auth, orgContext: true }) }
140
-
141
- // Custom plugin
142
- auth: { type: 'custom', plugin: myAuthPlugin }
143
-
144
- // Custom function
145
- auth: { type: 'authenticator', authenticate: async (req, reply) => { ... } }
146
-
147
- // Disabled
148
- auth: false
149
- ```
150
-
151
- **Decorates:** `app.authenticate`, `app.optionalAuthenticate`, `app.authorize`
152
-
153
- ### Better Auth + Mongoose populate bridge
154
-
155
- When you back Better Auth with `@better-auth/mongo-adapter`, BA writes through the native `mongodb` driver and never registers anything with Mongoose. Any arc resource that does `Schema({ userId: { ref: 'user' } })` and calls `.populate('userId')` then throws `MissingSchemaError`.
156
-
157
- Optional helper at a dedicated subpath registers `strict: false` stub Mongoose models for BA's collections so populate works. Lives behind `@classytic/arc/auth/mongoose` so users on Prisma/Drizzle/Kysely never get Mongoose pulled into their bundle.
158
-
159
- ```typescript
160
- import mongoose from 'mongoose';
161
- import { registerBetterAuthMongooseModels } from '@classytic/arc/auth/mongoose';
162
-
163
- // Default is core only — every plugin set is opt-in.
164
- registerBetterAuthMongooseModels(mongoose, {
165
- plugins: ['organization', 'organization-teams'],
166
- // For separate @better-auth/* packages:
167
- extraCollections: ['passkey', 'ssoProvider'],
168
- });
169
-
170
- // Now arc resources can populate BA-owned references:
171
- const Post = mongoose.model('Post', new mongoose.Schema({
172
- title: String,
173
- authorId: { type: String, ref: 'user' },
174
- }));
175
- await Post.findOne().populate('authorId');
176
112
  ```
177
113
 
178
- Supports `usePlural` (matches `mongodbAdapter({ usePlural: true })`) and `modelOverrides` (for custom `user: { modelName: 'profile' }` configs). Idempotent and de-dupes overlapping plugin sets.
179
-
180
- ### Token Revocation
181
-
182
- Arc provides the `isRevoked` primitive — you implement the store (Redis, DB, Better Auth):
183
-
184
- ```typescript
185
- auth: {
186
- type: 'jwt',
187
- jwt: { secret: process.env.JWT_SECRET },
188
- isRevoked: async (decoded) => {
189
- // Redis set, DB lookup, or any async check
190
- return await redis.sismember('revoked-tokens', decoded.jti ?? decoded.id);
191
- },
192
- }
193
- ```
114
+ Auto-generates: `GET /products`, `GET /products/:id`, `POST /products`, `PATCH /products/:id`, `DELETE /products/:id` + softDelete adds `GET /products/deleted`, `POST /products/:id/restore` + slugLookup adds `GET /products/by-slug/:slug` + custom routes + `POST /products/:id/action`.
194
115
 
195
- Fail-closed: if the revocation check throws, the token is rejected.
116
+ ---
196
117
 
197
118
  ## Permissions
198
119
 
199
- Function-based, composable:
120
+ Function-based — RBAC, ABAC, ReBAC, or any combination.
200
121
 
201
122
  ```typescript
202
123
  import {
203
124
  allowPublic, requireAuth, requireRoles, requireOwnership,
204
125
  requireOrgMembership, requireOrgRole, requireServiceScope,
205
- requireScopeContext,
206
- allOf, anyOf, denyAll,
126
+ requireScopeContext, requireOrgInScope,
127
+ allOf, anyOf, when, denyAll,
207
128
  createDynamicPermissionMatrix,
208
- } from '@classytic/arc';
129
+ } from '@classytic/arc/permissions';
209
130
 
210
131
  permissions: {
211
132
  list: allowPublic(),
@@ -213,567 +134,122 @@ permissions: {
213
134
  create: requireRoles(['admin', 'editor']),
214
135
  update: anyOf(requireOwnership('userId'), requireRoles(['admin'])),
215
136
  delete: allOf(requireAuth(), requireRoles(['admin'])),
216
-
217
- // Mixed human + machine routes — accept org admins OR API keys
218
- bulkImport: anyOf(
219
- requireOrgRole('admin'), // human path
220
- requireServiceScope('jobs:bulk-write'), // machine path (OAuth-style)
221
- ),
222
-
223
- // Multi-level tenancy — branch/project/region scoped routes
224
- branchAdmin: allOf(requireOrgRole('admin'), requireScopeContext('branchId')),
225
- euOnly: requireScopeContext('region', 'eu'),
226
- projectEdit: requireScopeContext({ projectId: 'p-1', region: 'eu' }),
227
-
228
- // Parent-child org hierarchy (holding → subsidiary → branch, MSP, white-label)
229
- // Reads scope.ancestorOrgIds (loaded by your auth function from your own org table)
230
- childOrgAccess: requireOrgInScope((ctx) => ctx.request.params.orgId),
231
137
  }
232
138
  ```
233
139
 
234
- `requireRoles()` checks platform roles (`user.role`) AND org roles
235
- (`scope.orgRoles`) by default — same call works for arc JWT, Better Auth user
236
- roles, and Better Auth org plugin. `requireOrgMembership()` accepts `member`,
237
- `service` (API key), and `elevated` scopes; `multiTenantPreset` filters by
238
- org for all three. For machine identities, `requireServiceScope('jobs:write')`
239
- mirrors OAuth 2.0 scope strings. For app-defined dimensions beyond org/team
240
- (branch, project, region, workspace), `requireScopeContext('branchId')`
241
- reads from `scope.context` populated by your auth function. For parent-child
242
- org hierarchies (holding → subsidiary, MSP → tenants, white-label),
243
- `requireOrgInScope((ctx) => ctx.request.params.orgId)` accepts the current
244
- org or any ancestor in `scope.ancestorOrgIds`.
245
-
246
- **Multi-level tenant filtering** — the `multiTenantPreset` scales from
247
- single-org isolation to lockstep filtering across any number of dimensions:
140
+ Custom checks return `{ granted, reason?, filters?, scope? }` `filters` propagate into the repo query (row-level ABAC), `scope` stamps attributes downstream.
248
141
 
249
- ```typescript
250
- import { multiTenantPreset } from '@classytic/arc/presets';
251
-
252
- // Single-field (default, backwards compatible)
253
- multiTenantPreset({ tenantField: 'organizationId' })
254
-
255
- // Multi-field — org + branch + project, all enforced in lockstep
256
- multiTenantPreset({
257
- tenantFields: [
258
- { field: 'organizationId', type: 'org' }, // → getOrgId(scope)
259
- { field: 'teamId', type: 'team' }, // → getTeamId(scope)
260
- { field: 'branchId', contextKey: 'branchId' }, // → scope.context.branchId
261
- { field: 'projectId', contextKey: 'projectId' },
262
- ],
263
- })
264
- ```
265
-
266
- Fail-closed semantics: if any required dimension is missing from the caller's
267
- scope, list/get/update/delete return 403 with the specific missing field name,
268
- and create is rejected. Elevated scopes apply whatever resolves and skip the
269
- rest (cross-context admin bypass). Your auth function populates
270
- `scope.context` from JWT claims, BA session fields, or request headers — arc
271
- takes no position on which dimension names you use.
272
-
273
- **Field-level permissions:**
274
-
275
- ```typescript
276
- import { fields } from '@classytic/arc';
277
-
278
- fields: {
279
- password: fields.hidden(),
280
- salary: fields.visibleTo(['admin', 'hr']),
281
- role: fields.writableBy(['admin']),
282
- email: fields.redactFor(['viewer'], '***'),
283
- }
284
- ```
285
-
286
- **Dynamic ACL (DB-managed):**
287
-
288
- ```typescript
289
- const acl = createDynamicPermissionMatrix({
290
- resolveRolePermissions: async (ctx) => aclService.getRoleMatrix(orgId),
291
- cacheStore: new RedisCacheStore({ client: redis, prefix: 'acl:' }),
292
- });
142
+ ---
293
143
 
294
- permissions: {
295
- list: acl.canAction('product', 'read'),
296
- create: acl.canAction('product', 'create'),
297
- }
298
- ```
299
-
300
- ## Presets
301
-
302
- Composable resource behaviors:
303
-
304
- | Preset | Effect | Config |
305
- |--------|--------|--------|
306
- | `softDelete` | `GET /deleted`, `POST /:id/restore`, `deletedAt` field | `{ deletedField }` |
307
- | `slugLookup` | `GET /slug/:slug` | `{ slugField }` |
308
- | `tree` | `GET /tree`, `GET /:parent/children` | `{ parentField }` |
309
- | `ownedByUser` | Auto-checks `createdBy` on update/delete | `{ ownerField }` |
310
- | `multiTenant` | Auto-filters all queries by tenant | `{ tenantField }` |
311
- | `audited` | Sets `createdBy`/`updatedBy` from user | — |
312
-
313
- ```typescript
314
- presets: ['softDelete', { name: 'multiTenant', tenantField: 'organizationId' }]
315
- ```
316
-
317
- ## QueryCache
318
-
319
- TanStack Query-inspired server cache with stale-while-revalidate and auto-invalidation:
320
-
321
- ```typescript
322
- // Enable globally
323
- const app = await createApp({
324
- arcPlugins: { queryCache: true }, // Memory store by default
325
- });
326
-
327
- // Per-resource config
328
- defineResource({
329
- name: 'product',
330
- cache: {
331
- staleTime: 30, // seconds fresh
332
- gcTime: 300, // seconds stale data kept (SWR window)
333
- tags: ['catalog'],
334
- invalidateOn: { 'category.*': ['catalog'] }, // cross-resource
335
- },
336
- });
337
- ```
338
-
339
- **How it works:**
340
- - `GET` requests: cached with `x-cache: HIT | STALE | MISS` header
341
- - `POST/PATCH/DELETE`: auto-bumps resource version, invalidating all cached queries
342
- - Cross-resource: category mutation bumps `catalog` tag, invalidates product cache
343
- - Multi-tenant safe: cache keys scoped by userId + orgId
344
-
345
- **Runtime modes:**
346
-
347
- | Mode | Store | Config |
348
- |------|-------|--------|
349
- | `memory` (default) | `MemoryCacheStore` (50 MiB budget) | Zero config |
350
- | `distributed` | `RedisCacheStore` | `stores: { queryCache: new RedisCacheStore({ client: redis }) }` |
351
-
352
- ## BaseController
353
-
354
- Override only what you need:
355
-
356
- ```typescript
357
- import { BaseController } from '@classytic/arc';
358
- import type { IRequestContext, IControllerResponse } from '@classytic/arc';
359
-
360
- class ProductController extends BaseController<Product> {
361
- constructor() { super(productRepo); }
362
-
363
- async getFeatured(req: IRequestContext): Promise<IControllerResponse> {
364
- const products = await this.repository.getAll({ filters: { isFeatured: true } });
365
- return { success: true, data: products };
366
- }
367
- }
368
- ```
369
-
370
- ## Events
371
-
372
- Domain event pub/sub with pluggable transports. The factory auto-registers `eventPlugin` — no manual setup needed:
373
-
374
- ```typescript
375
- // createApp() registers eventPlugin automatically (default: MemoryEventTransport)
376
- // Transport is sourced from stores.events if provided
377
- const app = await createApp({
378
- stores: { events: new RedisEventTransport(redis) }, // optional, defaults to memory
379
- arcPlugins: {
380
- events: { // event plugin config (default: true)
381
- logEvents: true,
382
- retry: { maxRetries: 3, backoffMs: 1000 },
383
- },
384
- },
385
- });
386
-
387
- await app.events.publish('order.created', { orderId: '123' });
388
- await app.events.subscribe('order.*', async (event) => { ... });
389
- ```
390
-
391
- CRUD events (`product.created`, `product.updated`, `product.deleted`) emit automatically.
392
-
393
- ### Causation Chains & DLQ (v2.9)
394
-
395
- ```typescript
396
- import { createEvent, createChildEvent, type DeadLetteredEvent } from '@classytic/arc/events';
397
-
398
- const placed = createEvent('order.placed', { orderId: 'o1' }, {
399
- correlationId: req.id, userId: user.id,
400
- });
401
- await app.events.publish(placed.type, placed.payload, placed.meta);
402
-
403
- // Downstream handler emits a child — correlation inherited, causation linked:
404
- const reserved = createChildEvent(placed, 'inventory.reserved', { sku: 'a' });
405
- // reserved.meta.correlationId === placed.meta.correlationId (stays stable across chain)
406
- // reserved.meta.causationId === placed.meta.id (direct parent)
407
-
408
- // Transports with native DLQ (Kafka, SQS) implement optional deadLetter():
409
- class KafkaTransport implements EventTransport {
410
- async deadLetter(dlq: DeadLetteredEvent) { /* route to .DLQ topic */ }
411
- }
412
- ```
413
-
414
- `EventMeta` also accepts `schemaVersion` (evolve event payloads) and `partitionKey` (ordered delivery hint for Kafka/Kinesis).
415
-
416
- ### defineEvent — Typed Events with Schema Validation
417
-
418
- Declare events with schemas for runtime validation and introspection:
419
-
420
- ```typescript
421
- import { defineEvent, createEventRegistry } from '@classytic/arc/events';
422
-
423
- // Define typed events
424
- const OrderCreated = defineEvent({
425
- name: 'order.created',
426
- version: 1,
427
- description: 'Emitted when an order is placed',
428
- schema: {
429
- type: 'object',
430
- properties: {
431
- orderId: { type: 'string' },
432
- total: { type: 'number' },
433
- currency: { type: 'string' },
434
- },
435
- required: ['orderId', 'total'],
436
- },
437
- });
438
-
439
- // Type-safe event creation
440
- const event = OrderCreated.create({ orderId: 'o-1', total: 100 }, { userId: 'user-1' });
441
- await app.events.publish(event.type, event.payload, event.meta);
442
- ```
443
-
444
- **Event Registry** — catalog + auto-validation on publish:
445
-
446
- ```typescript
447
- const registry = createEventRegistry();
448
- registry.register(OrderCreated);
449
- registry.register(OrderShipped);
450
-
451
- // Wire into eventPlugin — validates payloads on publish
452
- const app = await createApp({
453
- arcPlugins: {
454
- events: { registry, validateMode: 'warn' },
455
- // 'warn' (default): log warning, still publish
456
- // 'reject': throw error, do NOT publish
457
- // 'off': registry is introspection-only
458
- },
459
- });
460
-
461
- // Introspect at runtime
462
- app.events.registry?.catalog();
463
- // → [{ name: 'order.created', version: 1, schema: {...} }, ...]
464
- ```
465
-
466
- Export the registry alongside resources for `arc describe` to auto-detect:
467
-
468
- ```typescript
469
- // src/events.ts
470
- export const eventRegistry = createEventRegistry();
471
- eventRegistry.register(OrderCreated);
472
- eventRegistry.register(OrderShipped);
473
- ```
474
-
475
- ### Event Transports
144
+ ## Authentication
476
145
 
477
- | Transport | Import | Use Case |
478
- |-----------|--------|----------|
479
- | `MemoryEventTransport` | `@classytic/arc/events` | Development, testing, single-instance |
480
- | `RedisEventTransport` | `@classytic/arc/events/redis` | Multi-instance pub/sub (fan-out) |
481
- | `RedisStreamTransport` | `@classytic/arc/events/redis-stream` | Ordered events with consumer groups |
146
+ Discriminated union on `type`:
482
147
 
483
148
  ```typescript
484
- // Redis Pub/Sub
485
- import { RedisEventTransport } from '@classytic/arc/events/redis';
486
- const transport = new RedisEventTransport(redis, { channel: 'arc-events' });
149
+ // JWT (with optional revocation + custom token extractor)
150
+ auth: { type: 'jwt', jwt: { secret, expiresIn: '15m' } }
487
151
 
488
- // Redis Streams (ordered, durable)
489
- import { RedisStreamTransport } from '@classytic/arc/events/redis-stream';
490
- const transport = new RedisStreamTransport(redis, { stream: 'arc-events' });
491
- ```
492
-
493
- **Behavioral contract:**
494
- - **Memory**: Handlers execute sequentially (ordered, awaited)
495
- - **Redis Pub/Sub**: Handlers fire-and-forget (unordered, fan-out)
496
- - **Redis Streams**: Ordered delivery with consumer group acknowledgment
497
-
498
- ### Retry & Dead Letter Queue
499
-
500
- ```typescript
501
- import { withRetry, createDeadLetterPublisher } from '@classytic/arc/events';
502
-
503
- // Per-handler retry with exponential backoff
504
- await app.events.subscribe('order.created', withRetry(
505
- async (event) => { await sendConfirmationEmail(event.payload); },
506
- {
507
- maxRetries: 3,
508
- backoffMs: 1000,
509
- onDead: createDeadLetterPublisher(app.events), // publishes to $deadLetter channel
510
- },
511
- ));
512
-
513
- // Or configure auto-retry for ALL handlers via plugin
514
- const app = await createApp({
515
- arcPlugins: {
516
- events: {
517
- retry: { maxRetries: 3, backoffMs: 1000 },
518
- deadLetterQueue: { store: async (event, errors) => { /* custom DLQ */ } },
519
- },
520
- },
521
- });
522
- ```
152
+ // Better Auth (recommended for SaaS with orgs)
153
+ import { createBetterAuthAdapter } from '@classytic/arc/auth';
154
+ auth: { type: 'betterAuth', betterAuth: createBetterAuthAdapter({ auth: getAuth(), orgContext: true }) }
523
155
 
524
- ## Factory createApp()
156
+ // Custom Fastify plugin
157
+ auth: { type: 'custom', plugin: myAuthPlugin }
525
158
 
526
- ```typescript
527
- const app = await createApp({
528
- preset: 'production', // production | development | testing | edge
529
- runtime: 'memory', // memory (default) | distributed (requires Redis)
530
- auth: { type: 'jwt', jwt: { secret } },
531
- cors: { origin: ['https://myapp.com'] },
532
- helmet: true, // false to disable
533
- rateLimit: { max: 100 }, // false to disable
534
- arcPlugins: {
535
- events: true, // event plugin (default: true, false to disable)
536
- emitEvents: true, // CRUD event emission (default: true)
537
- queryCache: true, // server cache (default: false)
538
- sse: true, // server-sent events (default: false)
539
- caching: true, // ETag + Cache-Control (default: false)
540
- },
541
- stores: { // required when runtime: 'distributed'
542
- events: new RedisEventTransport({ client: redis }),
543
- cache: new RedisCacheStore({ client: redis }),
544
- queryCache: new RedisCacheStore({ client: redis, prefix: 'arc:qc:' }),
545
- },
546
- });
159
+ // Disabled (e.g. internal services)
160
+ auth: false
547
161
  ```
548
162
 
549
- **Arc plugins defaults:**
163
+ Better Auth + Mongoose `populate()`: import `registerBetterAuthMongooseModels` from `@classytic/arc/auth/mongoose` to register `strict: false` stub models for BA collections. Subpath gate keeps Mongoose out of Prisma/Drizzle bundles.
550
164
 
551
- | Plugin | Default | Status |
552
- |--------|---------|--------|
553
- | `events` | `true` | opt-out — registers `eventPlugin` (provides `fastify.events`) |
554
- | `emitEvents` | `true` | opt-out — CRUD operations emit domain events |
555
- | `requestId` | `true` | opt-out |
556
- | `health` | `true` | opt-out |
557
- | `gracefulShutdown` | `true` | opt-out |
558
- | `caching` | `false` | opt-in — ETag + Cache-Control headers |
559
- | `queryCache` | `false` | opt-in — TanStack Query-inspired server cache |
560
- | `sse` | `false` | opt-in — Server-Sent Events streaming |
165
+ ---
561
166
 
562
- | Preset | Logging | Rate Limit | Security |
563
- |--------|---------|------------|----------|
564
- | production | info | 100/min | full |
565
- | development | debug | 1000/min | relaxed |
566
- | testing | silent | disabled | minimal |
567
- | edge | warn | disabled | none (API GW handles) |
167
+ ## Subpath imports
568
168
 
569
- ## Real-Time
169
+ Tree-shake by importing only the subpath you need:
570
170
 
571
- SSE and WebSocket with fail-closed auth (throws at registration if auth missing):
171
+ | Subpath | Purpose |
172
+ |---|---|
173
+ | `@classytic/arc` | `defineResource`, `BaseController`, `createMongooseAdapter`, error classes |
174
+ | `@classytic/arc/factory` | `createApp`, `loadResources`, presets |
175
+ | `@classytic/arc/auth` | JWT + Better Auth adapters |
176
+ | `@classytic/arc/auth/mongoose` | Better Auth Mongoose stub models (opt-in) |
177
+ | `@classytic/arc/permissions` | All permission helpers |
178
+ | `@classytic/arc/scope` | `RequestScope` accessors (`isMember`, `isElevated`, `getOrgId`, …) |
179
+ | `@classytic/arc/cache` | `QueryCache`, transports, plugin |
180
+ | `@classytic/arc/events` | Event plugin, transports, outbox |
181
+ | `@classytic/arc/events/redis` · `/redis-stream` | Redis Pub/Sub + Streams transports (opt-in) |
182
+ | `@classytic/arc/plugins` | Health, request-id, versioning, tracing, response-cache |
183
+ | `@classytic/arc/integrations/jobs` | BullMQ job dispatcher |
184
+ | `@classytic/arc/integrations/websocket` | WebSocket integration |
185
+ | `@classytic/arc/mcp` | Model Context Protocol tools |
186
+ | `@classytic/arc/testing` | `createTestApp`, `expectArc`, `TestAuthProvider`, `createTestFixtures` |
187
+ | `@classytic/arc/types` | Type-only barrel (zero runtime cost) |
188
+
189
+ ---
190
+
191
+ ## Testing
572
192
 
573
193
  ```typescript
574
- // SSE via factory
575
- const app = await createApp({
576
- arcPlugins: { sse: { path: '/events', requireAuth: true, orgScoped: true } },
577
- });
194
+ import { createTestApp, expectArc } from '@classytic/arc/testing';
195
+ import productResource from './product.resource.js';
578
196
 
579
- // WebSocket separate plugin
580
- import { websocketPlugin } from '@classytic/arc/integrations/websocket';
581
- await app.register(websocketPlugin, {
582
- auth: true, // fail-closed: throws if authenticate not registered
583
- resources: ['product', 'order'],
584
- roomPolicy: (client, room) => ['product', 'order'].includes(room),
585
- reauthInterval: 300000, // re-validate token every 5 min (0 = disabled)
586
- maxMessageBytes: 16384, // 16KB message size cap
587
- maxSubscriptionsPerClient: 100, // prevent resource exhaustion
197
+ const ctx = await createTestApp({
198
+ resources: [productResource],
199
+ authMode: 'jwt',
200
+ connectMongoose: true, // in-memory Mongo + Mongoose connect
588
201
  });
202
+ ctx.auth.register('admin', { user: { id: '1', roles: ['admin'] }, orgId: 'org-1' });
589
203
 
590
- // EventGateway unified SSE + WebSocket with shared config
591
- import { eventGatewayPlugin } from '@classytic/arc/integrations/event-gateway';
592
- await app.register(eventGatewayPlugin, {
593
- auth: true, orgScoped: true,
594
- roomPolicy: (client, room) => allowedRooms.includes(room),
595
- sse: { path: '/api/events', patterns: ['order.*'] },
596
- ws: { path: '/ws', resources: ['product', 'order'] },
597
- });
598
- ```
599
-
600
- ## Pipeline — Guards, Transforms, Interceptors
601
-
602
- Functional composition for cross-cutting concerns:
603
-
604
- ```typescript
605
- import { pipe, guard, transform, intercept } from '@classytic/arc/pipeline';
606
-
607
- const isActive = guard('isActive', (ctx) => ctx.query?.filters?.isActive !== false);
608
- const slugify = transform('slugify', (ctx) => ({ ...ctx, body: { ...ctx.body, slug: toSlug(ctx.body.name) } }));
609
- const timing = intercept('timing', async (ctx, next) => {
610
- const start = Date.now();
611
- const result = await next();
612
- console.log(`${ctx.resource}.${ctx.operation}: ${Date.now() - start}ms`);
613
- return result;
614
- });
615
-
616
- defineResource({
617
- name: 'product',
618
- pipe: pipe(isActive, slugify, timing),
619
- // or per-operation: pipe: { create: pipe(slugify), list: pipe(timing) }
204
+ const res = await ctx.app.inject({
205
+ method: 'POST',
206
+ url: '/products',
207
+ headers: ctx.auth.as('admin').headers,
208
+ payload: { name: 'Widget' },
620
209
  });
621
- ```
622
-
623
- ## Utilities
210
+ expectArc(res).ok().hidesField('password');
624
211
 
625
- ```typescript
626
- // Circuit Breaker — fault tolerance for external service calls
627
- import { createCircuitBreaker } from '@classytic/arc/utils';
628
- const paymentBreaker = createCircuitBreaker(
629
- async (amount) => stripe.charges.create({ amount }),
630
- { name: 'stripe', failureThreshold: 5, resetTimeout: 30000, fallback: async () => cached },
631
- );
632
-
633
- // State Machine — workflow validation
634
- import { createStateMachine } from '@classytic/arc/utils';
635
- const orderState = createStateMachine('Order', {
636
- approve: ['pending', 'draft'],
637
- cancel: ['pending', 'approved'],
638
- fulfill: { from: ['approved'], to: 'fulfilled', guard: ({ data }) => data.paid },
639
- });
640
- orderState.assert('approve', currentStatus); // throws if invalid transition
212
+ await ctx.close();
641
213
  ```
642
214
 
643
- ## Integrations
644
-
645
- All separate subpath imports — only loaded when used:
646
-
647
- ```typescript
648
- // Job Queue (BullMQ)
649
- import { jobsPlugin, defineJob } from '@classytic/arc/integrations/jobs';
650
-
651
- // WebSocket (room-based, CRUD auto-broadcast)
652
- import { websocketPlugin } from '@classytic/arc/integrations/websocket';
653
-
654
- // EventGateway (unified SSE + WebSocket)
655
- import { eventGatewayPlugin } from '@classytic/arc/integrations/event-gateway';
656
-
657
- // Streamline Workflows
658
- import { streamlinePlugin } from '@classytic/arc/integrations/streamline';
659
-
660
- // Audit Trail
661
- import { auditPlugin } from '@classytic/arc/audit';
215
+ Three entry points: `createTestApp` (custom scenarios), `createHttpTestHarness` (~16 auto-generated CRUD/permission/validation tests per resource), `runStorageContract` (adapter conformance).
662
216
 
663
- // Idempotency (exactly-once mutations)
664
- import { idempotencyPlugin } from '@classytic/arc/idempotency';
665
-
666
- // OpenTelemetry Tracing
667
- import { tracingPlugin } from '@classytic/arc/plugins/tracing';
668
- ```
217
+ ---
669
218
 
670
219
  ## CLI
671
220
 
672
221
  ```bash
673
- npx @classytic/arc init my-api --mongokit --better-auth --ts # Scaffold project
674
- npx @classytic/arc generate resource product # Generate resource files
675
- npx @classytic/arc describe ./dist/index.js # Resource metadata (JSON)
676
- npx @classytic/arc docs ./openapi.json --entry ./dist/index.js # Export OpenAPI
677
- npx @classytic/arc introspect --entry ./dist/index.js # Show resources
678
- npx @classytic/arc doctor # Health check
222
+ arc init my-api --mongokit --better-auth --ts # scaffold a new project
223
+ arc generate resource product # generate a resource
224
+ arc generate resource product --mcp # + MCP tools file
225
+ arc docs ./openapi.json --entry ./dist/index.js # emit OpenAPI
226
+ arc introspect --entry ./dist/index.js # introspect resources
227
+ arc doctor # diagnose env
679
228
  ```
680
229
 
681
- `arc describe` auto-detects exported `EventRegistry` and includes the event catalog in output:
230
+ ---
682
231
 
683
- ```json
684
- {
685
- "$schema": "arc-describe/v1",
686
- "resources": [...],
687
- "eventCatalog": [
688
- { "name": "order.created", "version": 1, "hasSchema": true, "schemaFields": ["orderId", "total"], "requiredFields": ["orderId", "total"] }
689
- ],
690
- "stats": { "totalResources": 5, "totalRoutes": 28, "totalCatalogedEvents": 3 }
691
- }
692
- ```
693
-
694
- ## Bundle Size
695
-
696
- Arc is tree-shakable and split into 47 subpath exports. You pay only for what you import.
232
+ ## Highlights from recent releases
697
233
 
698
- | What you import | JS shipped to your bundle |
234
+ | Version | Headline |
699
235
  |---|---|
700
- | `createApp` + `defineResource` + `BaseController` (minimal CRUD API) | **~130 KB** |
701
- | `+ @classytic/arc/events/redis` (distributed pub/sub) | +24 KB |
702
- | `+ @classytic/arc/integrations/jobs` (BullMQ) | +8 KB |
703
- | `+ @classytic/arc/mcp` (AI agent tools) | +24 KB |
236
+ | **2.11.2** | `RouteSchemaOptions['query']` types `allowedPopulate` + `allowedLookups` |
237
+ | **2.11.1** | `loadResources({ context })` + factory exports; `ActionDefinition.schema` widened to `unknown`; `silent` removed in favor of `arcLog` fallback |
238
+ | **2.11.0** | `BaseController` mixin split, testing surface rewrite (`createTestApp`, `TestAuthProvider`, `expectArc`), action-router parity, async resources factory |
239
+ | **2.10** | Permissions split, `RepositoryLike` plugs into outbox/audit/idempotency, plugin onSend race fix |
704
240
 
705
- For reference — Fastify core alone is ~300 KB, NestJS core + reflect-metadata is 400+ KB. Arc's minimal footprint is smaller than either, with more features included. `dist/` on disk is 1.7 MB but most of it is `.d.mts` type declarations (free at runtime), the CLI (88 KB, only loaded when running `npx @classytic/arc init`), and the testing helpers (52 KB, never shipped to production).
706
-
707
- **Use subpath imports** — they're the whole reason arc stays lean:
708
-
709
- ```typescript
710
- // Good — each import resolves to exactly one subpath chunk
711
- import { createApp } from '@classytic/arc/factory';
712
- import { defineResource } from '@classytic/arc/core';
713
- import { jobsPlugin } from '@classytic/arc/integrations/jobs'; // only if you use queues
714
- import { mcpPlugin } from '@classytic/arc/mcp'; // only if you expose MCP
715
-
716
- // Bad — pulls the whole barrel; tree-shaking helps but subpath is better
717
- import { createApp, defineResource, jobsPlugin, mcpPlugin } from '@classytic/arc';
718
- ```
719
-
720
- Arc sets `"sideEffects": false` in [package.json](package.json), so modern bundlers (esbuild, Rollup, Webpack 5+, tsdown) correctly eliminate unused exports even from the barrel.
721
-
722
- ## Subpath Imports
723
-
724
- | Import | Purpose |
725
- |--------|---------|
726
- | `@classytic/arc` | Core: `defineResource`, `BaseController`, permissions, errors |
727
- | `@classytic/arc/factory` | `createApp()`, presets |
728
- | `@classytic/arc/cache` | `MemoryCacheStore`, `RedisCacheStore`, `QueryCache` |
729
- | `@classytic/arc/auth` | Auth plugin, Better Auth adapter, session manager |
730
- | `@classytic/arc/events` | Event plugin, transports, `defineEvent`, `createEventRegistry` |
731
- | `@classytic/arc/events/redis` | Redis Pub/Sub event transport |
732
- | `@classytic/arc/events/redis-stream` | Redis Streams event transport |
733
- | `@classytic/arc/plugins` | Health, graceful shutdown, request ID, SSE, caching |
734
- | `@classytic/arc/plugins/tracing` | OpenTelemetry |
735
- | `@classytic/arc/permissions` | All permission functions, role hierarchy |
736
- | `@classytic/arc/scope` | Request scope helpers (`isMember`, `isElevated`, `getOrgId`) |
737
- | `@classytic/arc/org` | Organization module |
738
- | `@classytic/arc/hooks` | Lifecycle hooks |
739
- | `@classytic/arc/presets` | Preset functions + interfaces |
740
- | `@classytic/arc/audit` | Audit trail |
741
- | `@classytic/arc/idempotency` | Idempotency |
742
- | `@classytic/arc/schemas` | TypeBox helpers |
743
- | `@classytic/arc/utils` | Errors, circuit breaker, state machine, query parser |
744
- | `@classytic/arc/testing` | Test utilities, mocks, in-memory DB |
745
- | `@classytic/arc/migrations` | Schema migrations |
746
- | `@classytic/arc/integrations/jobs` | BullMQ job queue |
747
- | `@classytic/arc/integrations/websocket` | WebSocket |
748
- | `@classytic/arc/integrations/event-gateway` | Unified SSE + WebSocket gateway |
749
- | `@classytic/arc/integrations/streamline` | Workflow orchestration |
750
- | `@classytic/arc/mcp` | MCP tools for AI agents |
751
- | `@classytic/arc/docs` | OpenAPI generation |
752
- | `@classytic/arc/cli` | CLI commands (programmatic) |
753
-
754
- ## Type imports
755
-
756
- Arc owns framework types (`IController`, `IRequestContext`, `ResourceConfig`, `RepositoryLike`, `PaginationResult`). The repository contract lives in `@classytic/repo-core` — import those types directly:
757
-
758
- ```typescript
759
- // Arc framework types
760
- import type { IRequestContext, RepositoryLike, PaginationResult } from '@classytic/arc';
761
-
762
- // Repository contract (repo-core is the single source of truth)
763
- import type { StandardRepo, WriteOptions, QueryOptions } from '@classytic/repo-core/repository';
764
- import type { OffsetPaginationResult } from '@classytic/repo-core/pagination';
765
- ```
241
+ Full history: [`/changelog/v2.md`](changelog/v2.md).
766
242
 
767
- > Arc 2.10 dropped the legacy `CrudRepository`, `PaginatedResult`, and pass-through `WriteOptions`/`QueryOptions` re-exports. See [CHANGELOG.md](CHANGELOG.md#210) for the migration table.
243
+ ---
768
244
 
769
- ## v2.10 Highlights
245
+ ## Documentation
770
246
 
771
- - **Clean-break on repo-core types** `CrudRepository` / `PaginatedResult` / pass-through repo options removed from arc's public surface. Import `StandardRepo`, `OffsetPaginationResult`, etc. directly from `@classytic/repo-core`. See [CHANGELOG.md](CHANGELOG.md) for the rewrite table.
772
- - **Outbox bugfix** — `repositoryAsOutboxStore.fail()` now passes `updatePipeline: true` to `findOneAndUpdate`, so retry / DLQ transitions work on mongokit ≥3.10.
773
- - **Plugin requirements documented** — audit / outbox / idempotency require mongokit's `methodRegistryPlugin` + `batchOperationsPlugin` for `deleteMany`; README snippets + production-ops docs now show the correct chain.
774
- - **Removed** `@classytic/arc/policies` (use `permissions/`), `@classytic/arc/rpc`, `@classytic/arc/dynamic` (use `factory/loadResources`).
247
+ - **Skill** for AI agents: `npx skills add classytic/arc` wires arc into Claude Code / agentic flows.
248
+ - **Concept reference**: [wiki/index.md](wiki/index.md) short, interlinked pages.
249
+ - **Guides**: [docs/](docs/) getting-started, framework-extension, production-ops, testing, ecosystem.
250
+ - **Release notes**: [changelog/v2.md](changelog/v2.md).
775
251
 
776
- See [CHANGELOG.md](CHANGELOG.md) for the full v2.9 / v2.8 / v2.7 history.
252
+ ---
777
253
 
778
254
  ## License
779
255