@classytic/arc 2.3.0 → 2.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +187 -18
- package/bin/arc.js +11 -3
- package/dist/BaseController-CkM5dUh_.mjs +1031 -0
- package/dist/{EventTransport-BkUDYZEb.d.mts → EventTransport-wc5hSLik.d.mts} +1 -1
- package/dist/{HookSystem-BsGV-j2l.mjs → HookSystem-COkyWztM.mjs} +2 -3
- package/dist/{ResourceRegistry-7Ic20ZMw.mjs → ResourceRegistry-DeCIFlix.mjs} +8 -5
- package/dist/adapters/index.d.mts +3 -5
- package/dist/adapters/index.mjs +2 -3
- package/dist/{prisma-DJbMt3yf.mjs → adapters-DTC4Ug66.mjs} +45 -12
- package/dist/audit/index.d.mts +4 -7
- package/dist/audit/index.mjs +2 -29
- package/dist/audit/mongodb.d.mts +1 -4
- package/dist/audit/mongodb.mjs +2 -3
- package/dist/auth/index.d.mts +7 -9
- package/dist/auth/index.mjs +65 -63
- package/dist/auth/redis-session.d.mts +1 -1
- package/dist/auth/redis-session.mjs +1 -2
- package/dist/{betterAuthOpenApi-DjWDddNc.mjs → betterAuthOpenApi-lz0IRbXJ.mjs} +4 -6
- package/dist/cache/index.d.mts +23 -23
- package/dist/cache/index.mjs +4 -6
- package/dist/{caching-GSDJcA6-.mjs → caching-BSXB-Xr7.mjs} +2 -24
- package/dist/chunk-BpYLSNr0.mjs +14 -0
- package/dist/circuitBreaker-BOBOpN2w.mjs +284 -0
- package/dist/circuitBreaker-JP2GdJ4b.d.mts +206 -0
- package/dist/cli/commands/describe.mjs +24 -7
- package/dist/cli/commands/docs.mjs +6 -7
- package/dist/cli/commands/doctor.d.mts +10 -0
- package/dist/cli/commands/doctor.mjs +156 -0
- package/dist/cli/commands/generate.mjs +66 -17
- package/dist/cli/commands/init.mjs +315 -45
- package/dist/cli/commands/introspect.mjs +2 -4
- package/dist/cli/index.d.mts +1 -10
- package/dist/cli/index.mjs +4 -153
- package/dist/{constants-DdXFXQtN.mjs → constants-Cxde4rpC.mjs} +1 -2
- package/dist/core/index.d.mts +3 -5
- package/dist/core/index.mjs +5 -4
- package/dist/core-C1XCMtqM.mjs +185 -0
- package/dist/{createApp-CgKOPhA4.mjs → createApp-ByWNRsZj.mjs} +64 -35
- package/dist/{defineResource-DWbpJYtm.mjs → defineResource-D9aY5Cy6.mjs} +108 -1157
- package/dist/discovery/index.mjs +37 -5
- package/dist/docs/index.d.mts +6 -9
- package/dist/docs/index.mjs +3 -21
- package/dist/dynamic/index.d.mts +93 -0
- package/dist/dynamic/index.mjs +122 -0
- package/dist/{elevation-DSTbVvYj.mjs → elevation-BEdACOLB.mjs} +5 -36
- package/dist/{elevation-DGo5shaX.d.mts → elevation-Ca_yveIO.d.mts} +41 -7
- package/dist/{errorHandler-C3GY3_ow.mjs → errorHandler--zp54tGc.mjs} +3 -5
- package/dist/errorHandler-Do4vVQ1f.d.mts +139 -0
- package/dist/{errors-DBANPbGr.mjs → errors-rxhfP7Hf.mjs} +1 -2
- package/dist/{eventPlugin-BEOvaDqo.mjs → eventPlugin-Ba00swHF.mjs} +25 -27
- package/dist/{eventPlugin-H6wDDjGO.d.mts → eventPlugin-iGrSEmwJ.d.mts} +105 -5
- package/dist/events/index.d.mts +72 -7
- package/dist/events/index.mjs +216 -4
- package/dist/events/transports/redis-stream-entry.d.mts +1 -1
- package/dist/events/transports/redis-stream-entry.mjs +19 -7
- package/dist/events/transports/redis.d.mts +1 -1
- package/dist/events/transports/redis.mjs +3 -4
- package/dist/factory/index.d.mts +23 -9
- package/dist/factory/index.mjs +48 -3
- package/dist/{fields-Bi_AVKSo.d.mts → fields-DFwdaWCq.d.mts} +1 -1
- package/dist/{fields-CTd_CrKr.mjs → fields-ipsbIRPK.mjs} +1 -2
- package/dist/hooks/index.d.mts +1 -3
- package/dist/hooks/index.mjs +2 -3
- package/dist/idempotency/index.d.mts +5 -5
- package/dist/idempotency/index.mjs +3 -7
- package/dist/idempotency/mongodb.d.mts +1 -1
- package/dist/idempotency/mongodb.mjs +4 -5
- package/dist/idempotency/redis.d.mts +1 -1
- package/dist/idempotency/redis.mjs +2 -5
- package/dist/{fastifyAdapter-6b_eRDBw.d.mts → index-BL8CaQih.d.mts} +56 -57
- package/dist/index-Diqcm14c.d.mts +369 -0
- package/dist/{prisma-Dy5S5F5i.d.mts → index-yhxyjqNb.d.mts} +4 -5
- package/dist/index.d.mts +100 -105
- package/dist/index.mjs +85 -58
- package/dist/integrations/event-gateway.d.mts +1 -1
- package/dist/integrations/event-gateway.mjs +8 -4
- package/dist/integrations/index.d.mts +4 -2
- package/dist/integrations/index.mjs +1 -1
- package/dist/integrations/jobs.d.mts +2 -2
- package/dist/integrations/jobs.mjs +63 -14
- package/dist/integrations/mcp/index.d.mts +219 -0
- package/dist/integrations/mcp/index.mjs +572 -0
- package/dist/integrations/mcp/testing.d.mts +53 -0
- package/dist/integrations/mcp/testing.mjs +104 -0
- package/dist/integrations/streamline.mjs +39 -19
- package/dist/integrations/webhooks.d.mts +56 -0
- package/dist/integrations/webhooks.mjs +139 -0
- package/dist/integrations/websocket-redis.d.mts +46 -0
- package/dist/integrations/websocket-redis.mjs +50 -0
- package/dist/integrations/websocket.d.mts +68 -2
- package/dist/integrations/websocket.mjs +96 -13
- package/dist/{interface-CSNjltAc.d.mts → interface-B4awm1RJ.d.mts} +2 -2
- package/dist/interface-DGmPxakH.d.mts +2213 -0
- package/dist/{keys-DhqDRxv3.mjs → keys-qcD-TVJl.mjs} +3 -4
- package/dist/{logger-ByrvQWZO.mjs → logger-Dz3j1ItV.mjs} +2 -4
- package/dist/{memory-B2v7KrCB.mjs → memory-Cb_7iy9e.mjs} +2 -4
- package/dist/metrics-Csh4nsvv.mjs +224 -0
- package/dist/migrations/index.d.mts +113 -44
- package/dist/migrations/index.mjs +84 -102
- package/dist/{mongodb-DNKEExbf.mjs → mongodb-BuQ7fNTg.mjs} +1 -4
- package/dist/{mongodb-ClykrfGo.d.mts → mongodb-CUpYfxfD.d.mts} +2 -3
- package/dist/{mongodb-Dg8O_gvd.d.mts → mongodb-bga9AbkD.d.mts} +2 -2
- package/dist/{openapi-9nB_kiuR.mjs → openapi-CBmZ6EQN.mjs} +4 -21
- package/dist/org/index.d.mts +12 -14
- package/dist/org/index.mjs +92 -119
- package/dist/org/types.d.mts +2 -2
- package/dist/org/types.mjs +1 -1
- package/dist/permissions/index.d.mts +4 -278
- package/dist/permissions/index.mjs +4 -579
- package/dist/permissions-CA5zg0yK.mjs +751 -0
- package/dist/plugins/index.d.mts +104 -107
- package/dist/plugins/index.mjs +203 -313
- package/dist/plugins/response-cache.mjs +4 -69
- package/dist/plugins/tracing-entry.d.mts +1 -1
- package/dist/plugins/tracing-entry.mjs +24 -11
- package/dist/{pluralize-CM-jZg7p.mjs → pluralize-CcT6qF0a.mjs} +12 -13
- package/dist/policies/index.d.mts +2 -2
- package/dist/policies/index.mjs +80 -83
- package/dist/presets/index.d.mts +26 -19
- package/dist/presets/index.mjs +2 -142
- package/dist/presets/multiTenant.d.mts +1 -4
- package/dist/presets/multiTenant.mjs +4 -6
- package/dist/presets-C9QXJV1u.mjs +422 -0
- package/dist/{queryCachePlugin-B6R0d4av.mjs → queryCachePlugin-ClosZdNS.mjs} +6 -27
- package/dist/{queryCachePlugin-Q6SYuHZ6.d.mts → queryCachePlugin-DcmETvcB.d.mts} +3 -3
- package/dist/queryParser-CgCtsjti.mjs +352 -0
- package/dist/{redis-UwjEp8Ea.d.mts → redis-CQ5YxMC5.d.mts} +2 -2
- package/dist/{redis-stream-CBg0upHI.d.mts → redis-stream-BW9UKLZM.d.mts} +9 -2
- package/dist/registry/index.d.mts +1 -4
- package/dist/registry/index.mjs +3 -4
- package/dist/{introspectionPlugin-B3JkrjwU.mjs → registry-I-ogLgL9.mjs} +1 -8
- package/dist/{requestContext-xi6OKBL-.mjs → requestContext-DYtmNpm5.mjs} +1 -3
- package/dist/resourceToTools-PMFE8HIv.mjs +533 -0
- package/dist/rpc/index.d.mts +90 -0
- package/dist/rpc/index.mjs +248 -0
- package/dist/{schemaConverter-Dtg0Kt9T.mjs → schemaConverter-DjzHpFam.mjs} +1 -2
- package/dist/schemas/index.d.mts +30 -30
- package/dist/schemas/index.mjs +2 -4
- package/dist/scope/index.d.mts +13 -2
- package/dist/scope/index.mjs +18 -5
- package/dist/{sessionManager-D_iEHjQl.d.mts → sessionManager-wbkYj2HL.d.mts} +2 -2
- package/dist/{sse-DkqQ1uxb.mjs → sse-BkViJPlT.mjs} +4 -25
- package/dist/testing/index.d.mts +551 -567
- package/dist/testing/index.mjs +1744 -1799
- package/dist/{tracing-8CEbhF0w.d.mts → tracing-bz_U4EM1.d.mts} +6 -1
- package/dist/{typeGuards-DwxA1t_L.mjs → typeGuards-Cj5Rgvlg.mjs} +1 -2
- package/dist/types/index.d.mts +4 -946
- package/dist/types/index.mjs +2 -4
- package/dist/types-BJmgxNbF.d.mts +275 -0
- package/dist/{types-RLkFVgaw.d.mts → types-BNUccdcf.d.mts} +2 -2
- package/dist/{types-Beqn1Un7.mjs → types-C6TQjtdi.mjs} +30 -2
- package/dist/{types-tKwaViYB.d.mts → types-Dt0-AI6E.d.mts} +68 -27
- package/dist/{types-DelU6kln.mjs → types-ZUu_h0jp.mjs} +1 -2
- package/dist/utils/index.d.mts +254 -351
- package/dist/utils/index.mjs +7 -6
- package/dist/utils-Dc0WhlIl.mjs +594 -0
- package/dist/versioning-BzfeHmhj.mjs +37 -0
- package/package.json +44 -10
- package/skills/arc/SKILL.md +518 -0
- package/skills/arc/references/auth.md +250 -0
- package/skills/arc/references/events.md +272 -0
- package/skills/arc/references/integrations.md +385 -0
- package/skills/arc/references/mcp.md +431 -0
- package/skills/arc/references/production.md +610 -0
- package/skills/arc/references/testing.md +183 -0
- package/dist/audited-CGdLiSlE.mjs +0 -140
- package/dist/chunk-C7Uep-_p.mjs +0 -20
- package/dist/circuitBreaker-CSS2VvL6.mjs +0 -1109
- package/dist/errorHandler-CW3OOeYq.d.mts +0 -72
- package/dist/interface-BtdYtQUA.d.mts +0 -1114
- package/dist/presets-BTeYbw7h.d.mts +0 -57
- package/dist/presets-CeFtfDR8.mjs +0 -119
- /package/dist/{errors-DAWRdiYP.d.mts → errors-CPpvPHT0.d.mts} +0 -0
- /package/dist/{externalPaths-SyPF2tgK.d.mts → externalPaths-DpO-s7r8.d.mts} +0 -0
- /package/dist/{interface-DTbsvIWe.d.mts → interface-D_BWALyZ.d.mts} +0 -0
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
# Arc Production Features
|
|
2
|
+
|
|
3
|
+
Health checks, audit trail, idempotency, tracing, SSE, caching, graceful shutdown.
|
|
4
|
+
|
|
5
|
+
## Health Plugin
|
|
6
|
+
|
|
7
|
+
Kubernetes-ready liveness/readiness probes:
|
|
8
|
+
|
|
9
|
+
```typescript
|
|
10
|
+
import { healthPlugin } from '@classytic/arc/plugins';
|
|
11
|
+
|
|
12
|
+
await fastify.register(healthPlugin, {
|
|
13
|
+
prefix: '/_health',
|
|
14
|
+
version: '1.0.0',
|
|
15
|
+
checks: [
|
|
16
|
+
{ name: 'mongodb', check: () => mongoose.connection.readyState === 1, critical: true },
|
|
17
|
+
{ name: 'redis', check: async () => await redis.ping() === 'PONG', timeout: 3000 },
|
|
18
|
+
],
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// GET /_health/live → { status: 'ok', timestamp, version }
|
|
22
|
+
// GET /_health/ready → { status: 'ok', checks: { mongodb: { status: 'ok', latency: 5 } } }
|
|
23
|
+
// Returns 503 if critical check fails
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Request ID Plugin
|
|
27
|
+
|
|
28
|
+
```typescript
|
|
29
|
+
import { requestIdPlugin } from '@classytic/arc/plugins';
|
|
30
|
+
|
|
31
|
+
await fastify.register(requestIdPlugin, {
|
|
32
|
+
header: 'x-request-id',
|
|
33
|
+
setResponseHeader: true,
|
|
34
|
+
generator: () => crypto.randomUUID(),
|
|
35
|
+
});
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Graceful Shutdown Plugin
|
|
39
|
+
|
|
40
|
+
```typescript
|
|
41
|
+
import { gracefulShutdownPlugin } from '@classytic/arc/plugins';
|
|
42
|
+
|
|
43
|
+
await fastify.register(gracefulShutdownPlugin, {
|
|
44
|
+
timeout: 30000,
|
|
45
|
+
signals: ['SIGTERM', 'SIGINT'],
|
|
46
|
+
logEvents: true,
|
|
47
|
+
onShutdown: async () => {
|
|
48
|
+
await mongoose.disconnect();
|
|
49
|
+
await redis.quit();
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// Sequence: receive signal → stop accepting connections → wait for in-flight
|
|
54
|
+
// → run onShutdown → close Fastify → exit process
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Audit Plugin
|
|
58
|
+
|
|
59
|
+
Change tracking with pluggable storage:
|
|
60
|
+
|
|
61
|
+
```typescript
|
|
62
|
+
import { auditPlugin } from '@classytic/arc/audit';
|
|
63
|
+
|
|
64
|
+
// Development
|
|
65
|
+
await fastify.register(auditPlugin, { enabled: true, stores: ['memory'] });
|
|
66
|
+
|
|
67
|
+
// Production
|
|
68
|
+
await fastify.register(auditPlugin, {
|
|
69
|
+
enabled: true,
|
|
70
|
+
stores: ['mongodb'],
|
|
71
|
+
mongoConnection: mongoose.connection,
|
|
72
|
+
mongoCollection: 'audit_logs',
|
|
73
|
+
ttlDays: 90, // Auto-cleanup via TTL index
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Usage
|
|
77
|
+
await fastify.audit.create('product', product._id, product, request.auditContext);
|
|
78
|
+
await fastify.audit.update('product', id, beforeDoc, afterDoc, request.auditContext);
|
|
79
|
+
await fastify.audit.delete('product', id, deletedDoc, request.auditContext);
|
|
80
|
+
await fastify.audit.custom('product', id, 'price_changed', { oldPrice: 100, newPrice: 150 }, ctx);
|
|
81
|
+
|
|
82
|
+
// Query
|
|
83
|
+
const entries = await fastify.audit.query({
|
|
84
|
+
resource: 'product', documentId: 'prod-123',
|
|
85
|
+
action: 'update', from: new Date('2024-01-01'), limit: 100,
|
|
86
|
+
});
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
**Audit entry:**
|
|
90
|
+
|
|
91
|
+
```typescript
|
|
92
|
+
interface AuditEntry {
|
|
93
|
+
id: string; timestamp: Date;
|
|
94
|
+
action: 'create' | 'update' | 'delete' | 'restore' | 'custom';
|
|
95
|
+
resource: string; documentId: string;
|
|
96
|
+
before?: Record<string, unknown>;
|
|
97
|
+
after?: Record<string, unknown>;
|
|
98
|
+
metadata?: Record<string, unknown>;
|
|
99
|
+
context: { user?; organizationId?; requestId?; ipAddress?; userAgent?; };
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
**Custom store:** Implement `AuditStore` interface (`log`, `query`, `close`).
|
|
104
|
+
|
|
105
|
+
## Idempotency Plugin
|
|
106
|
+
|
|
107
|
+
Exactly-once semantics for mutating operations:
|
|
108
|
+
|
|
109
|
+
```typescript
|
|
110
|
+
import { idempotencyPlugin } from '@classytic/arc/idempotency';
|
|
111
|
+
|
|
112
|
+
await fastify.register(idempotencyPlugin, {
|
|
113
|
+
enabled: true,
|
|
114
|
+
headerName: 'idempotency-key', // Default
|
|
115
|
+
ttlMs: 86400000, // 24 hours
|
|
116
|
+
lockTimeoutMs: 30000,
|
|
117
|
+
methods: ['POST', 'PUT', 'PATCH'],
|
|
118
|
+
include: [/\/orders/], // Only these routes
|
|
119
|
+
exclude: [/\/health/],
|
|
120
|
+
retryAfterSeconds: 1,
|
|
121
|
+
});
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
**Client:**
|
|
125
|
+
|
|
126
|
+
```typescript
|
|
127
|
+
fetch('/api/orders', {
|
|
128
|
+
method: 'POST',
|
|
129
|
+
headers: { 'Idempotency-Key': 'order-abc123-uuid' },
|
|
130
|
+
body: JSON.stringify({ items: [...] }),
|
|
131
|
+
});
|
|
132
|
+
// First request: processes, caches response
|
|
133
|
+
// Retry: returns cached response + x-idempotency-replayed: true
|
|
134
|
+
// Concurrent: returns 409 Conflict + Retry-After: 1
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
**Storage backends:**
|
|
138
|
+
|
|
139
|
+
```typescript
|
|
140
|
+
// Memory (default, dev)
|
|
141
|
+
import { MemoryIdempotencyStore } from '@classytic/arc/idempotency';
|
|
142
|
+
|
|
143
|
+
// Redis (production, multi-instance)
|
|
144
|
+
import { RedisIdempotencyStore } from '@classytic/arc/idempotency/redis';
|
|
145
|
+
store: new RedisIdempotencyStore({ client: redis, prefix: 'idem:', ttlMs: 86400000 })
|
|
146
|
+
|
|
147
|
+
// MongoDB (production, no Redis)
|
|
148
|
+
import { MongoIdempotencyStore } from '@classytic/arc/idempotency/mongodb';
|
|
149
|
+
store: new MongoIdempotencyStore({ connection: mongoose.connection, collection: 'arc_idempotency', createIndex: true })
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
**IdempotencyStore interface:**
|
|
153
|
+
|
|
154
|
+
```typescript
|
|
155
|
+
interface IdempotencyStore {
|
|
156
|
+
readonly name: string;
|
|
157
|
+
get(key: string): Promise<IdempotencyResult | undefined>;
|
|
158
|
+
set(key: string, result): Promise<void>;
|
|
159
|
+
tryLock(key: string, requestId: string, ttlMs: number): Promise<boolean>;
|
|
160
|
+
unlock(key: string, requestId: string): Promise<void>;
|
|
161
|
+
isLocked(key: string): Promise<boolean>;
|
|
162
|
+
delete(key: string): Promise<void>;
|
|
163
|
+
close?(): Promise<void>;
|
|
164
|
+
}
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## OpenTelemetry Tracing
|
|
168
|
+
|
|
169
|
+
```typescript
|
|
170
|
+
import { tracingPlugin } from '@classytic/arc/plugins/tracing';
|
|
171
|
+
|
|
172
|
+
await fastify.register(tracingPlugin, {
|
|
173
|
+
serviceName: 'my-api',
|
|
174
|
+
exporterUrl: 'http://localhost:4318/v1/traces',
|
|
175
|
+
sampleRate: 0.1, // Trace 10% of requests
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// Custom spans
|
|
179
|
+
import { createSpan } from '@classytic/arc/plugins/tracing';
|
|
180
|
+
|
|
181
|
+
return createSpan(req, 'processPayment', async (span) => {
|
|
182
|
+
span.setAttribute('orderId', order._id);
|
|
183
|
+
return await processPayment(order);
|
|
184
|
+
});
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
## QueryCache (Server Cache)
|
|
188
|
+
|
|
189
|
+
TanStack Query-inspired server cache with stale-while-revalidate and auto-invalidation on mutations.
|
|
190
|
+
|
|
191
|
+
```typescript
|
|
192
|
+
// Enable globally
|
|
193
|
+
const app = await createApp({
|
|
194
|
+
arcPlugins: { queryCache: true }, // Memory store, zero config
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// Per-resource config
|
|
198
|
+
defineResource({
|
|
199
|
+
name: 'product',
|
|
200
|
+
cache: {
|
|
201
|
+
staleTime: 30, // seconds fresh (no revalidation)
|
|
202
|
+
gcTime: 300, // seconds stale data kept (SWR window)
|
|
203
|
+
tags: ['catalog'], // cross-resource grouping
|
|
204
|
+
invalidateOn: { 'category.*': ['catalog'] }, // event → tag invalidation
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
**How it works:**
|
|
210
|
+
- `GET` → cached with `x-cache: HIT | STALE | MISS` header
|
|
211
|
+
- `POST/PATCH/DELETE` → auto-bumps resource version, invalidating cached queries
|
|
212
|
+
- Cross-resource: category mutation bumps `catalog` tag → products cache invalidated
|
|
213
|
+
- Multi-tenant safe: cache keys scoped by userId + orgId
|
|
214
|
+
|
|
215
|
+
**Runtime modes:**
|
|
216
|
+
|
|
217
|
+
| Mode | Store | Config |
|
|
218
|
+
|------|-------|--------|
|
|
219
|
+
| `memory` (default) | `MemoryCacheStore` (50 MiB budget) | Zero config |
|
|
220
|
+
| `distributed` | `RedisCacheStore` | `stores: { queryCache: new RedisCacheStore({ client: redis }) }` |
|
|
221
|
+
|
|
222
|
+
## Response Cache Plugin
|
|
223
|
+
|
|
224
|
+
```typescript
|
|
225
|
+
import { responseCachePlugin } from '@classytic/arc/plugins/response-cache';
|
|
226
|
+
|
|
227
|
+
await fastify.register(responseCachePlugin, {
|
|
228
|
+
// ETag generation + Cache-Control headers per route
|
|
229
|
+
});
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
**Note:** When QueryCache is active for a resource, response-cache is automatically skipped for that resource's GET routes.
|
|
233
|
+
|
|
234
|
+
## SSE Plugin (Server-Sent Events)
|
|
235
|
+
|
|
236
|
+
Bridges Arc domain events to SSE streams. Requires `eventPlugin` (auto-registered by factory).
|
|
237
|
+
|
|
238
|
+
```typescript
|
|
239
|
+
// Via factory (recommended)
|
|
240
|
+
const app = await createApp({
|
|
241
|
+
arcPlugins: {
|
|
242
|
+
sse: {
|
|
243
|
+
path: '/events/stream', // SSE endpoint (default: '/events/stream')
|
|
244
|
+
requireAuth: true, // Fail-closed auth (default: true)
|
|
245
|
+
patterns: ['order.*', 'product.*'], // Event patterns to stream (default: ['*'])
|
|
246
|
+
orgScoped: false, // Filter events by org from request.scope (default: false)
|
|
247
|
+
heartbeat: 30000, // Heartbeat interval in ms (default: 30000)
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// Manual registration
|
|
253
|
+
import { ssePlugin } from '@classytic/arc/plugins';
|
|
254
|
+
await fastify.register(ssePlugin, { requireAuth: true, orgScoped: true });
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
**Fail-closed auth:** When `requireAuth: true` (default), throws at registration if `fastify.authenticate` is missing — prevents exposing SSE without auth.
|
|
258
|
+
|
|
259
|
+
**Org-scoped:** When `orgScoped: true`, events with `organizationId` are only sent to clients whose `request.scope` matches. Prevents cross-tenant leakage.
|
|
260
|
+
|
|
261
|
+
## Error Handler Plugin
|
|
262
|
+
|
|
263
|
+
```typescript
|
|
264
|
+
import { errorHandlerPlugin } from '@classytic/arc/plugins';
|
|
265
|
+
|
|
266
|
+
// Global error handling — catches all errors, logs, formats response
|
|
267
|
+
// Auto-registered by createApp()
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
## Migrations
|
|
271
|
+
|
|
272
|
+
```typescript
|
|
273
|
+
import { defineMigration, MigrationRunner } from '@classytic/arc/migrations';
|
|
274
|
+
|
|
275
|
+
const v2 = defineMigration({
|
|
276
|
+
version: 2,
|
|
277
|
+
resource: 'product',
|
|
278
|
+
up: async (db) => {
|
|
279
|
+
await db.collection('products').updateMany({}, { $rename: { oldField: 'newField' } });
|
|
280
|
+
},
|
|
281
|
+
down: async (db) => {
|
|
282
|
+
await db.collection('products').updateMany({}, { $rename: { newField: 'oldField' } });
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
const runner = new MigrationRunner(mongoose.connection.db);
|
|
287
|
+
await runner.up([v2]);
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
## Policies
|
|
291
|
+
|
|
292
|
+
Query-level authorization — modify queries based on user:
|
|
293
|
+
|
|
294
|
+
```typescript
|
|
295
|
+
import { createAccessControlPolicy } from '@classytic/arc/policies';
|
|
296
|
+
|
|
297
|
+
const editorPolicy = createAccessControlPolicy({
|
|
298
|
+
statements: [
|
|
299
|
+
{ resource: 'product', action: ['create', 'update'] },
|
|
300
|
+
{ resource: 'order', action: ['read'] },
|
|
301
|
+
],
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
defineResource({
|
|
305
|
+
permissions: {
|
|
306
|
+
create: editorPolicy,
|
|
307
|
+
update: editorPolicy,
|
|
308
|
+
},
|
|
309
|
+
});
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
## OpenAPI & External Paths
|
|
313
|
+
|
|
314
|
+
Arc auto-generates OpenAPI 3.0 specs from resource definitions. External integrations (auth adapters, custom routes) inject their paths via `ExternalOpenApiPaths`.
|
|
315
|
+
|
|
316
|
+
```typescript
|
|
317
|
+
import type { ExternalOpenApiPaths } from '@classytic/arc/docs';
|
|
318
|
+
|
|
319
|
+
const externalPaths: ExternalOpenApiPaths = {
|
|
320
|
+
paths: { '/api/auth/sign-in': { post: { summary: 'Sign in', ... } } },
|
|
321
|
+
schemas: { User: { type: 'object', properties: { ... } } },
|
|
322
|
+
securitySchemes: {
|
|
323
|
+
cookieAuth: { type: 'apiKey', in: 'cookie', name: 'session_token' },
|
|
324
|
+
},
|
|
325
|
+
tags: [{ name: 'Authentication' }],
|
|
326
|
+
// Declare additional security alternatives for Arc resource paths
|
|
327
|
+
resourceSecurity: [{ apiKeyAuth: [], orgHeader: [] }],
|
|
328
|
+
};
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
**`resourceSecurity`** — declarative registration of auth alternatives for resource paths:
|
|
332
|
+
- Each array item is **OR**'d with `bearerAuth` (the default)
|
|
333
|
+
- Keys within the same object are **AND**'d (all required together)
|
|
334
|
+
- Example: `[{ apiKeyAuth: [], orgHeader: [] }]` → "bearer OR (api-key AND org-header)"
|
|
335
|
+
|
|
336
|
+
Arc's Better Auth adapter (`extractBetterAuthOpenApi`) auto-populates `resourceSecurity` when the `apiKey()` plugin is detected — no manual configuration needed.
|
|
337
|
+
|
|
338
|
+
**Security scheme definitions:**
|
|
339
|
+
|
|
340
|
+
| Scheme | Type | Source | Always present? |
|
|
341
|
+
|--------|------|--------|-----------------|
|
|
342
|
+
| `bearerAuth` | HTTP Bearer | Arc core | Yes |
|
|
343
|
+
| `orgHeader` | API Key (`x-organization-id`) | Arc core | Yes (multi-tenant) |
|
|
344
|
+
| `cookieAuth` | API Key (cookie) | Better Auth adapter | When Better Auth active |
|
|
345
|
+
| `apiKeyAuth` | API Key (`x-api-key`) | Better Auth adapter | When `apiKey()` plugin active |
|
|
346
|
+
|
|
347
|
+
## Deployment
|
|
348
|
+
|
|
349
|
+
Arc requires Node.js APIs (`node:crypto`, `AsyncLocalStorage`). Use `toFetchHandler()` for serverless/edge.
|
|
350
|
+
|
|
351
|
+
| Environment | Preset | Handler | Notes |
|
|
352
|
+
|-------------|--------|---------|-------|
|
|
353
|
+
| Docker/K8s | `production` | `app.listen()` | Full production |
|
|
354
|
+
| Google Cloud Run | `production` | `app.listen()` | Set min-instances > 0 for WebSocket |
|
|
355
|
+
| Railway/Render/Fly.io | `production` | `app.listen()` | Works with zero config |
|
|
356
|
+
| AWS Lambda | `edge` | `toFetchHandler()` | Node.js runtime |
|
|
357
|
+
| Vercel Serverless | `edge` | `toFetchHandler()` | Node.js runtime |
|
|
358
|
+
| Cloudflare Workers | `edge` | `toFetchHandler()` | Enable `nodejs_compat` in wrangler.toml |
|
|
359
|
+
|
|
360
|
+
**Edge handler:**
|
|
361
|
+
```typescript
|
|
362
|
+
import { createApp, toFetchHandler } from '@classytic/arc/factory';
|
|
363
|
+
const app = await createApp({ preset: 'edge', auth: { type: 'jwt', jwt: { secret } } });
|
|
364
|
+
export default { fetch: toFetchHandler(app) }; // Cloudflare Workers / any fetch-based runtime
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
**Production checklist:**
|
|
368
|
+
|
|
369
|
+
```typescript
|
|
370
|
+
// Validate env vars at startup
|
|
371
|
+
if (!process.env.JWT_SECRET || process.env.JWT_SECRET.length < 32) throw new Error('...');
|
|
372
|
+
|
|
373
|
+
const app = await createApp({
|
|
374
|
+
preset: 'production',
|
|
375
|
+
auth: { type: 'jwt', jwt: { secret: process.env.JWT_SECRET } },
|
|
376
|
+
cors: { origin: process.env.ALLOWED_ORIGINS?.split(',') ?? [], credentials: true },
|
|
377
|
+
rateLimit: { max: 100, timeWindow: '1 minute' },
|
|
378
|
+
arcPlugins: { queryCache: true },
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
process.on('SIGTERM', () => app.close());
|
|
382
|
+
process.on('SIGINT', () => app.close());
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
## Distributed Runtime
|
|
386
|
+
|
|
387
|
+
`runtime: 'distributed'` only validates stores you actually enable:
|
|
388
|
+
|
|
389
|
+
- `stores.events` — always required
|
|
390
|
+
- `stores.cache` — only when `arcPlugins.caching` enabled
|
|
391
|
+
- `stores.queryCache` — only when `arcPlugins.queryCache` enabled
|
|
392
|
+
- `stores.idempotency` — never validated (per-resource opt-in)
|
|
393
|
+
|
|
394
|
+
## Under-Pressure
|
|
395
|
+
|
|
396
|
+
Production preset: `maxEventLoopDelay: 3000` (avoids false 503s on Render/Railway/Fly.io). Override: `underPressure: { maxEventLoopDelay: 500 }`. Disable: `underPressure: false`.
|
|
397
|
+
|
|
398
|
+
## CORS
|
|
399
|
+
|
|
400
|
+
Production warns (not throws) when origin missing. `origin: '*'` from env is allowed. Smart CORS auto-converts `credentials: true` + `origin: '*'` to `origin: true`.
|
|
401
|
+
|
|
402
|
+
## Metrics Plugin
|
|
403
|
+
|
|
404
|
+
Prometheus-compatible metrics endpoint — zero external dependencies:
|
|
405
|
+
|
|
406
|
+
```typescript
|
|
407
|
+
import { metricsPlugin } from '@classytic/arc/plugins';
|
|
408
|
+
|
|
409
|
+
await fastify.register(metricsPlugin, {
|
|
410
|
+
path: '/_metrics', // default
|
|
411
|
+
prefix: 'arc', // metric name prefix (default: 'arc')
|
|
412
|
+
onCollect: (metrics) => pushToOTLP(metrics), // optional OTLP push
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
// GET /_metrics → Prometheus text format
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
**Built-in counters**: `arc_http_requests_total`, `arc_http_request_duration_seconds`, `arc_crud_operations_total`, `arc_cache_hits_total`, `arc_cache_misses_total`, `arc_events_published_total`, `arc_events_consumed_total`, `arc_circuit_breaker_state`.
|
|
419
|
+
|
|
420
|
+
**Programmatic access**: `fastify.metrics.recordOperation(resource, op, status, durationMs)`, `.recordCacheHit(resource)`, `.recordEventPublish(type)`, `.reset()`.
|
|
421
|
+
|
|
422
|
+
## API Versioning Plugin
|
|
423
|
+
|
|
424
|
+
Header-based or URL prefix-based versioning with deprecation warnings:
|
|
425
|
+
|
|
426
|
+
```typescript
|
|
427
|
+
import { versioningPlugin } from '@classytic/arc/plugins';
|
|
428
|
+
|
|
429
|
+
// Header-based: clients send Accept-Version: 2
|
|
430
|
+
await fastify.register(versioningPlugin, {
|
|
431
|
+
type: 'header',
|
|
432
|
+
deprecated: ['1'],
|
|
433
|
+
sunset: '2025-12-01',
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
// Prefix-based: /v2/products
|
|
437
|
+
await fastify.register(versioningPlugin, { type: 'prefix' });
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
**Headers set**: `x-api-version` on every response. `Deprecation: true` + `Sunset` for deprecated versions. Access via `request.apiVersion`.
|
|
441
|
+
|
|
442
|
+
## Per-Tenant Rate Limiting
|
|
443
|
+
|
|
444
|
+
Scope-aware rate limit key generator — isolates rate limits by org, user, or IP:
|
|
445
|
+
|
|
446
|
+
```typescript
|
|
447
|
+
import { createTenantKeyGenerator } from '@classytic/arc/scope';
|
|
448
|
+
|
|
449
|
+
const app = await createApp({
|
|
450
|
+
rateLimit: {
|
|
451
|
+
max: 100,
|
|
452
|
+
timeWindow: '1 minute',
|
|
453
|
+
keyGenerator: createTenantKeyGenerator(),
|
|
454
|
+
},
|
|
455
|
+
});
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
**Key resolution**: `member` → `organizationId`, `authenticated` → `userId`, `elevated` → `organizationId ?? userId`, `public` → IP. Custom strategy: `createTenantKeyGenerator({ strategy: (ctx) => ctx.ip })`.
|
|
459
|
+
|
|
460
|
+
## Event Outbox
|
|
461
|
+
|
|
462
|
+
Transactional outbox pattern — at-least-once delivery even if transport is down:
|
|
463
|
+
|
|
464
|
+
```typescript
|
|
465
|
+
import { EventOutbox, MemoryOutboxStore } from '@classytic/arc/events';
|
|
466
|
+
|
|
467
|
+
const outbox = new EventOutbox({
|
|
468
|
+
store: new MemoryOutboxStore(), // or MongoOutboxStore for production
|
|
469
|
+
transport: redisTransport,
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
// In business logic (same DB transaction)
|
|
473
|
+
await outbox.store(event);
|
|
474
|
+
|
|
475
|
+
// Relay cron (runs every few seconds)
|
|
476
|
+
const relayed = await outbox.relay(); // publishes pending → transport
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
**OutboxStore interface**: `save(event)`, `getPending(limit)`, `acknowledge(eventId)`.
|
|
480
|
+
|
|
481
|
+
## RPC Service Client — Schema Versioning
|
|
482
|
+
|
|
483
|
+
The service client supports a `schemaVersion` option for contract compatibility between services:
|
|
484
|
+
|
|
485
|
+
```typescript
|
|
486
|
+
import { createServiceClient } from '@classytic/arc/rpc';
|
|
487
|
+
|
|
488
|
+
const catalog = createServiceClient({
|
|
489
|
+
baseUrl: 'http://catalog:3000',
|
|
490
|
+
schemaVersion: '1.2.0', // sent as x-arc-schema-version header
|
|
491
|
+
correlationId: () => request.id,
|
|
492
|
+
retry: { maxRetries: 2 },
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
const products = await catalog.resource('product').list();
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
Receiving services can check `request.headers['x-arc-schema-version']` to detect version mismatches.
|
|
499
|
+
|
|
500
|
+
## Bulk Operations Preset
|
|
501
|
+
|
|
502
|
+
Adds bulk CRUD routes — repository must provide `createMany`, `updateMany`, `deleteMany`:
|
|
503
|
+
|
|
504
|
+
```typescript
|
|
505
|
+
defineResource({
|
|
506
|
+
name: 'product',
|
|
507
|
+
adapter: createMongooseAdapter({ model, repository }),
|
|
508
|
+
presets: ['bulk'], // adds POST/PATCH/DELETE /{resource}/bulk
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
// Or with options
|
|
512
|
+
presets: [bulkPreset({ operations: ['createMany', 'updateMany'], maxCreateItems: 500 })]
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
**Routes**: `POST /bulk` (body: `{ items }`) → `createMany`, `PATCH /bulk` (body: `{ filter, data }`) → `updateMany`, `DELETE /bulk` (body: `{ filter }`) → `deleteMany`. Permissions inherit from `create`/`update`/`delete`.
|
|
516
|
+
|
|
517
|
+
## Compensating Transaction
|
|
518
|
+
|
|
519
|
+
In-process rollback primitive — runs steps in order, compensates in reverse on failure.
|
|
520
|
+
For distributed sagas across services, use Temporal, Inngest, or Streamline.
|
|
521
|
+
|
|
522
|
+
```typescript
|
|
523
|
+
import { withCompensation } from '@classytic/arc/utils';
|
|
524
|
+
|
|
525
|
+
const result = await withCompensation('place-order', [
|
|
526
|
+
{
|
|
527
|
+
name: 'reserve-inventory',
|
|
528
|
+
execute: async (ctx) => {
|
|
529
|
+
const res = await inventoryService.reserve(ctx.items);
|
|
530
|
+
ctx.reservationId = res.id;
|
|
531
|
+
return res;
|
|
532
|
+
},
|
|
533
|
+
compensate: async (_ctx, result) => {
|
|
534
|
+
await inventoryService.release(result.id);
|
|
535
|
+
},
|
|
536
|
+
},
|
|
537
|
+
{
|
|
538
|
+
name: 'charge-payment',
|
|
539
|
+
execute: async (ctx) => await paymentService.charge(ctx.total),
|
|
540
|
+
compensate: async (_ctx, result) => await paymentService.refund(result.chargeId),
|
|
541
|
+
},
|
|
542
|
+
{
|
|
543
|
+
name: 'send-confirmation',
|
|
544
|
+
execute: async (ctx) => await emailService.send(ctx.email),
|
|
545
|
+
// No compensate — emails can't be unsent
|
|
546
|
+
},
|
|
547
|
+
], { items: cart.items, total: cart.total, email: user.email });
|
|
548
|
+
|
|
549
|
+
if (!result.success) {
|
|
550
|
+
console.error(`Saga failed at ${result.failedStep}: ${result.error}`);
|
|
551
|
+
// Compensation already ran for completed steps
|
|
552
|
+
}
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
**`defineCompensation()`** — reusable definition:
|
|
556
|
+
|
|
557
|
+
```typescript
|
|
558
|
+
const placeOrder = defineCompensation('place-order', steps);
|
|
559
|
+
await placeOrder.execute({ items, total, email });
|
|
560
|
+
```
|
|
561
|
+
|
|
562
|
+
**Fire-and-forget steps** — don't block, don't compensate, errors swallowed:
|
|
563
|
+
|
|
564
|
+
```typescript
|
|
565
|
+
await withCompensation('checkout', [
|
|
566
|
+
{ name: 'save-order', execute: saveOrder, compensate: cancelOrder },
|
|
567
|
+
{ name: 'send-email', execute: sendEmail, fireAndForget: true }, // non-blocking
|
|
568
|
+
{ name: 'charge', execute: chargeCard, compensate: refundCard },
|
|
569
|
+
]);
|
|
570
|
+
// 'charge' runs immediately after 'save-order' — doesn't wait for email
|
|
571
|
+
```
|
|
572
|
+
|
|
573
|
+
**Lifecycle hooks** — wire to Arc events, logging, or metrics:
|
|
574
|
+
|
|
575
|
+
```typescript
|
|
576
|
+
await withCompensation('checkout', steps, { orderId }, {
|
|
577
|
+
onStepComplete: (stepName, result) => {
|
|
578
|
+
fastify.events.publish(`checkout.${stepName}.completed`, result);
|
|
579
|
+
},
|
|
580
|
+
onStepFailed: (stepName, error) => {
|
|
581
|
+
fastify.events.publish(`checkout.${stepName}.failed`, { error: error.message });
|
|
582
|
+
},
|
|
583
|
+
onCompensate: (stepName) => {
|
|
584
|
+
fastify.log.warn(`Compensated: ${stepName}`);
|
|
585
|
+
},
|
|
586
|
+
});
|
|
587
|
+
```
|
|
588
|
+
|
|
589
|
+
**In an additionalRoute with Arc auth:**
|
|
590
|
+
|
|
591
|
+
```typescript
|
|
592
|
+
defineResource({
|
|
593
|
+
name: 'order',
|
|
594
|
+
additionalRoutes: [{
|
|
595
|
+
method: 'POST',
|
|
596
|
+
path: '/:id/checkout',
|
|
597
|
+
permissions: requireAuth(),
|
|
598
|
+
wrapHandler: false,
|
|
599
|
+
handler: async (request, reply) => {
|
|
600
|
+
const result = await withCompensation('checkout', steps, { orderId: request.params.id });
|
|
601
|
+
if (!result.success) return reply.code(422).send({ error: result.error });
|
|
602
|
+
return reply.send({ success: true, data: result.results });
|
|
603
|
+
},
|
|
604
|
+
}],
|
|
605
|
+
});
|
|
606
|
+
```
|
|
607
|
+
|
|
608
|
+
**Compensation errors**: collected in `result.compensationErrors[]` without stopping rollback. **Context**: mutable `Record<string, unknown>` shared across all steps.
|
|
609
|
+
|
|
610
|
+
**Scope**: In-process primitive. Process crash = no compensation. For durable distributed workflows, use Temporal, Inngest, or `@classytic/streamline`.
|