@classytic/arc 2.11.1 → 2.11.3
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.
- package/LICENSE +21 -21
- package/README.md +143 -673
- package/bin/arc.js +2 -2
- package/dist/{BaseController-JNV08qOT.mjs → BaseController-swXruJ2_.mjs} +2 -2
- package/dist/{actionPermissions-C8YYU92K.mjs → actionPermissions-sUUKDhtP.mjs} +4 -2
- package/dist/adapters/index.d.mts +2 -2
- package/dist/audit/index.d.mts +1 -1
- package/dist/auth/index.d.mts +1 -1
- package/dist/auth/index.mjs +1 -1
- package/dist/cli/commands/docs.mjs +1 -1
- package/dist/cli/commands/generate.d.mts +0 -2
- package/dist/cli/commands/generate.mjs +15 -15
- package/dist/cli/commands/init.mjs +24 -22
- package/dist/context/index.mjs +1 -1
- package/dist/core/index.d.mts +2 -2
- package/dist/core/index.mjs +3 -3
- package/dist/{core-DXdSSFW-.mjs → core-DnUsRpuX.mjs} +20 -8
- package/dist/{createActionRouter-BwaSM0No.mjs → createActionRouter-u3ql2EDo.mjs} +73 -13
- package/dist/{createApp-P1d6rjPy.mjs → createApp-BFxtdKy6.mjs} +1 -1
- package/dist/docs/index.d.mts +1 -1
- package/dist/docs/index.mjs +1 -1
- package/dist/{eventPlugin--5HIkdPU.mjs → eventPlugin-KrFIQ097.mjs} +1 -1
- package/dist/events/index.d.mts +1 -1
- package/dist/events/index.mjs +11 -3
- package/dist/factory/index.d.mts +1 -1
- package/dist/factory/index.mjs +1 -1
- package/dist/hooks/index.d.mts +1 -1
- package/dist/idempotency/index.d.mts +1 -1
- package/dist/{index-C_bgx9o4.d.mts → index-6u4_Gg6G.d.mts} +34 -0
- package/dist/{index-CvM1e09j.d.mts → index-BbMrcvGp.d.mts} +1 -1
- package/dist/{index-pUczGjO0.d.mts → index-BdXnTPRj.d.mts} +1 -1
- package/dist/{index-smCAoA5W.d.mts → index-DdQ3O9Pg.d.mts} +1 -1
- package/dist/index.d.mts +4 -4
- package/dist/index.mjs +6 -6
- package/dist/integrations/index.d.mts +1 -1
- package/dist/integrations/mcp/index.d.mts +2 -2
- package/dist/integrations/mcp/index.mjs +1 -1
- package/dist/integrations/mcp/testing.d.mts +1 -1
- package/dist/integrations/mcp/testing.mjs +1 -1
- package/dist/middleware/index.d.mts +1 -1
- package/dist/{openapi-C0L9ar7m.mjs → openapi-BGUn7Ki1.mjs} +2 -2
- package/dist/org/index.d.mts +1 -1
- package/dist/permissions/index.mjs +1 -1
- package/dist/{permissions-B4vU9L0Q.mjs → permissions-gd_aUWrR.mjs} +42 -0
- package/dist/pipeline/index.d.mts +1 -1
- package/dist/plugins/index.d.mts +1 -1
- package/dist/plugins/index.mjs +1 -1
- package/dist/plugins/tracing-entry.mjs +1 -1
- package/dist/presets/filesUpload.d.mts +1 -1
- package/dist/presets/filesUpload.mjs +1 -1
- package/dist/presets/index.d.mts +1 -1
- package/dist/presets/index.mjs +1 -1
- package/dist/presets/multiTenant.d.mts +1 -1
- package/dist/presets/search.d.mts +1 -1
- package/dist/presets/search.mjs +1 -1
- package/dist/{presets-k604Lj99.mjs → presets-Z7P5w4gF.mjs} +1 -1
- package/dist/registry/index.d.mts +1 -1
- package/dist/{requestContext-CfRkaxwf.mjs → requestContext-C5XeK3VA.mjs} +15 -0
- package/dist/{resourceToTools--okX6QBr.mjs → resourceToTools-ByZpgjeH.mjs} +5 -4
- package/dist/{routerShared-DeESFp4a.mjs → routerShared-BqLRb5l7.mjs} +60 -3
- package/dist/testing/index.d.mts +2 -2
- package/dist/testing/index.mjs +1 -1
- package/dist/types/index.d.mts +1 -1
- package/dist/{types-Bh_gEJBi.d.mts → types-9beEMe25.d.mts} +1 -1
- package/dist/{types-BdA4uMBV.d.mts → types-BH7dEGvU.d.mts} +1 -1
- package/dist/utils/index.d.mts +1 -1
- package/dist/utils/index.mjs +1 -1
- package/dist/{utils-D3Yxnrwr.mjs → utils-CcYTj09l.mjs} +1 -1
- package/package.json +5 -1
- package/skills/arc/references/events.md +489 -489
package/README.md
CHANGED
|
@@ -1,17 +1,38 @@
|
|
|
1
1
|
# @classytic/arc
|
|
2
2
|
|
|
3
|
-
Database-agnostic resource framework for Fastify.
|
|
3
|
+
Database-agnostic resource framework for Fastify. One `defineResource()` call → REST + auth + permissions + events + caching + OpenAPI + MCP tools — without boilerplate.
|
|
4
4
|
|
|
5
|
-
**v2.
|
|
6
|
-
|
|
7
|
-
## Install
|
|
5
|
+
**v2.11** · Fastify 5+ · Node.js 22+ · ESM only
|
|
8
6
|
|
|
9
7
|
```bash
|
|
8
|
+
# Core
|
|
10
9
|
npm install @classytic/arc fastify
|
|
11
|
-
|
|
10
|
+
|
|
11
|
+
# Security defaults that createApp() enables out of the box
|
|
12
|
+
npm install @fastify/cors @fastify/helmet @fastify/rate-limit @fastify/under-pressure @fastify/sensible
|
|
13
|
+
# (each is opt-out via `cors: false` / `helmet: false` / etc.)
|
|
14
|
+
|
|
15
|
+
# Pick a storage adapter
|
|
16
|
+
npm install @classytic/mongokit mongoose # MongoDB (most common)
|
|
17
|
+
# OR @classytic/sqlitekit drizzle-orm better-sqlite3 (sqlite)
|
|
18
|
+
# OR bring your own: implement RepositoryLike from @classytic/repo-core
|
|
12
19
|
```
|
|
13
20
|
|
|
14
|
-
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Why arc
|
|
24
|
+
|
|
25
|
+
| | |
|
|
26
|
+
|---|---|
|
|
27
|
+
| **One call, full REST** | `defineResource({ name, adapter, presets, permissions })` → `GET /`, `GET /:id`, `POST /`, `PATCH /:id`, `DELETE /:id` + custom routes + actions |
|
|
28
|
+
| **DB-agnostic** | Mongoose, Drizzle/sqlitekit, or any `RepositoryLike` impl — swap backends without rewriting routes. (Prisma adapter is experimental: implemented, no integration tests yet.) |
|
|
29
|
+
| **Multi-tenant by default** | Tenant-field auto-injected, scope-aware queries, per-org cache keys, elevation events. |
|
|
30
|
+
| **Tree-shakable subpaths** | `@classytic/arc/auth`, `/events`, `/cache`, `/mcp`, `/integrations/jobs` — pay only for what you import. |
|
|
31
|
+
| **MCP tools, free** | Resources auto-generate Model Context Protocol tools for AI agents. Same permissions, same field rules. |
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Quick start
|
|
15
36
|
|
|
16
37
|
```typescript
|
|
17
38
|
import mongoose from 'mongoose';
|
|
@@ -22,7 +43,7 @@ await mongoose.connect(process.env.DB_URI);
|
|
|
22
43
|
const app = await createApp({
|
|
23
44
|
preset: 'production',
|
|
24
45
|
resourcePrefix: '/api/v1',
|
|
25
|
-
resources: await loadResources(import.meta.url), // auto-
|
|
46
|
+
resources: await loadResources(import.meta.url), // auto-discover *.resource.ts
|
|
26
47
|
auth: { type: 'jwt', jwt: { secret: process.env.JWT_SECRET } },
|
|
27
48
|
cors: { origin: process.env.ALLOWED_ORIGINS?.split(',') },
|
|
28
49
|
});
|
|
@@ -30,182 +51,89 @@ const app = await createApp({
|
|
|
30
51
|
await app.listen({ port: 8040, host: '0.0.0.0' });
|
|
31
52
|
```
|
|
32
53
|
|
|
33
|
-
|
|
54
|
+
Resources can be a static array, an async factory (engine-bound), or auto-discovered from disk:
|
|
34
55
|
|
|
35
56
|
```typescript
|
|
36
|
-
// Auto-discover
|
|
37
|
-
resources: await loadResources(import.meta.url),
|
|
57
|
+
// Auto-discover (recommended for >5 resources)
|
|
58
|
+
resources: await loadResources(import.meta.url),
|
|
38
59
|
|
|
39
|
-
// Explicit
|
|
60
|
+
// Explicit list
|
|
40
61
|
resources: [productResource, orderResource],
|
|
41
62
|
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
});
|
|
63
|
+
// Async factory — runs after `bootstrap[]`, before route wiring
|
|
64
|
+
resources: async () => {
|
|
65
|
+
const [catalog, flow] = await Promise.all([ensureCatalogEngine(), ensureFlowEngine()]);
|
|
66
|
+
return loadResources(import.meta.url, { context: { catalog, flow } });
|
|
67
|
+
},
|
|
61
68
|
```
|
|
62
69
|
|
|
63
|
-
|
|
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'] } });
|
|
70
|
+
`loadResources({ context })` (2.11.1+) threads engine handles into resources whose default export is `(ctx) => defineResource(...)`. No parallel factory files, no `exclude: [...]` bookkeeping.
|
|
83
71
|
|
|
84
|
-
|
|
85
|
-
app.post('/orders/:id/refund', async (req) => {
|
|
86
|
-
await app.audit.custom('order', req.params.id, 'refund', { reason }, { user });
|
|
87
|
-
});
|
|
88
|
-
```
|
|
72
|
+
---
|
|
89
73
|
|
|
90
|
-
##
|
|
91
|
-
|
|
92
|
-
Single API for a full REST resource with routes, permissions, and behaviors:
|
|
74
|
+
## Define a resource
|
|
93
75
|
|
|
94
76
|
```typescript
|
|
95
|
-
import { defineResource, createMongooseAdapter
|
|
77
|
+
import { defineResource, createMongooseAdapter } from '@classytic/arc';
|
|
78
|
+
import { allowPublic, requireRoles, requireAuth } from '@classytic/arc/permissions';
|
|
79
|
+
import { buildCrudSchemasFromModel } from '@classytic/mongokit';
|
|
80
|
+
import ProductModel from './product.model.js';
|
|
81
|
+
import productRepository from './product.repository.js';
|
|
96
82
|
|
|
97
|
-
|
|
83
|
+
export default defineResource({
|
|
98
84
|
name: 'product',
|
|
99
|
-
adapter: createMongooseAdapter({
|
|
100
|
-
|
|
85
|
+
adapter: createMongooseAdapter({
|
|
86
|
+
model: ProductModel,
|
|
87
|
+
repository: productRepository,
|
|
88
|
+
schemaGenerator: buildCrudSchemasFromModel, // auto-derives CRUD schemas
|
|
89
|
+
}),
|
|
90
|
+
presets: ['softDelete', 'slugLookup', { name: 'multiTenant', tenantField: 'organizationId' }],
|
|
101
91
|
permissions: {
|
|
102
92
|
list: allowPublic(),
|
|
103
93
|
get: allowPublic(),
|
|
104
|
-
create:
|
|
105
|
-
update:
|
|
106
|
-
delete:
|
|
94
|
+
create: requireRoles(['admin']),
|
|
95
|
+
update: requireRoles(['admin']),
|
|
96
|
+
delete: requireRoles(['admin']),
|
|
97
|
+
},
|
|
98
|
+
schemaOptions: {
|
|
99
|
+
fieldRules: {
|
|
100
|
+
name: { minLength: 2, maxLength: 200 },
|
|
101
|
+
sku: { pattern: '^[A-Z]{3}-\\d{3}$' },
|
|
102
|
+
status: { enum: ['draft', 'active', 'archived'] },
|
|
103
|
+
priceMode: { nullable: true }, // accept null for round-trips
|
|
104
|
+
organizationId: { systemManaged: true, preserveForElevated: true },
|
|
105
|
+
},
|
|
106
|
+
query: {
|
|
107
|
+
allowedPopulate: ['category', 'createdBy'], // populate whitelist
|
|
108
|
+
filterableFields: { status: { type: 'string' } },
|
|
109
|
+
},
|
|
107
110
|
},
|
|
108
|
-
cache: { staleTime: 30, gcTime: 300, tags: ['catalog'] },
|
|
111
|
+
cache: { staleTime: 30, gcTime: 300, tags: ['catalog'] },
|
|
109
112
|
routes: [
|
|
110
113
|
{ method: 'GET', path: '/featured', handler: 'getFeatured', permissions: allowPublic() },
|
|
111
114
|
],
|
|
115
|
+
actions: {
|
|
116
|
+
approve: { handler: approveOrder, permissions: requireRoles(['admin']) },
|
|
117
|
+
},
|
|
112
118
|
});
|
|
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
119
|
```
|
|
177
120
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
### Token Revocation
|
|
181
|
-
|
|
182
|
-
Arc provides the `isRevoked` primitive — you implement the store (Redis, DB, Better Auth):
|
|
121
|
+
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`.
|
|
183
122
|
|
|
184
|
-
|
|
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
|
-
```
|
|
194
|
-
|
|
195
|
-
Fail-closed: if the revocation check throws, the token is rejected.
|
|
123
|
+
---
|
|
196
124
|
|
|
197
125
|
## Permissions
|
|
198
126
|
|
|
199
|
-
Function-based,
|
|
127
|
+
Function-based — RBAC, ABAC, ReBAC, or any combination.
|
|
200
128
|
|
|
201
129
|
```typescript
|
|
202
130
|
import {
|
|
203
131
|
allowPublic, requireAuth, requireRoles, requireOwnership,
|
|
204
132
|
requireOrgMembership, requireOrgRole, requireServiceScope,
|
|
205
|
-
requireScopeContext,
|
|
206
|
-
allOf, anyOf, denyAll,
|
|
133
|
+
requireScopeContext, requireOrgInScope,
|
|
134
|
+
allOf, anyOf, when, denyAll,
|
|
207
135
|
createDynamicPermissionMatrix,
|
|
208
|
-
} from '@classytic/arc';
|
|
136
|
+
} from '@classytic/arc/permissions';
|
|
209
137
|
|
|
210
138
|
permissions: {
|
|
211
139
|
list: allowPublic(),
|
|
@@ -213,567 +141,109 @@ permissions: {
|
|
|
213
141
|
create: requireRoles(['admin', 'editor']),
|
|
214
142
|
update: anyOf(requireOwnership('userId'), requireRoles(['admin'])),
|
|
215
143
|
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
|
-
}
|
|
232
|
-
```
|
|
233
|
-
|
|
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:
|
|
248
|
-
|
|
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
|
-
});
|
|
293
|
-
|
|
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
144
|
}
|
|
412
145
|
```
|
|
413
146
|
|
|
414
|
-
|
|
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:
|
|
147
|
+
Custom checks return `{ granted, reason?, filters?, scope? }` — `filters` propagate into the repo query (row-level ABAC), `scope` stamps attributes downstream.
|
|
467
148
|
|
|
468
|
-
|
|
469
|
-
// src/events.ts
|
|
470
|
-
export const eventRegistry = createEventRegistry();
|
|
471
|
-
eventRegistry.register(OrderCreated);
|
|
472
|
-
eventRegistry.register(OrderShipped);
|
|
473
|
-
```
|
|
149
|
+
---
|
|
474
150
|
|
|
475
|
-
|
|
151
|
+
## Authentication
|
|
476
152
|
|
|
477
|
-
|
|
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 |
|
|
153
|
+
Discriminated union on `type`:
|
|
482
154
|
|
|
483
155
|
```typescript
|
|
484
|
-
//
|
|
485
|
-
|
|
486
|
-
const transport = new RedisEventTransport(redis, { channel: 'arc-events' });
|
|
156
|
+
// JWT (with optional revocation + custom token extractor)
|
|
157
|
+
auth: { type: 'jwt', jwt: { secret, expiresIn: '15m' } }
|
|
487
158
|
|
|
488
|
-
//
|
|
489
|
-
import {
|
|
490
|
-
|
|
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
|
|
159
|
+
// Better Auth (recommended for SaaS with orgs)
|
|
160
|
+
import { createBetterAuthAdapter } from '@classytic/arc/auth';
|
|
161
|
+
auth: { type: 'betterAuth', betterAuth: createBetterAuthAdapter({ auth: getAuth(), orgContext: true }) }
|
|
499
162
|
|
|
500
|
-
|
|
501
|
-
|
|
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
|
-
));
|
|
163
|
+
// Custom Fastify plugin
|
|
164
|
+
auth: { type: 'custom', plugin: myAuthPlugin }
|
|
512
165
|
|
|
513
|
-
//
|
|
514
|
-
|
|
515
|
-
arcPlugins: {
|
|
516
|
-
events: {
|
|
517
|
-
retry: { maxRetries: 3, backoffMs: 1000 },
|
|
518
|
-
deadLetterQueue: { store: async (event, errors) => { /* custom DLQ */ } },
|
|
519
|
-
},
|
|
520
|
-
},
|
|
521
|
-
});
|
|
166
|
+
// Disabled (e.g. internal services)
|
|
167
|
+
auth: false
|
|
522
168
|
```
|
|
523
169
|
|
|
524
|
-
|
|
170
|
+
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.
|
|
525
171
|
|
|
526
|
-
|
|
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
|
-
});
|
|
547
|
-
```
|
|
172
|
+
---
|
|
548
173
|
|
|
549
|
-
|
|
174
|
+
## Subpath imports
|
|
550
175
|
|
|
551
|
-
|
|
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 |
|
|
176
|
+
Tree-shake by importing only the subpath you need:
|
|
561
177
|
|
|
562
|
-
|
|
|
563
|
-
|
|
564
|
-
|
|
|
565
|
-
|
|
|
566
|
-
|
|
|
567
|
-
|
|
|
178
|
+
| Subpath | Purpose |
|
|
179
|
+
|---|---|
|
|
180
|
+
| `@classytic/arc` | `defineResource`, `BaseController`, `createMongooseAdapter`, error classes |
|
|
181
|
+
| `@classytic/arc/factory` | `createApp`, `loadResources`, presets |
|
|
182
|
+
| `@classytic/arc/auth` | JWT + Better Auth adapters |
|
|
183
|
+
| `@classytic/arc/auth/mongoose` | Better Auth Mongoose stub models (opt-in) |
|
|
184
|
+
| `@classytic/arc/permissions` | All permission helpers |
|
|
185
|
+
| `@classytic/arc/scope` | `RequestScope` accessors (`isMember`, `isElevated`, `getOrgId`, …) |
|
|
186
|
+
| `@classytic/arc/cache` | `QueryCache`, transports, plugin |
|
|
187
|
+
| `@classytic/arc/events` | Event plugin, transports, outbox |
|
|
188
|
+
| `@classytic/arc/events/redis` · `/redis-stream` | Redis Pub/Sub + Streams transports (opt-in) |
|
|
189
|
+
| `@classytic/arc/plugins` | Health, request-id, versioning, tracing, response-cache |
|
|
190
|
+
| `@classytic/arc/integrations/jobs` | BullMQ job dispatcher |
|
|
191
|
+
| `@classytic/arc/integrations/websocket` | WebSocket integration |
|
|
192
|
+
| `@classytic/arc/mcp` | Model Context Protocol tools |
|
|
193
|
+
| `@classytic/arc/testing` | `createTestApp`, `expectArc`, `TestAuthProvider`, `createTestFixtures` |
|
|
194
|
+
| `@classytic/arc/types` | Type-only barrel (zero runtime cost) |
|
|
568
195
|
|
|
569
|
-
|
|
196
|
+
---
|
|
570
197
|
|
|
571
|
-
|
|
198
|
+
## Testing
|
|
572
199
|
|
|
573
200
|
```typescript
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
arcPlugins: { sse: { path: '/events', requireAuth: true, orgScoped: true } },
|
|
577
|
-
});
|
|
578
|
-
|
|
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
|
|
588
|
-
});
|
|
589
|
-
|
|
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
|
|
201
|
+
import { createTestApp, expectArc } from '@classytic/arc/testing';
|
|
202
|
+
import productResource from './product.resource.js';
|
|
601
203
|
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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;
|
|
204
|
+
const ctx = await createTestApp({
|
|
205
|
+
resources: [productResource],
|
|
206
|
+
authMode: 'jwt',
|
|
207
|
+
connectMongoose: true, // in-memory Mongo + Mongoose connect
|
|
614
208
|
});
|
|
209
|
+
ctx.auth.register('admin', { user: { id: '1', roles: ['admin'] }, orgId: 'org-1' });
|
|
615
210
|
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
211
|
+
const res = await ctx.app.inject({
|
|
212
|
+
method: 'POST',
|
|
213
|
+
url: '/products',
|
|
214
|
+
headers: ctx.auth.as('admin').headers,
|
|
215
|
+
payload: { name: 'Widget' },
|
|
620
216
|
});
|
|
621
|
-
|
|
217
|
+
expectArc(res).ok().hidesField('password');
|
|
622
218
|
|
|
623
|
-
|
|
624
|
-
|
|
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
|
|
219
|
+
await ctx.close();
|
|
641
220
|
```
|
|
642
221
|
|
|
643
|
-
|
|
222
|
+
Three entry points: `createTestApp` (custom scenarios), `createHttpTestHarness` (~16 auto-generated CRUD/permission/validation tests per resource), `runStorageContract` (adapter conformance).
|
|
644
223
|
|
|
645
|
-
|
|
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';
|
|
662
|
-
|
|
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
|
-
```
|
|
224
|
+
---
|
|
669
225
|
|
|
670
226
|
## CLI
|
|
671
227
|
|
|
672
228
|
```bash
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
```
|
|
680
|
-
|
|
681
|
-
`arc describe` auto-detects exported `EventRegistry` and includes the event catalog in output:
|
|
682
|
-
|
|
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.
|
|
697
|
-
|
|
698
|
-
| What you import | JS shipped to your bundle |
|
|
699
|
-
|---|---|
|
|
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 |
|
|
704
|
-
|
|
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';
|
|
229
|
+
arc init my-api --mongokit --better-auth --ts # scaffold a new project
|
|
230
|
+
arc generate resource product # generate a resource
|
|
231
|
+
arc generate resource product --mcp # + MCP tools file
|
|
232
|
+
arc docs ./openapi.json --entry ./dist/index.js # emit OpenAPI
|
|
233
|
+
arc introspect --entry ./dist/index.js # introspect resources
|
|
234
|
+
arc doctor # diagnose env
|
|
765
235
|
```
|
|
766
236
|
|
|
767
|
-
|
|
237
|
+
---
|
|
768
238
|
|
|
769
|
-
##
|
|
239
|
+
## Documentation
|
|
770
240
|
|
|
771
|
-
- **
|
|
772
|
-
- **
|
|
773
|
-
- **
|
|
774
|
-
- **
|
|
241
|
+
- **Skill** for AI agents: `npx skills add classytic/arc` — wires arc into Claude Code / agentic flows.
|
|
242
|
+
- **Concept reference**: [wiki/index.md](wiki/index.md) — short, interlinked pages.
|
|
243
|
+
- **Guides**: [docs/](docs/) — getting-started, framework-extension, production-ops, testing, ecosystem.
|
|
244
|
+
- **Release notes**: [changelog/v2.md](changelog/v2.md).
|
|
775
245
|
|
|
776
|
-
|
|
246
|
+
---
|
|
777
247
|
|
|
778
248
|
## License
|
|
779
249
|
|