Package Exports
- @classytic/arc
- @classytic/arc/adapters
- @classytic/arc/audit
- @classytic/arc/audit/mongodb
- @classytic/arc/auth
- @classytic/arc/auth/redis
- @classytic/arc/cache
- @classytic/arc/cli
- @classytic/arc/core
- @classytic/arc/discovery
- @classytic/arc/docs
- @classytic/arc/dynamic
- @classytic/arc/events
- @classytic/arc/events/redis
- @classytic/arc/events/redis-stream
- @classytic/arc/factory
- @classytic/arc/hooks
- @classytic/arc/idempotency
- @classytic/arc/idempotency/mongodb
- @classytic/arc/idempotency/redis
- @classytic/arc/integrations
- @classytic/arc/integrations/event-gateway
- @classytic/arc/integrations/jobs
- @classytic/arc/integrations/streamline
- @classytic/arc/integrations/webhooks
- @classytic/arc/integrations/websocket
- @classytic/arc/integrations/websocket-redis
- @classytic/arc/mcp
- @classytic/arc/mcp/testing
- @classytic/arc/migrations
- @classytic/arc/org
- @classytic/arc/org/types
- @classytic/arc/permissions
- @classytic/arc/plugins
- @classytic/arc/plugins/response-cache
- @classytic/arc/plugins/tracing
- @classytic/arc/policies
- @classytic/arc/presets
- @classytic/arc/presets/tenant
- @classytic/arc/registry
- @classytic/arc/rpc
- @classytic/arc/schemas
- @classytic/arc/scope
- @classytic/arc/testing
- @classytic/arc/types
- @classytic/arc/utils
Readme
@classytic/arc
Database-agnostic resource framework for Fastify. Define resources, get CRUD routes, permissions, presets, caching, events, and OpenAPI — without boilerplate.
Requires: Fastify 5+ | Node.js 22+ | ESM only
Install
npm install @classytic/arc fastify
npm install @classytic/mongokit mongoose # MongoDB adapterQuick Start
import mongoose from 'mongoose';
import { createApp, loadResources } from '@classytic/arc/factory';
await mongoose.connect(process.env.DB_URI);
const app = await createApp({
preset: 'production',
resourcePrefix: '/api/v1',
resources: await loadResources(import.meta.url), // auto-discovers *.resource.ts
auth: { type: 'jwt', jwt: { secret: process.env.JWT_SECRET } },
cors: { origin: process.env.ALLOWED_ORIGINS?.split(',') },
});
await app.listen({ port: 8040, host: '0.0.0.0' });Three ways to register resources:
// Auto-discover from directory (recommended)
resources: await loadResources(import.meta.url), // dev/prod parity
// Explicit array
resources: [productResource, orderResource],
// Via plugins callback (full Fastify control)
plugins: async (f) => { await f.register(productResource.toPlugin()); },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.
Import compatibility: Works with relative imports and Node.js
#subpath imports. Does not support tsconfig path aliases (@/*,~/) — use explicitresources: [...]instead.
Boot Sequence
const app = await createApp({
resourcePrefix: '/api/v1',
plugins: async (f) => { await connectDB(); }, // 1. infra (DB, docs)
bootstrap: [inventoryInit, accountingInit], // 2. domain init
resources: await loadResources(import.meta.url), // 3. routes
afterResources: async (f) => { subscribeEvents(f); }, // 4. post-wiring
onReady: async (f) => { logger.info('ready'); },
});Audit (per-resource opt-in)
Clean DX without growing exclude lists:
// app.ts — register once with perResource mode
await fastify.register(auditPlugin, { autoAudit: { perResource: true } });
// order.resource.ts — opt in
defineResource({ name: 'order', audit: true });
// payment.resource.ts — only audit deletes
defineResource({ name: 'payment', audit: { operations: ['delete'] } });
// Manual logging from MCP tools or custom routes
app.post('/orders/:id/refund', async (req) => {
await app.audit.custom('order', req.params.id, 'refund', { reason }, { user });
});defineResource
Single API for a full REST resource with routes, permissions, and behaviors:
import { defineResource, createMongooseAdapter, allowPublic, roles } from '@classytic/arc';
const productResource = defineResource({
name: 'product',
adapter: createMongooseAdapter({ model: ProductModel, repository: productRepo }),
presets: ['softDelete', 'slugLookup', { name: 'multiTenant', tenantField: 'orgId' }],
permissions: {
list: allowPublic(),
get: allowPublic(),
create: roles('admin', 'editor'), // checks platform + org roles
update: roles('admin', 'editor'),
delete: roles('admin'),
},
cache: { staleTime: 30, gcTime: 300, tags: ['catalog'] }, // QueryCache (opt-in)
additionalRoutes: [
{ method: 'GET', path: '/featured', handler: 'getFeatured', permissions: allowPublic(), wrapHandler: true },
],
});
// Auto-generates: GET /, GET /:id, POST /, PATCH /:id, DELETE /:id
// Plus preset routes: GET /deleted, POST /:id/restore, GET /slug/:slugCustom primary key? Use idField for resources keyed by UUIDs, slugs, or business identifiers:
defineResource({
name: 'job',
adapter: createMongooseAdapter(JobModel, jobRepository),
idField: 'jobId', // routes + BaseController lookups + OpenAPI + MCP tools all use this
});
// GET /jobs/job-5219f346-a4d → 200 (no ObjectId pattern enforcement)Authentication
Auth uses a discriminated union — pick a type:
// Arc JWT
auth: { type: 'jwt', jwt: { secret: process.env.JWT_SECRET, expiresIn: '15m' } }
// Better Auth (recommended for SaaS with orgs)
import { createBetterAuthAdapter } from '@classytic/arc/auth';
auth: { type: 'betterAuth', betterAuth: createBetterAuthAdapter({ auth, orgContext: true }) }
// Custom plugin
auth: { type: 'custom', plugin: myAuthPlugin }
// Custom function
auth: { type: 'authenticator', authenticate: async (req, reply) => { ... } }
// Disabled
auth: falseDecorates: app.authenticate, app.optionalAuthenticate, app.authorize
Token Revocation
Arc provides the isRevoked primitive — you implement the store (Redis, DB, Better Auth):
auth: {
type: 'jwt',
jwt: { secret: process.env.JWT_SECRET },
isRevoked: async (decoded) => {
// Redis set, DB lookup, or any async check
return await redis.sismember('revoked-tokens', decoded.jti ?? decoded.id);
},
}Fail-closed: if the revocation check throws, the token is rejected.
Permissions
Function-based, composable:
import {
allowPublic, requireAuth, requireRoles, requireOwnership,
requireOrgMembership, requireOrgRole, allOf, anyOf, denyAll,
createDynamicPermissionMatrix,
} from '@classytic/arc';
permissions: {
list: allowPublic(),
get: requireAuth(),
create: requireRoles(['admin', 'editor']),
update: anyOf(requireOwnership('userId'), requireRoles(['admin'])),
delete: allOf(requireAuth(), requireRoles(['admin'])),
}Field-level permissions:
import { fields } from '@classytic/arc';
fields: {
password: fields.hidden(),
salary: fields.visibleTo(['admin', 'hr']),
role: fields.writableBy(['admin']),
email: fields.redactFor(['viewer'], '***'),
}Dynamic ACL (DB-managed):
const acl = createDynamicPermissionMatrix({
resolveRolePermissions: async (ctx) => aclService.getRoleMatrix(orgId),
cacheStore: new RedisCacheStore({ client: redis, prefix: 'acl:' }),
});
permissions: {
list: acl.canAction('product', 'read'),
create: acl.canAction('product', 'create'),
}Presets
Composable resource behaviors:
| Preset | Effect | Config |
|---|---|---|
softDelete |
GET /deleted, POST /:id/restore, deletedAt field |
{ deletedField } |
slugLookup |
GET /slug/:slug |
{ slugField } |
tree |
GET /tree, GET /:parent/children |
{ parentField } |
ownedByUser |
Auto-checks createdBy on update/delete |
{ ownerField } |
multiTenant |
Auto-filters all queries by tenant | { tenantField } |
audited |
Sets createdBy/updatedBy from user |
— |
presets: ['softDelete', { name: 'multiTenant', tenantField: 'organizationId' }]QueryCache
TanStack Query-inspired server cache with stale-while-revalidate and auto-invalidation:
// Enable globally
const app = await createApp({
arcPlugins: { queryCache: true }, // Memory store by default
});
// Per-resource config
defineResource({
name: 'product',
cache: {
staleTime: 30, // seconds fresh
gcTime: 300, // seconds stale data kept (SWR window)
tags: ['catalog'],
invalidateOn: { 'category.*': ['catalog'] }, // cross-resource
},
});How it works:
GETrequests: cached withx-cache: HIT | STALE | MISSheaderPOST/PATCH/DELETE: auto-bumps resource version, invalidating all cached queries- Cross-resource: category mutation bumps
catalogtag, invalidates product cache - Multi-tenant safe: cache keys scoped by userId + orgId
Runtime modes:
| Mode | Store | Config |
|---|---|---|
memory (default) |
MemoryCacheStore (50 MiB budget) |
Zero config |
distributed |
RedisCacheStore |
stores: { queryCache: new RedisCacheStore({ client: redis }) } |
BaseController
Override only what you need:
import { BaseController } from '@classytic/arc';
import type { IRequestContext, IControllerResponse } from '@classytic/arc';
class ProductController extends BaseController<Product> {
constructor() { super(productRepo); }
async getFeatured(req: IRequestContext): Promise<IControllerResponse> {
const products = await this.repository.getAll({ filters: { isFeatured: true } });
return { success: true, data: products };
}
}Events
Domain event pub/sub with pluggable transports. The factory auto-registers eventPlugin — no manual setup needed:
// createApp() registers eventPlugin automatically (default: MemoryEventTransport)
// Transport is sourced from stores.events if provided
const app = await createApp({
stores: { events: new RedisEventTransport(redis) }, // optional, defaults to memory
arcPlugins: {
events: { // event plugin config (default: true)
logEvents: true,
retry: { maxRetries: 3, backoffMs: 1000 },
},
},
});
await app.events.publish('order.created', { orderId: '123' });
await app.events.subscribe('order.*', async (event) => { ... });CRUD events (product.created, product.updated, product.deleted) emit automatically.
defineEvent — Typed Events with Schema Validation
Declare events with schemas for runtime validation and introspection:
import { defineEvent, createEventRegistry } from '@classytic/arc/events';
// Define typed events
const OrderCreated = defineEvent({
name: 'order.created',
version: 1,
description: 'Emitted when an order is placed',
schema: {
type: 'object',
properties: {
orderId: { type: 'string' },
total: { type: 'number' },
currency: { type: 'string' },
},
required: ['orderId', 'total'],
},
});
// Type-safe event creation
const event = OrderCreated.create({ orderId: 'o-1', total: 100 }, { userId: 'user-1' });
await app.events.publish(event.type, event.payload, event.meta);Event Registry — catalog + auto-validation on publish:
const registry = createEventRegistry();
registry.register(OrderCreated);
registry.register(OrderShipped);
// Wire into eventPlugin — validates payloads on publish
const app = await createApp({
arcPlugins: {
events: { registry, validateMode: 'warn' },
// 'warn' (default): log warning, still publish
// 'reject': throw error, do NOT publish
// 'off': registry is introspection-only
},
});
// Introspect at runtime
app.events.registry?.catalog();
// → [{ name: 'order.created', version: 1, schema: {...} }, ...]Export the registry alongside resources for arc describe to auto-detect:
// src/events.ts
export const eventRegistry = createEventRegistry();
eventRegistry.register(OrderCreated);
eventRegistry.register(OrderShipped);Event Transports
| Transport | Import | Use Case |
|---|---|---|
MemoryEventTransport |
@classytic/arc/events |
Development, testing, single-instance |
RedisEventTransport |
@classytic/arc/events/redis |
Multi-instance pub/sub (fan-out) |
RedisStreamTransport |
@classytic/arc/events/redis-stream |
Ordered events with consumer groups |
// Redis Pub/Sub
import { RedisEventTransport } from '@classytic/arc/events/redis';
const transport = new RedisEventTransport(redis, { channel: 'arc-events' });
// Redis Streams (ordered, durable)
import { RedisStreamTransport } from '@classytic/arc/events/redis-stream';
const transport = new RedisStreamTransport(redis, { stream: 'arc-events' });Behavioral contract:
- Memory: Handlers execute sequentially (ordered, awaited)
- Redis Pub/Sub: Handlers fire-and-forget (unordered, fan-out)
- Redis Streams: Ordered delivery with consumer group acknowledgment
Retry & Dead Letter Queue
import { withRetry, createDeadLetterPublisher } from '@classytic/arc/events';
// Per-handler retry with exponential backoff
await app.events.subscribe('order.created', withRetry(
async (event) => { await sendConfirmationEmail(event.payload); },
{
maxRetries: 3,
backoffMs: 1000,
onDead: createDeadLetterPublisher(app.events), // publishes to $deadLetter channel
},
));
// Or configure auto-retry for ALL handlers via plugin
const app = await createApp({
arcPlugins: {
events: {
retry: { maxRetries: 3, backoffMs: 1000 },
deadLetterQueue: { store: async (event, errors) => { /* custom DLQ */ } },
},
},
});Factory — createApp()
const app = await createApp({
preset: 'production', // production | development | testing | edge
runtime: 'memory', // memory (default) | distributed (requires Redis)
auth: { type: 'jwt', jwt: { secret } },
cors: { origin: ['https://myapp.com'] },
helmet: true, // false to disable
rateLimit: { max: 100 }, // false to disable
arcPlugins: {
events: true, // event plugin (default: true, false to disable)
emitEvents: true, // CRUD event emission (default: true)
queryCache: true, // server cache (default: false)
sse: true, // server-sent events (default: false)
caching: true, // ETag + Cache-Control (default: false)
},
stores: { // required when runtime: 'distributed'
events: new RedisEventTransport({ client: redis }),
cache: new RedisCacheStore({ client: redis }),
queryCache: new RedisCacheStore({ client: redis, prefix: 'arc:qc:' }),
},
});Arc plugins defaults:
| Plugin | Default | Status |
|---|---|---|
events |
true |
opt-out — registers eventPlugin (provides fastify.events) |
emitEvents |
true |
opt-out — CRUD operations emit domain events |
requestId |
true |
opt-out |
health |
true |
opt-out |
gracefulShutdown |
true |
opt-out |
caching |
false |
opt-in — ETag + Cache-Control headers |
queryCache |
false |
opt-in — TanStack Query-inspired server cache |
sse |
false |
opt-in — Server-Sent Events streaming |
| Preset | Logging | Rate Limit | Security |
|---|---|---|---|
| production | info | 100/min | full |
| development | debug | 1000/min | relaxed |
| testing | silent | disabled | minimal |
| edge | warn | disabled | none (API GW handles) |
Real-Time
SSE and WebSocket with fail-closed auth (throws at registration if auth missing):
// SSE — via factory
const app = await createApp({
arcPlugins: { sse: { path: '/events', requireAuth: true, orgScoped: true } },
});
// WebSocket — separate plugin
import { websocketPlugin } from '@classytic/arc/integrations/websocket';
await app.register(websocketPlugin, {
auth: true, // fail-closed: throws if authenticate not registered
resources: ['product', 'order'],
roomPolicy: (client, room) => ['product', 'order'].includes(room),
reauthInterval: 300000, // re-validate token every 5 min (0 = disabled)
maxMessageBytes: 16384, // 16KB message size cap
maxSubscriptionsPerClient: 100, // prevent resource exhaustion
});
// EventGateway — unified SSE + WebSocket with shared config
import { eventGatewayPlugin } from '@classytic/arc/integrations/event-gateway';
await app.register(eventGatewayPlugin, {
auth: true, orgScoped: true,
roomPolicy: (client, room) => allowedRooms.includes(room),
sse: { path: '/api/events', patterns: ['order.*'] },
ws: { path: '/ws', resources: ['product', 'order'] },
});Pipeline — Guards, Transforms, Interceptors
Functional composition for cross-cutting concerns:
import { pipe, guard, transform, intercept } from '@classytic/arc';
const isActive = guard('isActive', (ctx) => ctx.query?.filters?.isActive !== false);
const slugify = transform('slugify', (ctx) => ({ ...ctx, body: { ...ctx.body, slug: toSlug(ctx.body.name) } }));
const timing = intercept('timing', async (ctx, next) => {
const start = Date.now();
const result = await next();
console.log(`${ctx.resource}.${ctx.operation}: ${Date.now() - start}ms`);
return result;
});
defineResource({
name: 'product',
pipe: pipe(isActive, slugify, timing),
// or per-operation: pipe: { create: pipe(slugify), list: pipe(timing) }
});Utilities
// Circuit Breaker — fault tolerance for external service calls
import { createCircuitBreaker } from '@classytic/arc/utils';
const paymentBreaker = createCircuitBreaker(
async (amount) => stripe.charges.create({ amount }),
{ name: 'stripe', failureThreshold: 5, resetTimeout: 30000, fallback: async () => cached },
);
// State Machine — workflow validation
import { createStateMachine } from '@classytic/arc/utils';
const orderState = createStateMachine('Order', {
approve: ['pending', 'draft'],
cancel: ['pending', 'approved'],
fulfill: { from: ['approved'], to: 'fulfilled', guard: ({ data }) => data.paid },
});
orderState.assert('approve', currentStatus); // throws if invalid transitionIntegrations
All separate subpath imports — only loaded when used:
// Job Queue (BullMQ)
import { jobsPlugin, defineJob } from '@classytic/arc/integrations/jobs';
// WebSocket (room-based, CRUD auto-broadcast)
import { websocketPlugin } from '@classytic/arc/integrations/websocket';
// EventGateway (unified SSE + WebSocket)
import { eventGatewayPlugin } from '@classytic/arc/integrations/event-gateway';
// Streamline Workflows
import { streamlinePlugin } from '@classytic/arc/integrations/streamline';
// Audit Trail
import { auditPlugin } from '@classytic/arc/audit';
// Idempotency (exactly-once mutations)
import { idempotencyPlugin } from '@classytic/arc/idempotency';
// OpenTelemetry Tracing
import { tracingPlugin } from '@classytic/arc/plugins/tracing';CLI
npx @classytic/arc init my-api --mongokit --better-auth --ts # Scaffold project
npx @classytic/arc generate resource product # Generate resource files
npx @classytic/arc describe ./dist/index.js # Resource metadata (JSON)
npx @classytic/arc docs ./openapi.json --entry ./dist/index.js # Export OpenAPI
npx @classytic/arc introspect --entry ./dist/index.js # Show resources
npx @classytic/arc doctor # Health checkarc describe auto-detects exported EventRegistry and includes the event catalog in output:
{
"$schema": "arc-describe/v1",
"resources": [...],
"eventCatalog": [
{ "name": "order.created", "version": 1, "hasSchema": true, "schemaFields": ["orderId", "total"], "requiredFields": ["orderId", "total"] }
],
"stats": { "totalResources": 5, "totalRoutes": 28, "totalCatalogedEvents": 3 }
}Subpath Imports
| Import | Purpose |
|---|---|
@classytic/arc |
Core: defineResource, BaseController, permissions, errors |
@classytic/arc/factory |
createApp(), presets |
@classytic/arc/cache |
MemoryCacheStore, RedisCacheStore, QueryCache |
@classytic/arc/auth |
Auth plugin, Better Auth adapter, session manager |
@classytic/arc/events |
Event plugin, transports, defineEvent, createEventRegistry |
@classytic/arc/events/redis |
Redis Pub/Sub event transport |
@classytic/arc/events/redis-stream |
Redis Streams event transport |
@classytic/arc/plugins |
Health, graceful shutdown, request ID, SSE, caching |
@classytic/arc/plugins/tracing |
OpenTelemetry |
@classytic/arc/permissions |
All permission functions, role hierarchy |
@classytic/arc/scope |
Request scope helpers (isMember, isElevated, getOrgId) |
@classytic/arc/org |
Organization module |
@classytic/arc/hooks |
Lifecycle hooks |
@classytic/arc/presets |
Preset functions + interfaces |
@classytic/arc/audit |
Audit trail |
@classytic/arc/idempotency |
Idempotency |
@classytic/arc/policies |
Policy engine |
@classytic/arc/schemas |
TypeBox helpers |
@classytic/arc/utils |
Errors, circuit breaker, state machine, query parser |
@classytic/arc/testing |
Test utilities, mocks, in-memory DB |
@classytic/arc/migrations |
Schema migrations |
@classytic/arc/integrations/jobs |
BullMQ job queue |
@classytic/arc/integrations/websocket |
WebSocket |
@classytic/arc/integrations/event-gateway |
Unified SSE + WebSocket gateway |
@classytic/arc/integrations/streamline |
Workflow orchestration |
@classytic/arc/docs |
OpenAPI generation |
@classytic/arc/cli |
CLI commands (programmatic) |
License
MIT