@classytic/arc 2.10.8 → 2.11.1
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/dist/{BaseController-DVNKvoX4.mjs → BaseController-JNV08qOT.mjs} +480 -442
- package/dist/{queryCachePlugin-Dumka73q.d.mts → QueryCache-DOBNHBE0.d.mts} +2 -32
- package/dist/adapters/index.d.mts +2 -2
- package/dist/adapters/index.mjs +1 -1
- package/dist/{adapters-BXY4i-hw.mjs → adapters-D0tT2Tyo.mjs} +54 -0
- package/dist/audit/index.d.mts +1 -1
- package/dist/auth/index.d.mts +1 -1
- package/dist/auth/index.mjs +5 -5
- package/dist/{betterAuthOpenApi--rdY15Ld.mjs → betterAuthOpenApi-DwxtK3uG.mjs} +1 -1
- package/dist/cache/index.d.mts +3 -2
- package/dist/cache/index.mjs +3 -3
- package/dist/cli/commands/docs.mjs +2 -2
- package/dist/cli/commands/generate.mjs +37 -27
- package/dist/cli/commands/init.mjs +46 -33
- package/dist/cli/commands/introspect.mjs +1 -1
- package/dist/context/index.mjs +1 -1
- package/dist/core/index.d.mts +3 -3
- package/dist/core/index.mjs +4 -3
- package/dist/core-DXdSSFW-.mjs +1037 -0
- package/dist/createActionRouter-BwaSM0No.mjs +166 -0
- package/dist/{createApp-BwnEAO2h.mjs → createApp-P1d6rjPy.mjs} +75 -27
- package/dist/docs/index.d.mts +1 -1
- package/dist/docs/index.mjs +2 -2
- package/dist/{elevation-Dci0AYLT.mjs → elevation-DOFoxoDs.mjs} +1 -1
- package/dist/{errorHandler-CSxe7KIM.mjs → errorHandler-BQm8ZxTK.mjs} +1 -1
- package/dist/{eventPlugin-ByU4Cv0e.mjs → eventPlugin--5HIkdPU.mjs} +1 -1
- package/dist/events/index.d.mts +3 -3
- package/dist/events/index.mjs +2 -2
- package/dist/events/transports/redis-stream-entry.d.mts +1 -1
- package/dist/factory/index.d.mts +2 -2
- package/dist/factory/index.mjs +2 -2
- package/dist/hooks/index.d.mts +1 -1
- package/dist/hooks/index.mjs +1 -1
- package/dist/idempotency/index.d.mts +3 -3
- package/dist/idempotency/index.mjs +1 -1
- package/dist/idempotency/redis.d.mts +1 -1
- package/dist/{index-C_Noptz-.d.mts → index-BYCqHCVu.d.mts} +2 -2
- package/dist/{index-BGbpGVyM.d.mts → index-C_bgx9o4.d.mts} +712 -500
- package/dist/{index-BziRPS4H.d.mts → index-CvM1e09j.d.mts} +29 -10
- package/dist/{index-EqQN6p0W.d.mts → index-pUczGjO0.d.mts} +11 -8
- package/dist/index-smCAoA5W.d.mts +1179 -0
- package/dist/index.d.mts +6 -38
- package/dist/index.mjs +9 -9
- package/dist/integrations/event-gateway.d.mts +1 -1
- package/dist/integrations/event-gateway.mjs +1 -1
- package/dist/integrations/index.d.mts +2 -2
- 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/integrations/streamline.d.mts +46 -5
- package/dist/integrations/streamline.mjs +50 -21
- package/dist/integrations/websocket-redis.d.mts +1 -1
- package/dist/integrations/websocket.d.mts +2 -154
- package/dist/integrations/websocket.mjs +292 -224
- package/dist/{keys-nWQGUTu1.mjs → keys-CARyUjiR.mjs} +2 -0
- package/dist/{loadResources-Bksk8ydA.mjs → loadResources-CPpkyKfM.mjs} +32 -8
- package/dist/middleware/index.d.mts +1 -1
- package/dist/middleware/index.mjs +1 -1
- package/dist/{openapi-DpNpqBmo.mjs → openapi-C0L9ar7m.mjs} +4 -4
- package/dist/org/index.d.mts +1 -1
- package/dist/permissions/index.d.mts +1 -1
- package/dist/permissions/index.mjs +2 -4
- package/dist/{permissions-wkqRwicB.mjs → permissions-B4vU9L0Q.mjs} +221 -3
- package/dist/{pipe-CGJxqDGx.mjs → pipe-DVoIheVC.mjs} +1 -1
- package/dist/pipeline/index.d.mts +1 -1
- package/dist/pipeline/index.mjs +1 -1
- package/dist/plugins/index.d.mts +4 -4
- package/dist/plugins/index.mjs +10 -10
- package/dist/plugins/response-cache.mjs +1 -1
- package/dist/plugins/tracing-entry.d.mts +1 -1
- package/dist/plugins/tracing-entry.mjs +42 -24
- package/dist/presets/filesUpload.d.mts +1 -1
- package/dist/presets/filesUpload.mjs +3 -3
- 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/multiTenant.mjs +6 -0
- package/dist/presets/search.d.mts +1 -1
- package/dist/presets/search.mjs +1 -1
- package/dist/{presets-CrwOvuXI.mjs → presets-k604Lj99.mjs} +1 -1
- package/dist/queryCachePlugin-BUXBSm4F.d.mts +34 -0
- package/dist/{queryCachePlugin-ChLNZvFT.mjs → queryCachePlugin-Bq6bO6vc.mjs} +3 -3
- package/dist/{redis-MXLp1oOf.d.mts → redis-Cm1gnRDf.d.mts} +1 -1
- package/dist/registry/index.d.mts +1 -1
- package/dist/registry/index.mjs +2 -2
- package/dist/{resourceToTools-BhF3JV5p.mjs → resourceToTools--okX6QBr.mjs} +534 -420
- package/dist/routerShared-DeESFp4a.mjs +515 -0
- package/dist/schemaIR-BlG9bY7v.mjs +137 -0
- package/dist/scope/index.mjs +2 -2
- package/dist/testing/index.d.mts +367 -711
- package/dist/testing/index.mjs +637 -1434
- package/dist/{tracing-xqXzWeaf.d.mts → tracing-DokiEsuz.d.mts} +9 -4
- package/dist/types/index.d.mts +3 -3
- package/dist/types/index.mjs +1 -3
- package/dist/{types-CVdgPXBW.d.mts → types-BdA4uMBV.d.mts} +191 -28
- package/dist/{types-CVKBssX5.d.mts → types-Bh_gEJBi.d.mts} +1 -1
- package/dist/utils/index.d.mts +2 -968
- package/dist/utils/index.mjs +5 -6
- package/dist/utils-D3Yxnrwr.mjs +1639 -0
- package/dist/websocket-CyJ1VIFI.d.mts +186 -0
- package/package.json +7 -5
- package/skills/arc/SKILL.md +124 -39
- package/skills/arc/references/testing.md +212 -183
- package/dist/applyPermissionResult-QhV1Pa-g.mjs +0 -37
- package/dist/core-3MWJosCH.mjs +0 -1459
- package/dist/createActionRouter-C8UUB3Px.mjs +0 -249
- package/dist/errors-BI8kEKsO.d.mts +0 -140
- package/dist/fields-CTMWOUDt.mjs +0 -126
- package/dist/queryParser-NR__Qiju.mjs +0 -419
- package/dist/types-CDnTEpga.mjs +0 -27
- package/dist/utils-LMwVidKy.mjs +0 -947
- /package/dist/{HookSystem-BjFu7zf1.mjs → HookSystem-CGsMd6oK.mjs} +0 -0
- /package/dist/{ResourceRegistry-CcN2LVrc.mjs → ResourceRegistry-DkAeAuTX.mjs} +0 -0
- /package/dist/{actionPermissions-TUVR3uiZ.mjs → actionPermissions-C8YYU92K.mjs} +0 -0
- /package/dist/{caching-3h93rkJM.mjs → caching-CheW3m-S.mjs} +0 -0
- /package/dist/{errorHandler-2ii4RIYr.d.mts → errorHandler-Co3lnVmJ.d.mts} +0 -0
- /package/dist/{errors-BqdUDja_.mjs → errors-D5c-5BJL.mjs} +0 -0
- /package/dist/{eventPlugin-D1ThQ1Pp.d.mts → eventPlugin-CUNjYYRY.d.mts} +0 -0
- /package/dist/{interface-B-pe8fhj.d.mts → interface-CkkWm5uR.d.mts} +0 -0
- /package/dist/{interface-yhyb_pLY.d.mts → interface-Da0r7Lna.d.mts} +0 -0
- /package/dist/{memory-DqI-449b.mjs → memory-DikHSvWa.mjs} +0 -0
- /package/dist/{metrics-TuOmguhi.mjs → metrics-Csh4nsvv.mjs} +0 -0
- /package/dist/{multipartBody-CUQGVlM_.mjs → multipartBody-CvTR1Un6.mjs} +0 -0
- /package/dist/{pluralize-CWP6MB39.mjs → pluralize-BneOJkpi.mjs} +0 -0
- /package/dist/{redis-stream-bkO88VHx.d.mts → redis-stream-CM8TXTix.d.mts} +0 -0
- /package/dist/{registry-B0Wl7uVV.mjs → registry-D63ee7fl.mjs} +0 -0
- /package/dist/{replyHelpers-BLojtuvR.mjs → replyHelpers-ByllIXXV.mjs} +0 -0
- /package/dist/{requestContext-C38GskNt.mjs → requestContext-CfRkaxwf.mjs} +0 -0
- /package/dist/{schemaConverter-BxFDdtXu.mjs → schemaConverter-B0oKLuqI.mjs} +0 -0
- /package/dist/{sse-D8UeDwis.mjs → sse-V7aXc3bW.mjs} +0 -0
- /package/dist/{store-helpers-DYYUQbQN.mjs → store-helpers-BhrzxvyQ.mjs} +0 -0
- /package/dist/{typeGuards-Cj5Rgvlg.mjs → typeGuards-CcFZXgU7.mjs} +0 -0
- /package/dist/{types-D57iXYb8.mjs → types-DV9WDfeg.mjs} +0 -0
- /package/dist/{versioning-B6mimogM.mjs → versioning-CGPjkqAg.mjs} +0 -0
- /package/dist/{versioning-CeUXHfjw.d.mts → versioning-M9lNLhO8.d.mts} +0 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { FastifyPluginAsync } from "fastify";
|
|
2
|
+
|
|
3
|
+
//#region src/integrations/websocket/adapter.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* WebSocket cross-instance adapter contract.
|
|
6
|
+
*
|
|
7
|
+
* The adapter is NOT used for local broadcasts — `RoomManager` handles those.
|
|
8
|
+
* The adapter only handles the cross-instance relay (Redis pub/sub, NATS, etc.)
|
|
9
|
+
* so a message broadcast on instance A is also delivered to clients connected
|
|
10
|
+
* to instance B.
|
|
11
|
+
*
|
|
12
|
+
* Implementations:
|
|
13
|
+
* - `LocalWebSocketAdapter` (here) — no-op, single-instance only
|
|
14
|
+
* - `RedisWebSocketAdapter` (@classytic/arc/integrations/websocket-redis)
|
|
15
|
+
*
|
|
16
|
+
* Custom adapters just need to satisfy the interface.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* Adapter interface for cross-instance WebSocket broadcast.
|
|
20
|
+
*
|
|
21
|
+
* - `publish()`: Send a message to all instances (via Redis, NATS, etc.)
|
|
22
|
+
* - `subscribe()`: Receive messages from other instances
|
|
23
|
+
* - `close()`: Clean up connections
|
|
24
|
+
*/
|
|
25
|
+
interface WebSocketAdapter {
|
|
26
|
+
/** Adapter name for logging */
|
|
27
|
+
readonly name: string;
|
|
28
|
+
/** Publish a room broadcast to all other instances */
|
|
29
|
+
publish(room: string, message: string): Promise<void>;
|
|
30
|
+
/** Subscribe to broadcasts from other instances */
|
|
31
|
+
subscribe(callback: (room: string, message: string) => void): Promise<void>;
|
|
32
|
+
/** Close adapter connections */
|
|
33
|
+
close(): Promise<void>;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Default adapter — no cross-instance broadcast (single-instance only).
|
|
37
|
+
* All methods are no-ops. Used when no adapter is configured.
|
|
38
|
+
*/
|
|
39
|
+
declare class LocalWebSocketAdapter implements WebSocketAdapter {
|
|
40
|
+
readonly name = "local";
|
|
41
|
+
publish(): Promise<void>;
|
|
42
|
+
subscribe(): Promise<void>;
|
|
43
|
+
close(): Promise<void>;
|
|
44
|
+
}
|
|
45
|
+
//#endregion
|
|
46
|
+
//#region src/integrations/websocket/types.d.ts
|
|
47
|
+
/**
|
|
48
|
+
* A connected WebSocket client — one entry per TCP socket.
|
|
49
|
+
*
|
|
50
|
+
* `subscriptions` is mutated by `RoomManager`; other fields are set once at
|
|
51
|
+
* handshake time and treated as effectively immutable for the lifetime of
|
|
52
|
+
* the connection.
|
|
53
|
+
*/
|
|
54
|
+
interface WebSocketClient {
|
|
55
|
+
id: string;
|
|
56
|
+
socket: {
|
|
57
|
+
send(data: string): void;
|
|
58
|
+
close(): void;
|
|
59
|
+
readyState: number;
|
|
60
|
+
};
|
|
61
|
+
subscriptions: Set<string>;
|
|
62
|
+
userId?: string;
|
|
63
|
+
organizationId?: string;
|
|
64
|
+
/** OAuth client ID — present for service/machine-to-machine connections */
|
|
65
|
+
clientId?: string;
|
|
66
|
+
/** OAuth scopes — present for service/machine-to-machine connections */
|
|
67
|
+
scopes?: readonly string[];
|
|
68
|
+
metadata?: Record<string, unknown>;
|
|
69
|
+
}
|
|
70
|
+
interface WebSocketMessage {
|
|
71
|
+
type: string;
|
|
72
|
+
resource?: string;
|
|
73
|
+
channel?: string;
|
|
74
|
+
data?: unknown;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Result of a successful authentication. The plugin's handshake and the
|
|
78
|
+
* optional re-auth loop both return this shape so the downstream code
|
|
79
|
+
* doesn't branch on auth mode. `null` means rejected.
|
|
80
|
+
*/
|
|
81
|
+
interface AuthResult {
|
|
82
|
+
userId?: string;
|
|
83
|
+
organizationId?: string;
|
|
84
|
+
/** Set for machine-to-machine / service account auth */
|
|
85
|
+
clientId?: string;
|
|
86
|
+
/** OAuth scopes for service accounts */
|
|
87
|
+
scopes?: readonly string[];
|
|
88
|
+
}
|
|
89
|
+
interface WebSocketPluginOptions {
|
|
90
|
+
/** WebSocket endpoint path (default: '/ws') */
|
|
91
|
+
path?: string;
|
|
92
|
+
/** Require authentication for WebSocket connections (default: true) */
|
|
93
|
+
auth?: boolean;
|
|
94
|
+
/** Resources to auto-broadcast CRUD events for */
|
|
95
|
+
resources?: string[];
|
|
96
|
+
/** Heartbeat interval in ms (default: 30000). Set 0 to disable. */
|
|
97
|
+
heartbeatInterval?: number;
|
|
98
|
+
/** Custom authentication function for WebSocket upgrade */
|
|
99
|
+
authenticate?: (request: unknown) => Promise<AuthResult | null>;
|
|
100
|
+
/** Max clients per resource subscription (default: 10000) */
|
|
101
|
+
maxClientsPerRoom?: number;
|
|
102
|
+
/**
|
|
103
|
+
* Expose a stats endpoint at `{path}/stats`.
|
|
104
|
+
* - `false` (default): stats endpoint is not registered
|
|
105
|
+
* - `true`: registered without auth
|
|
106
|
+
* - `'authenticated'`: guarded by `fastify.authenticate` if available
|
|
107
|
+
*/
|
|
108
|
+
exposeStats?: boolean | "authenticated";
|
|
109
|
+
/**
|
|
110
|
+
* Authorize room subscriptions. Return true to allow, false to deny.
|
|
111
|
+
* Called before every subscribe. If not provided, all rooms are allowed.
|
|
112
|
+
*/
|
|
113
|
+
roomPolicy?: (client: WebSocketClient, room: string) => boolean | Promise<boolean>;
|
|
114
|
+
/** Maximum message size in bytes from client (default: 16384 = 16KB). Messages exceeding this are dropped. */
|
|
115
|
+
maxMessageBytes?: number;
|
|
116
|
+
/** Maximum subscriptions per client (default: 100). Prevents resource exhaustion. */
|
|
117
|
+
maxSubscriptionsPerClient?: number;
|
|
118
|
+
/**
|
|
119
|
+
* Periodic re-authentication interval in ms (default: 0 = disabled).
|
|
120
|
+
* When set, the server periodically re-validates the client's auth token.
|
|
121
|
+
* If the token is expired/revoked, the client is disconnected with code 4003.
|
|
122
|
+
*
|
|
123
|
+
* Recommended: 300000 (5 minutes) for production.
|
|
124
|
+
*
|
|
125
|
+
* @example
|
|
126
|
+
* ```typescript
|
|
127
|
+
* websocketPlugin({ reauthInterval: 5 * 60 * 1000 }) // re-check every 5 min
|
|
128
|
+
* ```
|
|
129
|
+
*/
|
|
130
|
+
reauthInterval?: number;
|
|
131
|
+
/** Custom message handler */
|
|
132
|
+
onMessage?: (client: WebSocketClient, message: WebSocketMessage) => void | Promise<void>;
|
|
133
|
+
/** Called when a client connects */
|
|
134
|
+
onConnect?: (client: WebSocketClient) => void | Promise<void>;
|
|
135
|
+
/** Called when a client disconnects */
|
|
136
|
+
onDisconnect?: (client: WebSocketClient) => void | Promise<void>;
|
|
137
|
+
/**
|
|
138
|
+
* Cross-instance broadcast adapter (default: LocalWebSocketAdapter — single-instance only).
|
|
139
|
+
* Provide a RedisWebSocketAdapter for multi-instance deployments.
|
|
140
|
+
*
|
|
141
|
+
* @example
|
|
142
|
+
* ```typescript
|
|
143
|
+
* import { RedisWebSocketAdapter } from '@classytic/arc/integrations/websocket-redis';
|
|
144
|
+
* adapter: new RedisWebSocketAdapter(redis, { channel: 'arc-ws' })
|
|
145
|
+
* ```
|
|
146
|
+
*/
|
|
147
|
+
adapter?: WebSocketAdapter;
|
|
148
|
+
}
|
|
149
|
+
//#endregion
|
|
150
|
+
//#region src/integrations/websocket/plugin.d.ts
|
|
151
|
+
/** Pluggable WebSocket integration for Arc */
|
|
152
|
+
declare const websocketPlugin: FastifyPluginAsync<WebSocketPluginOptions>;
|
|
153
|
+
//#endregion
|
|
154
|
+
//#region src/integrations/websocket/room-manager.d.ts
|
|
155
|
+
declare class RoomManager {
|
|
156
|
+
private rooms;
|
|
157
|
+
private clients;
|
|
158
|
+
private maxPerRoom;
|
|
159
|
+
private adapter?;
|
|
160
|
+
constructor(maxPerRoom?: number, adapter?: WebSocketAdapter);
|
|
161
|
+
addClient(client: WebSocketClient): void;
|
|
162
|
+
removeClient(clientId: string): void;
|
|
163
|
+
subscribe(clientId: string, room: string): boolean;
|
|
164
|
+
unsubscribe(clientId: string, room: string): void;
|
|
165
|
+
broadcast(room: string, message: string, excludeClientId?: string): void;
|
|
166
|
+
broadcastToOrg(organizationId: string, room: string, message: string): void;
|
|
167
|
+
/**
|
|
168
|
+
* Broadcast locally AND through adapter (for cross-instance delivery).
|
|
169
|
+
* Use this instead of broadcast() when multi-instance is possible.
|
|
170
|
+
*/
|
|
171
|
+
broadcastWithAdapter(room: string, message: string, excludeClientId?: string): Promise<void>;
|
|
172
|
+
/**
|
|
173
|
+
* Org-scoped broadcast locally AND through adapter.
|
|
174
|
+
* Uses a namespaced room key for the adapter so other instances
|
|
175
|
+
* can filter by org when delivering locally.
|
|
176
|
+
*/
|
|
177
|
+
broadcastToOrgWithAdapter(organizationId: string, room: string, message: string): Promise<void>;
|
|
178
|
+
getClient(clientId: string): WebSocketClient | undefined;
|
|
179
|
+
getStats(): {
|
|
180
|
+
clients: number;
|
|
181
|
+
rooms: number;
|
|
182
|
+
subscriptions: Record<string, number>;
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
//#endregion
|
|
186
|
+
export { WebSocketMessage as a, WebSocketAdapter as c, WebSocketClient as i, websocketPlugin as n, WebSocketPluginOptions as o, AuthResult as r, LocalWebSocketAdapter as s, RoomManager as t };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@classytic/arc",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.11.1",
|
|
4
4
|
"description": "Resource-oriented backend framework for Fastify — clean, minimal, powerful, tree-shakable",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -233,15 +233,16 @@
|
|
|
233
233
|
"test:e2e": "vitest run tests/e2e",
|
|
234
234
|
"test:unit": "vitest run tests/core tests/hooks tests/utils tests/plugins",
|
|
235
235
|
"smoke": "node scripts/smoke-test.mjs",
|
|
236
|
+
"push": "classytic-push",
|
|
236
237
|
"prepublishOnly": "npm run typecheck && npm run lint && npm run build && npm run test:ci && npm run smoke"
|
|
237
238
|
},
|
|
238
239
|
"engines": {
|
|
239
240
|
"node": ">=22"
|
|
240
241
|
},
|
|
241
242
|
"peerDependencies": {
|
|
242
|
-
"@classytic/mongokit": ">=3.11.
|
|
243
|
+
"@classytic/mongokit": ">=3.11.1",
|
|
243
244
|
"@classytic/repo-core": ">=0.2.0",
|
|
244
|
-
"@classytic/streamline": ">=2.
|
|
245
|
+
"@classytic/streamline": ">=2.2.0",
|
|
245
246
|
"@fastify/cors": ">=11.0.0",
|
|
246
247
|
"@fastify/helmet": ">=13.0.0",
|
|
247
248
|
"@fastify/jwt": ">=10.0.0",
|
|
@@ -363,10 +364,11 @@
|
|
|
363
364
|
"@better-auth/drizzle-adapter": "^1.6.2",
|
|
364
365
|
"@better-auth/mongo-adapter": "^1.6.2",
|
|
365
366
|
"@biomejs/biome": "^2.4.11",
|
|
366
|
-
"@classytic/
|
|
367
|
+
"@classytic/dev-tools": "^0.2.0",
|
|
368
|
+
"@classytic/mongokit": "^3.11.1",
|
|
367
369
|
"@classytic/repo-core": "^0.2.0",
|
|
368
370
|
"@classytic/sqlitekit": "^0.2.0",
|
|
369
|
-
"@classytic/streamline": "^2.
|
|
371
|
+
"@classytic/streamline": "^2.2.0",
|
|
370
372
|
"@fastify/cors": "^11.2.0",
|
|
371
373
|
"@fastify/helmet": "^13.0.2",
|
|
372
374
|
"@fastify/jwt": "^10.0.0",
|
package/skills/arc/SKILL.md
CHANGED
|
@@ -8,11 +8,11 @@ description: |
|
|
|
8
8
|
Triggers: arc, fastify resource, defineResource, createApp, BaseController, arc preset,
|
|
9
9
|
arc auth, arc events, arc jobs, arc websocket, arc mcp, arc plugin, arc testing, arc cli,
|
|
10
10
|
arc permissions, arc hooks, arc pipeline, arc factory, arc cache, arc QueryCache.
|
|
11
|
-
version: 2.
|
|
11
|
+
version: 2.11.1
|
|
12
12
|
license: MIT
|
|
13
13
|
metadata:
|
|
14
14
|
author: Classytic
|
|
15
|
-
version: "2.
|
|
15
|
+
version: "2.11.1"
|
|
16
16
|
tags:
|
|
17
17
|
- fastify
|
|
18
18
|
- rest-api
|
|
@@ -62,27 +62,34 @@ const app = await createApp({
|
|
|
62
62
|
preset: 'production',
|
|
63
63
|
auth: { type: 'jwt', jwt: { secret: process.env.JWT_SECRET } },
|
|
64
64
|
cors: { origin: process.env.ALLOWED_ORIGINS?.split(',') },
|
|
65
|
+
resources: [productResource, orderResource], // canonical path — factory registers in the right lifecycle slot
|
|
65
66
|
});
|
|
66
67
|
|
|
67
|
-
await app.register(productResource.toPlugin());
|
|
68
68
|
await app.listen({ port: 8040, host: '0.0.0.0' });
|
|
69
69
|
```
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
For async-booted engines (repository wired in `bootstrap[]`), use the factory form:
|
|
72
72
|
|
|
73
|
-
|
|
73
|
+
```typescript
|
|
74
|
+
resources: async (fastify) => {
|
|
75
|
+
const engine = await ensureCatalogEngine();
|
|
76
|
+
return [buildProductResource(engine), buildCategoryResource(engine)];
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Advanced escape hatch: `await app.register(productResource.toPlugin())` registers a single resource directly. Use only when you need manual control over the scope/prefix — the `resources` factory option is preferred.
|
|
81
|
+
|
|
82
|
+
## Security defaults (active in 2.11)
|
|
83
|
+
|
|
84
|
+
- **Field-write perms: `reject`** — requests carrying non-writable fields get 403 with denied-field list. Opt into silent strip: `defineResource({ onFieldWriteDenied: 'strip' })`.
|
|
74
85
|
- **multiTenant injects org on UPDATE** — body-supplied `organizationId` overwritten with caller's scope. Closes tenant-hop vector.
|
|
75
|
-
- **Elevation
|
|
76
|
-
- **`verifySignature(body, ...)
|
|
77
|
-
- **Upload `sanitizeFilename`**
|
|
86
|
+
- **Elevation emits `arc.scope.elevated`** — audit via `fastify.events.subscribe(...)`.
|
|
87
|
+
- **`verifySignature(body, ...)`** throws on parsed body — pass `req.rawBody`.
|
|
88
|
+
- **Upload `sanitizeFilename`** strict by default. Pass `false` / `'*'` / custom fn to relax.
|
|
78
89
|
- **Idempotency `namespace`** option for shared-store prod+canary deployments.
|
|
90
|
+
- **`systemManaged` fields auto-strip from `required[]`** — framework-injected fields (tenant, audit) removed from create/update `required[]` so Fastify preValidation doesn't reject before arc's injection runs.
|
|
79
91
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
- `createActionRouter`, `buildActionBodySchema` — use `actions` on `defineResource`
|
|
83
|
-
- `ResourceConfig.onRegister` — use `actions` or resource `hooks`
|
|
84
|
-
- `AdditionalRoute` type + `resource.additionalRoutes` field — use `RouteDefinition` and `resource.routes` (single source of truth; no normalised mirror)
|
|
85
|
-
- `wrapHandler` on route defs — derived from `!route.raw` at use-site (set `raw: true` to opt out of the arc pipeline)
|
|
92
|
+
For removed APIs and version-by-version breaking changes, see [CHANGELOG.md](../../CHANGELOG.md). For the full tenant-pipeline walkthrough, see [docs/production-ops/tenant-pipeline.mdx](../../docs/production-ops/tenant-pipeline.mdx).
|
|
86
93
|
|
|
87
94
|
## defineResource()
|
|
88
95
|
|
|
@@ -105,24 +112,25 @@ const productResource = defineResource({
|
|
|
105
112
|
},
|
|
106
113
|
cache: { staleTime: 30, gcTime: 300, tags: ['catalog'] },
|
|
107
114
|
|
|
108
|
-
//
|
|
115
|
+
// routeGuards — auto-apply to ALL routes (CRUD + custom + preset)
|
|
109
116
|
routeGuards: [modeGuard, orgGuard.preHandler],
|
|
110
117
|
|
|
111
|
-
//
|
|
118
|
+
// fieldRules — portable constraints + framework-injection hints
|
|
112
119
|
schemaOptions: {
|
|
113
120
|
fieldRules: {
|
|
114
121
|
name: { minLength: 2, maxLength: 200, description: 'Product name' },
|
|
115
122
|
price: { min: 0, max: 100000 },
|
|
116
123
|
sku: { pattern: '^[A-Z]{3}-\\d{3}$' },
|
|
117
124
|
status: { enum: ['draft', 'active', 'archived'] },
|
|
118
|
-
deletedAt: { systemManaged: true },
|
|
125
|
+
deletedAt: { systemManaged: true }, // arc stamps it — strip from body + required[]
|
|
126
|
+
priceMode: { nullable: true }, // Zod .nullable() lost through Mongoose — widen to accept null
|
|
119
127
|
},
|
|
120
128
|
},
|
|
121
129
|
|
|
122
130
|
// Custom routes (compose with presets — softDelete adds /deleted, /:id/restore)
|
|
123
131
|
routes: [
|
|
124
|
-
{ method: 'GET', path: '/stats', handler: 'getStats', permissions:
|
|
125
|
-
{ method: 'POST', path: '/webhook', handler: webhookFn, raw: true, permissions:
|
|
132
|
+
{ method: 'GET', path: '/stats', handler: 'getStats', permissions: requireAuth() },
|
|
133
|
+
{ method: 'POST', path: '/webhook', handler: webhookFn, raw: true, permissions: requireAuth() },
|
|
126
134
|
],
|
|
127
135
|
|
|
128
136
|
// Actions — single POST /:id/action endpoint, discriminated on `action` body field
|
|
@@ -130,19 +138,20 @@ const productResource = defineResource({
|
|
|
130
138
|
approve: async (id, data, req) => service.approve(id, req.user._id),
|
|
131
139
|
cancel: {
|
|
132
140
|
handler: async (id, data, req) => service.cancel(id, data.reason, req.user._id),
|
|
133
|
-
permissions:
|
|
141
|
+
permissions: requireRoles('admin'),
|
|
134
142
|
schema: { reason: { type: 'string' } },
|
|
135
143
|
},
|
|
136
144
|
},
|
|
137
|
-
actionPermissions:
|
|
145
|
+
actionPermissions: requireAuth(),
|
|
138
146
|
});
|
|
139
147
|
|
|
140
|
-
|
|
148
|
+
// Register via createApp — canonical path:
|
|
149
|
+
// createApp({ resources: [productResource] })
|
|
141
150
|
// Auto-generates: GET /, GET /:id, POST /, PATCH /:id, DELETE /:id
|
|
142
151
|
// + softDelete preset adds: GET /deleted, POST /:id/restore
|
|
143
152
|
```
|
|
144
153
|
|
|
145
|
-
## routeGuards + defineGuard
|
|
154
|
+
## routeGuards + defineGuard
|
|
146
155
|
|
|
147
156
|
Resource-level guards that apply to **every** route (CRUD + custom + preset):
|
|
148
157
|
|
|
@@ -171,7 +180,7 @@ defineResource({
|
|
|
171
180
|
name: 'procurement',
|
|
172
181
|
routeGuards: [modeGuard, orgGuard.preHandler], // all routes protected
|
|
173
182
|
routes: [{
|
|
174
|
-
method: 'GET', path: '/summary', raw: true, permissions:
|
|
183
|
+
method: 'GET', path: '/summary', raw: true, permissions: requireAuth(),
|
|
175
184
|
handler: async (req, reply) => {
|
|
176
185
|
const { orgId } = orgGuard.from(req); // typed, no re-computation
|
|
177
186
|
reply.send({ orgId, count: await Model.countDocuments() });
|
|
@@ -183,9 +192,9 @@ defineResource({
|
|
|
183
192
|
|
|
184
193
|
**Execution order:** auth → permissions → cache/idempotency → `routeGuards` → per-route `preHandler`
|
|
185
194
|
|
|
186
|
-
## fieldRules → OpenAPI + AJV
|
|
195
|
+
## fieldRules → OpenAPI + AJV
|
|
187
196
|
|
|
188
|
-
One definition, two outputs — constraints auto-map to OpenAPI schema + Fastify AJV validation
|
|
197
|
+
One definition, two outputs — constraints auto-map to OpenAPI schema + Fastify AJV validation. Extends repo-core's `FieldRule` floor with arc extensions.
|
|
189
198
|
|
|
190
199
|
```typescript
|
|
191
200
|
schemaOptions: {
|
|
@@ -194,14 +203,29 @@ schemaOptions: {
|
|
|
194
203
|
price: { min: 0, max: 100000 },
|
|
195
204
|
sku: { pattern: '^[A-Z]{3}-\\d{3}$' },
|
|
196
205
|
status: { enum: ['draft', 'active', 'archived'] },
|
|
197
|
-
password: { hidden: true },
|
|
198
|
-
deletedAt: { systemManaged: true },
|
|
199
|
-
slug: { immutable: true },
|
|
206
|
+
password: { hidden: true }, // blocked from select + OpenAPI
|
|
207
|
+
deletedAt: { systemManaged: true }, // blocked from input schemas; framework stamps it
|
|
208
|
+
slug: { immutable: true }, // excluded from update body
|
|
209
|
+
priceMode: { nullable: true }, // widen JSON-Schema type to include null
|
|
210
|
+
organizationId: { systemManaged: true, preserveForElevated: true }, // tenant field (auto-injected)
|
|
200
211
|
},
|
|
201
212
|
},
|
|
202
213
|
```
|
|
203
214
|
|
|
204
|
-
|
|
215
|
+
| Flag | Effect |
|
|
216
|
+
|---|---|
|
|
217
|
+
| `systemManaged` | Strip from body on ingest, drop from `required[]`. Framework stamps the value (tenant, audit, engine-derived slug). |
|
|
218
|
+
| `preserveForElevated` | Elevated admins keep the field on ingest (platform-level cross-tenant writes). |
|
|
219
|
+
| `immutable` / `immutableAfterCreate` | Omit from update body. Inheritance: repo-core floor. |
|
|
220
|
+
| `optional` | Strip from `required[]` without touching `properties`. |
|
|
221
|
+
| `nullable` | Widen JSON-Schema `type` to include null (+ appends `null` to `enum` if present). |
|
|
222
|
+
| `hidden` | Block from response projection + OpenAPI. |
|
|
223
|
+
| `minLength` / `maxLength` / `min` / `max` / `pattern` / `enum` | Map to AJV validators + OpenAPI constraints. |
|
|
224
|
+
| `description` | Maps to OpenAPI `description`. |
|
|
225
|
+
|
|
226
|
+
Mongoose model-level constraints (`minlength`, `maxlength`, `min`, `max`, `enum`) take precedence. `fieldRules` supplements what the model doesn't declare. Kit schema generators see only the repo-core floor; arc's extensions apply post-kit via `mergeFieldRuleConstraints`.
|
|
227
|
+
|
|
228
|
+
See [docs/framework-extension/custom-adapters.mdx — Field Rules](../../docs/framework-extension/custom-adapters.mdx#field-rules--shaping-kit-generated-schemas) for the `systemManaged` decision table.
|
|
205
229
|
|
|
206
230
|
## Authentication
|
|
207
231
|
|
|
@@ -665,22 +689,44 @@ defineResource({
|
|
|
665
689
|
|
|
666
690
|
**Response header:** `x-cache: HIT | STALE | MISS`
|
|
667
691
|
|
|
668
|
-
##
|
|
692
|
+
## Controllers (v2.11 mixin split)
|
|
693
|
+
|
|
694
|
+
`BaseController` was split in 2.11 from a 1,589-LOC god class into a mixin composition. `extends BaseController<Product>` still works exactly the same — a declaration-merged interface threads `TDoc` through every CRUD + preset method.
|
|
669
695
|
|
|
670
696
|
```typescript
|
|
671
697
|
import { BaseController } from '@classytic/arc';
|
|
672
698
|
import type { IRequestContext, IControllerResponse } from '@classytic/arc';
|
|
673
699
|
|
|
700
|
+
// Full surface: CRUD + SoftDelete + Tree + Slug + Bulk
|
|
674
701
|
class ProductController extends BaseController<Product> {
|
|
675
702
|
constructor() { super(productRepo); }
|
|
676
703
|
|
|
677
|
-
async getFeatured(req: IRequestContext): Promise<IControllerResponse
|
|
704
|
+
async getFeatured(req: IRequestContext): Promise<IControllerResponse<Product[]>> {
|
|
678
705
|
const products = await this.repository.getAll({ filters: { isFeatured: true } });
|
|
679
706
|
return { success: true, data: products };
|
|
680
707
|
}
|
|
681
708
|
}
|
|
682
709
|
```
|
|
683
710
|
|
|
711
|
+
**Slim CRUD-only surface** (869 LOC instead of 1,650):
|
|
712
|
+
|
|
713
|
+
```typescript
|
|
714
|
+
import { BaseCrudController } from '@classytic/arc';
|
|
715
|
+
class ReportController extends BaseCrudController<Report> {}
|
|
716
|
+
```
|
|
717
|
+
|
|
718
|
+
**Pick specific mixins:**
|
|
719
|
+
|
|
720
|
+
```typescript
|
|
721
|
+
import { BaseCrudController, SoftDeleteMixin, BulkMixin } from '@classytic/arc';
|
|
722
|
+
class OrderController extends SoftDeleteMixin(BulkMixin(BaseCrudController)) {}
|
|
723
|
+
// → list/get/create/update/delete + getDeleted/restore + bulkCreate/bulkUpdate/bulkDelete
|
|
724
|
+
```
|
|
725
|
+
|
|
726
|
+
**Mixin surface:** `SoftDeleteMixin` (`getDeleted`, `restore`) · `TreeMixin` (`getTree`, `getChildren`) · `SlugMixin` (`getBySlug`) · `BulkMixin` (`bulkCreate`, `bulkUpdate`, `bulkDelete`). Each exported from `@classytic/arc` and `@classytic/arc/core`.
|
|
727
|
+
|
|
728
|
+
**Shared helpers** (protected on `BaseCrudController` so mixins can extend): `meta(req)`, `getHooks(req)`, `tenantRepoOptions(req)`, `resolveRepoId(id, existing)`, `notFoundResponse(reason)`, `resolveCacheConfig(op)`, `cacheScope(req)`.
|
|
729
|
+
|
|
684
730
|
**IRequestContext:** `{ params, query, body, user, headers, context, metadata, server }` — `user` is `Record<string, unknown> | undefined` (guard with `if (req.user)` on public routes)
|
|
685
731
|
|
|
686
732
|
**IControllerResponse:** `{ success, data?, error?, status?, meta?, headers? }`
|
|
@@ -688,9 +734,15 @@ class ProductController extends BaseController<Product> {
|
|
|
688
734
|
## Adapters (Database-Agnostic)
|
|
689
735
|
|
|
690
736
|
```typescript
|
|
691
|
-
// Mongoose
|
|
737
|
+
// Mongoose — canonical arc factory
|
|
692
738
|
import { createMongooseAdapter } from '@classytic/arc';
|
|
693
|
-
|
|
739
|
+
import { buildCrudSchemasFromModel } from '@classytic/mongokit';
|
|
740
|
+
|
|
741
|
+
const adapter = createMongooseAdapter({
|
|
742
|
+
model: ProductModel,
|
|
743
|
+
repository: productRepo,
|
|
744
|
+
schemaGenerator: buildCrudSchemasFromModel, // ← no cast; RouteSchemaOptions extends SchemaBuilderOptions
|
|
745
|
+
});
|
|
694
746
|
|
|
695
747
|
// Custom adapter — implement MinimalRepo from @classytic/repo-core/repository:
|
|
696
748
|
import type { MinimalRepo } from '@classytic/repo-core/repository';
|
|
@@ -699,6 +751,10 @@ import type { MinimalRepo } from '@classytic/repo-core/repository';
|
|
|
699
751
|
// Arc feature-detects optional methods at call sites.
|
|
700
752
|
```
|
|
701
753
|
|
|
754
|
+
- `createMongooseAdapter` is the **canonical arc export**. Use directly — no cast on `schemaGenerator` (arc's `RouteSchemaOptions extends SchemaBuilderOptions`; `ArcFieldRule extends FieldRule`).
|
|
755
|
+
- `createAdapter` is a **CLI-scaffolded host wrapper** (`src/lib/adapter.ts`). Keep for scaffolded apps; hand-built apps should import `createMongooseAdapter` directly.
|
|
756
|
+
- Built-in mongoose fallback detects `{ default: null }` on schema paths and widens the emitted JSON-Schema type automatically — no `fieldRules` entry needed for that case.
|
|
757
|
+
|
|
702
758
|
## Events
|
|
703
759
|
|
|
704
760
|
The factory auto-registers `eventPlugin` — no manual setup needed:
|
|
@@ -873,6 +929,36 @@ const result = await withCompensation('checkout', [
|
|
|
873
929
|
// result: { success, completedSteps, results, failedStep?, error?, compensationErrors? }
|
|
874
930
|
```
|
|
875
931
|
|
|
932
|
+
## Testing
|
|
933
|
+
|
|
934
|
+
Three entry points — pick by what you're testing. Full details in [references/testing.md](references/testing.md).
|
|
935
|
+
|
|
936
|
+
```typescript
|
|
937
|
+
import {
|
|
938
|
+
createTestApp, // turnkey Fastify + in-memory Mongo + auth + fixtures
|
|
939
|
+
createHttpTestHarness, // auto-generates ~16 CRUD/permission/validation tests
|
|
940
|
+
expectArc, // fluent envelope matchers
|
|
941
|
+
createTestFixtures, // DB-agnostic seeding with per-record destroyers
|
|
942
|
+
} from '@classytic/arc/testing';
|
|
943
|
+
|
|
944
|
+
const ctx = await createTestApp({
|
|
945
|
+
resources: [productResource],
|
|
946
|
+
authMode: 'jwt', // 'jwt' | 'better-auth' | 'none'
|
|
947
|
+
db: 'in-memory', // default
|
|
948
|
+
connectMongoose: true, // one-liner for Mongoose-backed resources
|
|
949
|
+
});
|
|
950
|
+
ctx.auth.register('admin', { user: { id: '1', roles: ['admin'] }, orgId: 'org-1' });
|
|
951
|
+
|
|
952
|
+
const res = await ctx.app.inject({
|
|
953
|
+
method: 'POST', url: '/products',
|
|
954
|
+
headers: ctx.auth.as('admin').headers,
|
|
955
|
+
payload: { name: 'Widget' },
|
|
956
|
+
});
|
|
957
|
+
expectArc(res).ok().hidesField('password');
|
|
958
|
+
```
|
|
959
|
+
|
|
960
|
+
`TestAppContext` = `{ app, auth, fixtures, dbUri, close }`. `authMode: 'better-auth'` requires the caller to also pass `auth: { type: 'better-auth', ... }`.
|
|
961
|
+
|
|
876
962
|
## CLI
|
|
877
963
|
|
|
878
964
|
```bash
|
|
@@ -993,7 +1079,7 @@ const app = await createApp({
|
|
|
993
1079
|
|
|
994
1080
|
`loadResources()` discovers files matching `*.resource.{ts,js,mts,mjs}`, recursively. Pass `import.meta.url` for dev/prod parity (resolves to `src/` in dev, `dist/` in prod automatically). Discovers `default` export, `export const resource`, OR any named export with `toPlugin()` (e.g., `export const userResource`).
|
|
995
1081
|
|
|
996
|
-
Options: `exclude`, `include`, `suffix`, `recursive`, `
|
|
1082
|
+
Options: `exclude`, `include`, `suffix`, `recursive`, `context`, `logger`. Silent by default — pass `logger: { warn(msg) {...} }` to receive skip / factory-failure diagnostics.
|
|
997
1083
|
|
|
998
1084
|
**Per-resource opt-out of `resourcePrefix`** — for webhooks, admin routes:
|
|
999
1085
|
```typescript
|
|
@@ -1138,9 +1224,8 @@ import { defineResource, BaseController, allowPublic } from '@classytic/arc';
|
|
|
1138
1224
|
import { createApp } from '@classytic/arc/factory';
|
|
1139
1225
|
import { MemoryCacheStore, RedisCacheStore, QueryCache } from '@classytic/arc/cache';
|
|
1140
1226
|
import { createBetterAuthAdapter, extractBetterAuthOpenApi } from '@classytic/arc/auth';
|
|
1141
|
-
//
|
|
1142
|
-
//
|
|
1143
|
-
// keeps Mongoose out of Prisma/Drizzle/Kysely bundles).
|
|
1227
|
+
// Optional Mongoose stub-models bridge for `populate()` against Better Auth
|
|
1228
|
+
// collections — subpath gate keeps Mongoose out of Prisma/Drizzle/Kysely bundles.
|
|
1144
1229
|
import { registerBetterAuthMongooseModels } from '@classytic/arc/auth/mongoose';
|
|
1145
1230
|
import type { ExternalOpenApiPaths } from '@classytic/arc/docs';
|
|
1146
1231
|
import { eventPlugin } from '@classytic/arc/events';
|
|
@@ -1158,7 +1243,7 @@ import { createTestApp } from '@classytic/arc/testing';
|
|
|
1158
1243
|
import { Type, ArcListResponse } from '@classytic/arc/schemas';
|
|
1159
1244
|
import { createStateMachine, CircuitBreaker, withCompensation, defineCompensation } from '@classytic/arc/utils';
|
|
1160
1245
|
import { defineMigration } from '@classytic/arc/migrations';
|
|
1161
|
-
// Scope accessors
|
|
1246
|
+
// Scope accessors
|
|
1162
1247
|
import {
|
|
1163
1248
|
// Type guards
|
|
1164
1249
|
isMember, isService, isElevated, isAuthenticated, hasOrgAccess,
|