@classytic/arc 2.16.0 → 2.17.0
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 +22 -2
- package/dist/{BaseController-DlCCTIxJ.mjs → BaseController-Dmeh4Y_y.mjs} +5 -47
- package/dist/{QueryCache-SvmT_9ti.d.mts → QueryCache-8YmAQ-zW.d.mts} +1 -1
- package/dist/{ResourceRegistry-f48hFk3m.mjs → ResourceRegistry-CzSzVK1m.mjs} +3 -0
- package/dist/audit/index.d.mts +1 -1
- package/dist/audit/index.mjs +2 -1
- package/dist/auth/index.d.mts +4 -4
- package/dist/auth/index.mjs +2 -2
- package/dist/auth/redis-session.d.mts +1 -1
- package/dist/auth/redis-session.mjs +28 -2
- package/dist/{buildHandler-BZX6zzDM.mjs → buildHandler-C_SkBNF3.mjs} +6 -4
- package/dist/cache/index.d.mts +3 -3
- package/dist/cli/commands/describe.d.mts +1 -1
- package/dist/cli/commands/describe.mjs +1 -1
- package/dist/cli/commands/docs.mjs +2 -2
- package/dist/cli/commands/generate.d.mts +1 -1
- package/dist/cli/commands/generate.mjs +1 -1
- package/dist/cli/commands/init.d.mts +1 -1
- package/dist/cli/commands/init.mjs +1 -1
- package/dist/cli/commands/introspect.mjs +1 -1
- package/dist/cli/index.d.mts +2 -2
- package/dist/cli/index.mjs +2 -2
- package/dist/core/index.d.mts +2 -2
- package/dist/core/index.mjs +3 -3
- package/dist/{core-DBJ_j6rX.mjs → core-DYJvu7aI.mjs} +174 -31
- package/dist/{createActionRouter-DUpN3Dd1.mjs → createActionRouter-BHUMb1-y.mjs} +6 -3
- package/dist/{createAggregationRouter-Dq-TUCuY.mjs → createAggregationRouter-BYVvhfc5.mjs} +3 -3
- package/dist/{createApp-DNccuhyI.mjs → createApp-CzCEEsEm.mjs} +9 -9
- package/dist/{defineEvent-DRwY0fYm.mjs → defineEvent-NPKUJRt8.mjs} +1 -1
- package/dist/docs/index.d.mts +2 -2
- package/dist/docs/index.mjs +2 -2
- package/dist/{elevation-Dci0AYLT.mjs → elevation-6mfTLBC-.mjs} +1 -1
- package/dist/{errorHandler-DpoXQHZ9.mjs → errorHandler-wQejMBT3.mjs} +45 -4
- package/dist/{eventPlugin-C2cGqtRO.mjs → eventPlugin-CnJCUyM0.mjs} +1 -1
- package/dist/{eventPlugin-CtHC_av1.d.mts → eventPlugin-DjEx_J3j.d.mts} +1 -1
- package/dist/events/index.d.mts +3 -3
- package/dist/events/index.mjs +5 -5
- package/dist/events/transports/redis-stream-entry.d.mts +1 -1
- package/dist/events/transports/redis-stream-entry.mjs +34 -1
- package/dist/events/transports/redis.d.mts +1 -1
- package/dist/events/transports/redis.mjs +17 -1
- package/dist/factory/index.d.mts +2 -2
- package/dist/factory/index.mjs +4 -3
- package/dist/fastify-augmentation-7LIR0nDv.d.mts +42 -0
- package/dist/{fields-Anj0xdih.d.mts → fields-DqZLp5d6.d.mts} +1 -1
- package/dist/hooks/index.d.mts +1 -1
- package/dist/idempotency/index.d.mts +2 -2
- package/dist/idempotency/index.mjs +21 -5
- package/dist/idempotency/redis.d.mts +1 -1
- package/dist/{index-3oIimXQn.d.mts → index-3L-adFJO.d.mts} +3 -3
- package/dist/{index-CkW0flkU.d.mts → index-C1JSBan-.d.mts} +256 -8
- package/dist/{index-B-ulKx5P.d.mts → index-CruZir1o.d.mts} +36 -16
- package/dist/index.d.mts +5 -5
- package/dist/index.mjs +5 -5
- package/dist/integrations/event-gateway.d.mts +2 -2
- package/dist/integrations/event-gateway.mjs +1 -1
- package/dist/integrations/index.d.mts +2 -2
- package/dist/integrations/jobs.d.mts +36 -1
- package/dist/integrations/jobs.mjs +82 -9
- package/dist/integrations/mcp/index.d.mts +67 -5
- package/dist/integrations/mcp/index.mjs +93 -2
- package/dist/integrations/mcp/testing.d.mts +1 -1
- package/dist/integrations/mcp/testing.mjs +1 -1
- package/dist/integrations/websocket-redis.d.mts +1 -1
- package/dist/integrations/websocket.d.mts +1 -1
- package/dist/integrations/websocket.mjs +2 -1
- package/dist/{loadResourcesFromEntry-BLMEI2Xa.mjs → loadResourcesFromEntry-DCjYMgfC.mjs} +1 -1
- package/dist/{mcpPlugin-7vGV51ED.mjs → mcpPlugin-hZE00H74.mjs} +114 -11
- package/dist/middleware/index.d.mts +1 -1
- package/dist/middleware/index.mjs +1 -1
- package/dist/{multipartBody-BOvVSVCD.mjs → multipartBody-VjVWmZl8.mjs} +2 -1
- package/dist/{openapi-34T9yNwd.mjs → openapi-DKDs9NfD.mjs} +37 -10
- package/dist/permissions/index.d.mts +2 -2
- package/dist/permissions/index.mjs +2 -2
- package/dist/{permissions-CTxMrreC.mjs → permissions-B1Qy55fg.mjs} +169 -169
- package/dist/pipeline/index.d.mts +1 -1
- package/dist/plugins/index.d.mts +5 -59
- package/dist/plugins/index.mjs +7 -7
- package/dist/plugins/tracing-entry.d.mts +1 -1
- package/dist/plugins/tracing-entry.mjs +1 -1
- package/dist/preloadResources-BOXzPhKU.mjs +45 -0
- package/dist/presets/filesUpload.d.mts +4 -4
- package/dist/presets/filesUpload.mjs +2 -2
- package/dist/presets/index.d.mts +1 -1
- package/dist/presets/index.mjs +1 -1
- package/dist/presets/multiTenant.d.mts +1 -1
- package/dist/presets/search.d.mts +2 -2
- package/dist/presets/search.mjs +1 -1
- package/dist/{presets-C9BE6WaZ.mjs → presets-BYbPobbF.mjs} +5 -3
- package/dist/{queryCachePlugin-Biqzfbi5.d.mts → queryCachePlugin-Do4P_WBC.d.mts} +2 -2
- package/dist/{redis-Cyzrz6SX.d.mts → redis-cpqVb85w.d.mts} +1 -1
- package/dist/{redis-stream-DT-YjzrB.d.mts → redis-stream-BuiAS3aE.d.mts} +1 -1
- package/dist/registry/index.d.mts +30 -8
- package/dist/registry/index.mjs +2 -2
- package/dist/{registry-BBE23CDj.mjs → registry-BrlHkrRS.mjs} +14 -8
- package/dist/{routerShared-CZV5aabX.mjs → routerShared-nCyJGOKX.mjs} +164 -17
- package/dist/scope/index.d.mts +2 -2
- package/dist/scope/index.mjs +2 -2
- package/dist/testing/index.d.mts +2 -25
- package/dist/testing/index.mjs +2 -45
- package/dist/testing/storageContract.d.mts +1 -1
- package/dist/types/index.d.mts +4 -4
- package/dist/types/storage.d.mts +1 -1
- package/dist/{types-D-fYtKjb.d.mts → types-BRmS8TNa.d.mts} +34 -11
- package/dist/{types-BsJMEQ4D.d.mts → types-CoMjNmkF.d.mts} +12 -1
- package/dist/utils/index.d.mts +83 -5
- package/dist/utils/index.mjs +2 -2
- package/dist/{utils-DC5ycPfr.mjs → utils-CvD3glpM.mjs} +168 -3
- package/dist/{validate-By96rH0r.mjs → validate-DFIP2VrM.mjs} +65 -3
- package/dist/{versioning-ZwX9tmbS.d.mts → versioning-5H4930tj.d.mts} +1 -1
- package/package.json +3 -3
- package/skills/arc/SKILL.md +43 -1
- package/skills/arc/references/api-reference.md +2 -0
- package/skills/arc/references/mcp.md +30 -1
- /package/dist/{EventTransport-C-2oAHtw.d.mts → EventTransport-B1GFiYUy.d.mts} +0 -0
- /package/dist/{EventTransport-Hxvv5QQz.mjs → EventTransport-ClEDfiMS.mjs} +0 -0
- /package/dist/{caching-TeHE8G-v.mjs → caching-8MfrrUYU.mjs} +0 -0
- /package/dist/{elevation-0YBpa663.d.mts → elevation-DvwtjDwu.d.mts} +0 -0
- /package/dist/{errorHandler-mHuyWzZE.d.mts → errorHandler-DuqTqeqn.d.mts} +0 -0
- /package/dist/{errors-C1lX_jlm.d.mts → errors-4ZloYFvm.d.mts} +0 -0
- /package/dist/{externalPaths-DFg-2KTp.d.mts → externalPaths-ClykOQqa.d.mts} +0 -0
- /package/dist/{generate-CYac-OLv.mjs → generate-CcM24hoq.mjs} +0 -0
- /package/dist/{generate-BWFwgcCM.d.mts → generate-cRnsafMS.d.mts} +0 -0
- /package/dist/{init-Dv71MsJr.d.mts → init-D-vrTZ1G.d.mts} +0 -0
- /package/dist/{init-HDvoO9L5.mjs → init-DYzfniuX.mjs} +0 -0
- /package/dist/{interface-CH0OQudo.d.mts → interface-BVkZsvEe.d.mts} +0 -0
- /package/dist/{interface-NwJ_qPlY.d.mts → interface-DoE6UoC2.d.mts} +0 -0
- /package/dist/{loadResources-ChQEj8ih.mjs → loadResources-fol6JuvW.mjs} +0 -0
- /package/dist/{metrics-TuOmguhi.mjs → metrics-B-PU4-Yu.mjs} +0 -0
- /package/dist/{replyHelpers-C-gD32oF.mjs → replyHelpers-boS5Rx26.mjs} +0 -0
- /package/dist/{schemaIR-Ctc89DSn.mjs → schemaIR-DAOvBWgT.mjs} +0 -0
- /package/dist/{sessionManager-BqFegc0W.d.mts → sessionManager-DlmaHUAl.d.mts} +0 -0
- /package/dist/{sse-BY6sTy4P.mjs → sse-jh5OqFjR.mjs} +0 -0
- /package/dist/{storage-D2KZJAmn.d.mts → storage-HcAgP5Ni.d.mts} +0 -0
- /package/dist/{store-helpers-B0sunfZZ.mjs → store-helpers-5JgaxieH.mjs} +0 -0
- /package/dist/{tracing-Dm8n7Cnn.d.mts → tracing-bRqqUxFO.d.mts} +0 -0
- /package/dist/{types-DVfpSfx2.d.mts → types-CfapW2t8.d.mts} +0 -0
- /package/dist/{types-D57iXYb8.mjs → types-DV9WDfeg.mjs} +0 -0
- /package/dist/{versioning-B6mimogM.mjs → versioning-CuIwUQZ2.mjs} +0 -0
- /package/dist/{websocket-BkjeGZRn.d.mts → websocket-BJ0w-3Dt.d.mts} +0 -0
package/dist/utils/index.d.mts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { Kt as AnyRecord, S as QueryParserInterface, Zt as UserLike, b as ParsedQuery, et as OpenApiSchemas, st as ResourceConfig } from "../index-
|
|
2
|
-
import { n as ErrorMapper } from "../errorHandler-
|
|
3
|
-
import { a as NotFoundError, c as RateLimitError, d as ValidationError, f as createDomainError, i as ForbiddenError, l as ServiceUnavailableError, m as isArcError, n as ConflictError, o as OrgAccessDeniedError, p as createError, r as ErrorOptions, s as OrgRequiredError, t as ArcError, u as UnauthorizedError } from "../errors-
|
|
1
|
+
import { Kt as AnyRecord, S as QueryParserInterface, Zt as UserLike, b as ParsedQuery, et as OpenApiSchemas, st as ResourceConfig } from "../index-C1JSBan-.mjs";
|
|
2
|
+
import { n as ErrorMapper } from "../errorHandler-DuqTqeqn.mjs";
|
|
3
|
+
import { a as NotFoundError, c as RateLimitError, d as ValidationError, f as createDomainError, i as ForbiddenError, l as ServiceUnavailableError, m as isArcError, n as ConflictError, o as OrgAccessDeniedError, p as createError, r as ErrorOptions, s as OrgRequiredError, t as ArcError, u as UnauthorizedError } from "../errors-4ZloYFvm.mjs";
|
|
4
4
|
import { errorContractSchema, errorDetailSchema } from "@classytic/repo-core/errors";
|
|
5
5
|
import { FastifyInstance, FastifyReply, FastifyRequest, RouteHandlerMethod } from "fastify";
|
|
6
6
|
|
|
@@ -1144,7 +1144,8 @@ declare function simpleEqualityMatcher(item: unknown, filters: Record<string, un
|
|
|
1144
1144
|
* State Machine Utility
|
|
1145
1145
|
*
|
|
1146
1146
|
* Pure utility for validating state transitions in workflow systems.
|
|
1147
|
-
* Zero dependencies,
|
|
1147
|
+
* Zero runtime dependencies — usable from any layer of arc (controllers,
|
|
1148
|
+
* hooks, pipeline steps, tests).
|
|
1148
1149
|
*
|
|
1149
1150
|
* @example
|
|
1150
1151
|
* const orderState = createStateMachine('Order', {
|
|
@@ -1254,6 +1255,83 @@ declare function createStateMachine(name: string, transitions?: TransitionConfig
|
|
|
1254
1255
|
trackHistory?: boolean;
|
|
1255
1256
|
}): StateMachine;
|
|
1256
1257
|
//#endregion
|
|
1258
|
+
//#region src/utils/streaming.d.ts
|
|
1259
|
+
/**
|
|
1260
|
+
* Canonical SSE headers for AI-SDK-style UI message streams.
|
|
1261
|
+
*
|
|
1262
|
+
* `x-vercel-ai-ui-message-stream: v1` is the marker the Vercel AI SDK
|
|
1263
|
+
* client uses to detect the v1 UI message stream protocol (vs the older
|
|
1264
|
+
* data stream protocol). Surfaced as a constant so hosts don't memorise it.
|
|
1265
|
+
*/
|
|
1266
|
+
declare const UI_MESSAGE_STREAM_HEADERS: {
|
|
1267
|
+
readonly "content-type": "text/event-stream";
|
|
1268
|
+
readonly "cache-control": "no-cache";
|
|
1269
|
+
readonly connection: "keep-alive";
|
|
1270
|
+
readonly "x-accel-buffering": "no";
|
|
1271
|
+
readonly "x-vercel-ai-ui-message-stream": "v1";
|
|
1272
|
+
};
|
|
1273
|
+
interface PipeUIMessageStreamOptions {
|
|
1274
|
+
/**
|
|
1275
|
+
* Headers to send before the first chunk. Defaults to
|
|
1276
|
+
* {@link UI_MESSAGE_STREAM_HEADERS}. Pass a fully-replaced bag when
|
|
1277
|
+
* you need a different protocol marker; merge manually otherwise —
|
|
1278
|
+
* the helper does not deep-merge to keep behaviour predictable.
|
|
1279
|
+
*/
|
|
1280
|
+
headers?: Record<string, string>;
|
|
1281
|
+
/**
|
|
1282
|
+
* Abort signal — when fired, the helper cancels the source stream and
|
|
1283
|
+
* ends the reply early. `request.raw` `close` is wired automatically
|
|
1284
|
+
* when {@link pipeUIMessageStreamToReply} sees `reply.request`, so most
|
|
1285
|
+
* hosts never pass this directly.
|
|
1286
|
+
*/
|
|
1287
|
+
signal?: AbortSignal;
|
|
1288
|
+
/**
|
|
1289
|
+
* Custom chunk serialiser. Default: `JSON.stringify(chunk)`. Override
|
|
1290
|
+
* when chunks are already strings (rare — most AI SDK transforms emit
|
|
1291
|
+
* objects) or when you need pretty-print / type-prefix shenanigans.
|
|
1292
|
+
*/
|
|
1293
|
+
serialize?: (chunk: unknown) => string;
|
|
1294
|
+
}
|
|
1295
|
+
/**
|
|
1296
|
+
* Pipe a Web `ReadableStream` of JSON-serialisable chunks (typically
|
|
1297
|
+
* `UIMessageChunk` from the Vercel AI SDK) into a Fastify reply as an
|
|
1298
|
+
* SSE event stream.
|
|
1299
|
+
*
|
|
1300
|
+
* - Sets {@link UI_MESSAGE_STREAM_HEADERS} (or the caller's override)
|
|
1301
|
+
* before the first chunk.
|
|
1302
|
+
* - Writes each chunk as `data: ${JSON.stringify(chunk)}\n\n`.
|
|
1303
|
+
* - Cancels the source and closes the reply when the client disconnects.
|
|
1304
|
+
* - Resolves once the stream is fully written.
|
|
1305
|
+
*
|
|
1306
|
+
* @example
|
|
1307
|
+
* ```ts
|
|
1308
|
+
* import { pipeUIMessageStreamToReply } from '@classytic/arc/utils';
|
|
1309
|
+
* import { streamText, convertToUIMessageStream } from 'ai';
|
|
1310
|
+
*
|
|
1311
|
+
* defineResource({
|
|
1312
|
+
* name: 'chat',
|
|
1313
|
+
* routes: [{
|
|
1314
|
+
* method: 'POST', path: '/', streamResponse: true,
|
|
1315
|
+
* handler: async (req, reply) => {
|
|
1316
|
+
* const result = streamText({ model, messages: req.body.messages });
|
|
1317
|
+
* await pipeUIMessageStreamToReply(reply, result.toUIMessageStream());
|
|
1318
|
+
* },
|
|
1319
|
+
* }],
|
|
1320
|
+
* });
|
|
1321
|
+
* ```
|
|
1322
|
+
*/
|
|
1323
|
+
declare function pipeUIMessageStreamToReply(reply: FastifyReply, stream: ReadableStream<unknown>, options?: PipeUIMessageStreamOptions): Promise<void>;
|
|
1324
|
+
/**
|
|
1325
|
+
* Heuristic — does this value look like a Web `ReadableStream`?
|
|
1326
|
+
*
|
|
1327
|
+
* Used by `createCrudRouter` to auto-pipe `streamResponse: true` handlers
|
|
1328
|
+
* that return a UI message stream instead of writing to `reply.raw`
|
|
1329
|
+
* directly. Cross-realm safe (no `instanceof`) — checks the duck shape
|
|
1330
|
+
* the Streams spec mandates: `getReader`, `cancel`, and the `locked`
|
|
1331
|
+
* getter.
|
|
1332
|
+
*/
|
|
1333
|
+
declare function isReadableStream(value: unknown): value is ReadableStream<unknown>;
|
|
1334
|
+
//#endregion
|
|
1257
1335
|
//#region src/utils/typeGuards.d.ts
|
|
1258
1336
|
interface EventsDecorator {
|
|
1259
1337
|
publish: <T>(type: string, payload: T, meta?: Record<string, unknown>) => Promise<void>;
|
|
@@ -1283,4 +1361,4 @@ declare function hasEvents(instance: FastifyInstance): instance is FastifyInstan
|
|
|
1283
1361
|
*/
|
|
1284
1362
|
declare function getUserId(user: UserLike | null | undefined): string | undefined;
|
|
1285
1363
|
//#endregion
|
|
1286
|
-
export { ArcError, ArcQueryParser, type ArcQueryParserOptions, CircuitBreaker, CircuitBreakerError, type CircuitBreakerOptions, CircuitBreakerRegistry, type CircuitBreakerStats, CircuitState, type CompensationDefinition, type CompensationError, type CompensationHooks, type CompensationResult, type CompensationStep, type ConfigError, ConflictError, type ErrorOptions, type EventsDecorator, ForbiddenError, type Guard, type GuardConfig, type JsonSchema, type JsonSchemaTarget, NotFoundError, OrgAccessDeniedError, OrgRequiredError, RateLimitError, ServiceUnavailableError, type StateMachine, type TransitionConfig, UnauthorizedError, type ValidateOptions, ValidationError, type ValidationResult, aggregateListResponse, assertValidConfig, bareListResponse, convertOpenApiSchemas, convertRouteSchema, createCircuitBreaker, createCircuitBreakerRegistry, createDomainError, createError, createQueryParser, createStateMachine, defineCompensation, defineErrorMapper, defineGuard, deleteResponse, errorContractSchema, errorDetailSchema, formatValidationErrors, getDefaultCrudSchemas, getListQueryParams, getUserId, handleRaw, hasEvents, isArcError, isJsonSchema, isZodSchema, keysetListResponse, listResponse, offsetListResponse, paginationSchema, queryParams, responses, scheduleBackground, simpleEqualityMatcher, toJsonSchema, validateResourceConfig, withCompensation };
|
|
1364
|
+
export { ArcError, ArcQueryParser, type ArcQueryParserOptions, CircuitBreaker, CircuitBreakerError, type CircuitBreakerOptions, CircuitBreakerRegistry, type CircuitBreakerStats, CircuitState, type CompensationDefinition, type CompensationError, type CompensationHooks, type CompensationResult, type CompensationStep, type ConfigError, ConflictError, type ErrorOptions, type EventsDecorator, ForbiddenError, type Guard, type GuardConfig, type JsonSchema, type JsonSchemaTarget, NotFoundError, OrgAccessDeniedError, OrgRequiredError, type PipeUIMessageStreamOptions, RateLimitError, ServiceUnavailableError, type StateMachine, type TransitionConfig, UI_MESSAGE_STREAM_HEADERS, UnauthorizedError, type ValidateOptions, ValidationError, type ValidationResult, aggregateListResponse, assertValidConfig, bareListResponse, convertOpenApiSchemas, convertRouteSchema, createCircuitBreaker, createCircuitBreakerRegistry, createDomainError, createError, createQueryParser, createStateMachine, defineCompensation, defineErrorMapper, defineGuard, deleteResponse, errorContractSchema, errorDetailSchema, formatValidationErrors, getDefaultCrudSchemas, getListQueryParams, getUserId, handleRaw, hasEvents, isArcError, isJsonSchema, isReadableStream, isZodSchema, keysetListResponse, listResponse, offsetListResponse, paginationSchema, pipeUIMessageStreamToReply, queryParams, responses, scheduleBackground, simpleEqualityMatcher, toJsonSchema, validateResourceConfig, withCompensation };
|
package/dist/utils/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { a as OrgAccessDeniedError, c as ServiceUnavailableError, d as createDomainError, f as createError, i as NotFoundError, l as UnauthorizedError, n as ConflictError, o as OrgRequiredError, p as isArcError, r as ForbiddenError, s as RateLimitError, t as ArcError, u as ValidationError } from "../errors-j4aJm1Wg.mjs";
|
|
2
|
-
import { A as
|
|
2
|
+
import { A as CircuitState, C as defineGuard, D as CircuitBreaker, E as withCompensation, F as validateResourceConfig, I as simpleEqualityMatcher, M as createCircuitBreakerRegistry, N as assertValidConfig, O as CircuitBreakerError, P as formatValidationErrors, S as handleRaw, T as defineCompensation, _ as paginationSchema, a as createStateMachine, b as ArcQueryParser, c as bareListResponse, d as errorDetailSchema, f as getDefaultCrudSchemas, g as offsetListResponse, h as listResponse, i as pipeUIMessageStreamToReply, j as createCircuitBreaker, k as CircuitBreakerRegistry, l as deleteResponse, m as keysetListResponse, n as UI_MESSAGE_STREAM_HEADERS, o as scheduleBackground, p as getListQueryParams, r as isReadableStream, s as aggregateListResponse, t as getUserId, u as errorContractSchema, v as queryParams, w as defineErrorMapper, x as createQueryParser, y as responses } from "../utils-CvD3glpM.mjs";
|
|
3
3
|
import { a as toJsonSchema, i as isZodSchema, n as convertRouteSchema, r as isJsonSchema, t as convertOpenApiSchemas } from "../schemaConverter-De34B1ZG.mjs";
|
|
4
4
|
import { t as hasEvents } from "../typeGuards-BzkXkvVv.mjs";
|
|
5
|
-
export { ArcError, ArcQueryParser, CircuitBreaker, CircuitBreakerError, CircuitBreakerRegistry, CircuitState, ConflictError, ForbiddenError, NotFoundError, OrgAccessDeniedError, OrgRequiredError, RateLimitError, ServiceUnavailableError, UnauthorizedError, ValidationError, aggregateListResponse, assertValidConfig, bareListResponse, convertOpenApiSchemas, convertRouteSchema, createCircuitBreaker, createCircuitBreakerRegistry, createDomainError, createError, createQueryParser, createStateMachine, defineCompensation, defineErrorMapper, defineGuard, deleteResponse, errorContractSchema, errorDetailSchema, formatValidationErrors, getDefaultCrudSchemas, getListQueryParams, getUserId, handleRaw, hasEvents, isArcError, isJsonSchema, isZodSchema, keysetListResponse, listResponse, offsetListResponse, paginationSchema, queryParams, responses, scheduleBackground, simpleEqualityMatcher, toJsonSchema, validateResourceConfig, withCompensation };
|
|
5
|
+
export { ArcError, ArcQueryParser, CircuitBreaker, CircuitBreakerError, CircuitBreakerRegistry, CircuitState, ConflictError, ForbiddenError, NotFoundError, OrgAccessDeniedError, OrgRequiredError, RateLimitError, ServiceUnavailableError, UI_MESSAGE_STREAM_HEADERS, UnauthorizedError, ValidationError, aggregateListResponse, assertValidConfig, bareListResponse, convertOpenApiSchemas, convertRouteSchema, createCircuitBreaker, createCircuitBreakerRegistry, createDomainError, createError, createQueryParser, createStateMachine, defineCompensation, defineErrorMapper, defineGuard, deleteResponse, errorContractSchema, errorDetailSchema, formatValidationErrors, getDefaultCrudSchemas, getListQueryParams, getUserId, handleRaw, hasEvents, isArcError, isJsonSchema, isReadableStream, isZodSchema, keysetListResponse, listResponse, offsetListResponse, paginationSchema, pipeUIMessageStreamToReply, queryParams, responses, scheduleBackground, simpleEqualityMatcher, toJsonSchema, validateResourceConfig, withCompensation };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { m as RESERVED_QUERY_PARAMS, t as CRUD_OPERATIONS } from "./constants-TrJVIJl0.mjs";
|
|
2
2
|
import { arcLog } from "./logger/index.mjs";
|
|
3
3
|
import { t as ArcError } from "./errors-j4aJm1Wg.mjs";
|
|
4
|
-
import { r as getAvailablePresets } from "./presets-
|
|
4
|
+
import { r as getAvailablePresets } from "./presets-BYbPobbF.mjs";
|
|
5
5
|
import { errorContractSchema as errorContractSchema$1, errorDetailSchema as errorDetailSchema$1 } from "@classytic/repo-core/errors";
|
|
6
6
|
//#region src/utils/simpleEqualityMatcher.ts
|
|
7
7
|
/**
|
|
@@ -142,7 +142,10 @@ function validateResourceConfig(config, options = {}) {
|
|
|
142
142
|
suggestion: `Change to "${config.prefix.slice(0, -1)}"`
|
|
143
143
|
});
|
|
144
144
|
}
|
|
145
|
-
if (config.routes)
|
|
145
|
+
if (config.routes) {
|
|
146
|
+
validateRoutes(config.routes, errors);
|
|
147
|
+
validateRouteCrudCollisions(config, errors);
|
|
148
|
+
}
|
|
146
149
|
return {
|
|
147
150
|
valid: errors.length === 0,
|
|
148
151
|
errors,
|
|
@@ -210,6 +213,71 @@ function validatePresetOptions(preset, warnings) {
|
|
|
210
213
|
suggestion: validOptions.length > 0 ? `Valid options: ${validOptions.join(", ")}` : `Preset "${preset.name}" has no configurable options`
|
|
211
214
|
});
|
|
212
215
|
}
|
|
216
|
+
/**
|
|
217
|
+
* Detect collisions between user-declared `routes:` entries and the
|
|
218
|
+
* auto-CRUD route table that `createCrudRouter` will register.
|
|
219
|
+
*
|
|
220
|
+
* Without this check, a custom `POST /` on the same prefix as auto-CRUD's
|
|
221
|
+
* `create` boots fine through `defineResource()` and only blows up at
|
|
222
|
+
* `app.register()` time with Fastify's opaque `FST_ERR_DUPLICATED_ROUTE`.
|
|
223
|
+
* That error doesn't mention `disabledRoutes` and doesn't distinguish
|
|
224
|
+
* "you collided with auto-CRUD" from "you collided with yourself" — so
|
|
225
|
+
* the consumer has to grep arc's source to discover the fix.
|
|
226
|
+
*
|
|
227
|
+
* We surface the fix in the error message, naming the conflicting op
|
|
228
|
+
* and the literal line to add.
|
|
229
|
+
*
|
|
230
|
+
* Honored config:
|
|
231
|
+
* - `disableDefaultRoutes: true` → no auto-CRUD, no collisions possible
|
|
232
|
+
* - `disabledRoutes: ['create']` → that op's path is free for custom use
|
|
233
|
+
* - `updateMethod: 'PUT' | 'both'` → update slot covers PUT/PATCH accordingly
|
|
234
|
+
*/
|
|
235
|
+
function validateRouteCrudCollisions(config, errors) {
|
|
236
|
+
if (config.disableDefaultRoutes) return;
|
|
237
|
+
if (!config.routes || config.routes.length === 0) return;
|
|
238
|
+
const updateMethod = config.updateMethod ?? "PATCH";
|
|
239
|
+
const updateMethods = updateMethod === "both" ? ["PUT", "PATCH"] : [updateMethod];
|
|
240
|
+
const disabled = new Set(config.disabledRoutes ?? []);
|
|
241
|
+
const crudTable = [
|
|
242
|
+
{
|
|
243
|
+
op: "list",
|
|
244
|
+
method: "GET",
|
|
245
|
+
path: "/"
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
op: "get",
|
|
249
|
+
method: "GET",
|
|
250
|
+
path: "/:id"
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
op: "create",
|
|
254
|
+
method: "POST",
|
|
255
|
+
path: "/"
|
|
256
|
+
},
|
|
257
|
+
...updateMethods.map((m) => ({
|
|
258
|
+
op: "update",
|
|
259
|
+
method: m,
|
|
260
|
+
path: "/:id"
|
|
261
|
+
})),
|
|
262
|
+
{
|
|
263
|
+
op: "delete",
|
|
264
|
+
method: "DELETE",
|
|
265
|
+
path: "/:id"
|
|
266
|
+
}
|
|
267
|
+
];
|
|
268
|
+
for (const [i, route] of config.routes.entries()) {
|
|
269
|
+
if (!route.method || !route.path) continue;
|
|
270
|
+
for (const crud of crudTable) {
|
|
271
|
+
if (disabled.has(crud.op)) continue;
|
|
272
|
+
if (route.method !== crud.method || route.path !== crud.path) continue;
|
|
273
|
+
errors.push({
|
|
274
|
+
field: `routes[${i}]`,
|
|
275
|
+
message: `Route ${route.method} ${route.path} collides with auto-CRUD "${crud.op}"`,
|
|
276
|
+
suggestion: `Add \`disabledRoutes: ['${crud.op}']\` to suppress the auto-CRUD route, or move your custom handler to a different path. Set \`disableDefaultRoutes: true\` to disable all auto-CRUD on this resource.`
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
213
281
|
function validateRoutes(routes, errors) {
|
|
214
282
|
const validMethods = [
|
|
215
283
|
"GET",
|
|
@@ -1602,6 +1670,103 @@ function createStateMachine(name, transitions = {}, options = {}) {
|
|
|
1602
1670
|
};
|
|
1603
1671
|
}
|
|
1604
1672
|
//#endregion
|
|
1673
|
+
//#region src/utils/streaming.ts
|
|
1674
|
+
/**
|
|
1675
|
+
* Canonical SSE headers for AI-SDK-style UI message streams.
|
|
1676
|
+
*
|
|
1677
|
+
* `x-vercel-ai-ui-message-stream: v1` is the marker the Vercel AI SDK
|
|
1678
|
+
* client uses to detect the v1 UI message stream protocol (vs the older
|
|
1679
|
+
* data stream protocol). Surfaced as a constant so hosts don't memorise it.
|
|
1680
|
+
*/
|
|
1681
|
+
const UI_MESSAGE_STREAM_HEADERS = {
|
|
1682
|
+
"content-type": "text/event-stream",
|
|
1683
|
+
"cache-control": "no-cache",
|
|
1684
|
+
connection: "keep-alive",
|
|
1685
|
+
"x-accel-buffering": "no",
|
|
1686
|
+
"x-vercel-ai-ui-message-stream": "v1"
|
|
1687
|
+
};
|
|
1688
|
+
/**
|
|
1689
|
+
* Pipe a Web `ReadableStream` of JSON-serialisable chunks (typically
|
|
1690
|
+
* `UIMessageChunk` from the Vercel AI SDK) into a Fastify reply as an
|
|
1691
|
+
* SSE event stream.
|
|
1692
|
+
*
|
|
1693
|
+
* - Sets {@link UI_MESSAGE_STREAM_HEADERS} (or the caller's override)
|
|
1694
|
+
* before the first chunk.
|
|
1695
|
+
* - Writes each chunk as `data: ${JSON.stringify(chunk)}\n\n`.
|
|
1696
|
+
* - Cancels the source and closes the reply when the client disconnects.
|
|
1697
|
+
* - Resolves once the stream is fully written.
|
|
1698
|
+
*
|
|
1699
|
+
* @example
|
|
1700
|
+
* ```ts
|
|
1701
|
+
* import { pipeUIMessageStreamToReply } from '@classytic/arc/utils';
|
|
1702
|
+
* import { streamText, convertToUIMessageStream } from 'ai';
|
|
1703
|
+
*
|
|
1704
|
+
* defineResource({
|
|
1705
|
+
* name: 'chat',
|
|
1706
|
+
* routes: [{
|
|
1707
|
+
* method: 'POST', path: '/', streamResponse: true,
|
|
1708
|
+
* handler: async (req, reply) => {
|
|
1709
|
+
* const result = streamText({ model, messages: req.body.messages });
|
|
1710
|
+
* await pipeUIMessageStreamToReply(reply, result.toUIMessageStream());
|
|
1711
|
+
* },
|
|
1712
|
+
* }],
|
|
1713
|
+
* });
|
|
1714
|
+
* ```
|
|
1715
|
+
*/
|
|
1716
|
+
async function pipeUIMessageStreamToReply(reply, stream, options = {}) {
|
|
1717
|
+
const serialize = options.serialize ?? defaultSerialize;
|
|
1718
|
+
const raw = reply.raw;
|
|
1719
|
+
if (!raw.headersSent) {
|
|
1720
|
+
const headers = options.headers ?? UI_MESSAGE_STREAM_HEADERS;
|
|
1721
|
+
for (const [key, value] of Object.entries(headers)) raw.setHeader(key, value);
|
|
1722
|
+
raw.statusCode = reply.statusCode;
|
|
1723
|
+
raw.flushHeaders?.();
|
|
1724
|
+
}
|
|
1725
|
+
const reader = stream.getReader();
|
|
1726
|
+
const onAbort = () => {
|
|
1727
|
+
reader.cancel().catch(() => {});
|
|
1728
|
+
};
|
|
1729
|
+
reply.request.raw.once("close", onAbort);
|
|
1730
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
1731
|
+
try {
|
|
1732
|
+
while (true) {
|
|
1733
|
+
const { value, done } = await reader.read();
|
|
1734
|
+
if (done) break;
|
|
1735
|
+
if (!raw.write(`data: ${serialize(value)}\n\n`)) await once(raw, "drain");
|
|
1736
|
+
}
|
|
1737
|
+
} catch (err) {
|
|
1738
|
+
if (!raw.writableEnded) raw.write(`event: error\ndata: ${JSON.stringify({ message: String(err) })}\n\n`);
|
|
1739
|
+
throw err;
|
|
1740
|
+
} finally {
|
|
1741
|
+
reply.request.raw.removeListener("close", onAbort);
|
|
1742
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
1743
|
+
if (!raw.writableEnded) raw.end();
|
|
1744
|
+
reader.releaseLock?.();
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
function defaultSerialize(chunk) {
|
|
1748
|
+
return typeof chunk === "string" ? chunk : JSON.stringify(chunk);
|
|
1749
|
+
}
|
|
1750
|
+
function once(emitter, event) {
|
|
1751
|
+
return new Promise((resolve) => {
|
|
1752
|
+
emitter.once(event, () => resolve());
|
|
1753
|
+
});
|
|
1754
|
+
}
|
|
1755
|
+
/**
|
|
1756
|
+
* Heuristic — does this value look like a Web `ReadableStream`?
|
|
1757
|
+
*
|
|
1758
|
+
* Used by `createCrudRouter` to auto-pipe `streamResponse: true` handlers
|
|
1759
|
+
* that return a UI message stream instead of writing to `reply.raw`
|
|
1760
|
+
* directly. Cross-realm safe (no `instanceof`) — checks the duck shape
|
|
1761
|
+
* the Streams spec mandates: `getReader`, `cancel`, and the `locked`
|
|
1762
|
+
* getter.
|
|
1763
|
+
*/
|
|
1764
|
+
function isReadableStream(value) {
|
|
1765
|
+
if (value === null || typeof value !== "object") return false;
|
|
1766
|
+
const obj = value;
|
|
1767
|
+
return typeof obj.getReader === "function" && typeof obj.cancel === "function" && typeof obj.locked === "boolean";
|
|
1768
|
+
}
|
|
1769
|
+
//#endregion
|
|
1605
1770
|
//#region src/utils/userHelpers.ts
|
|
1606
1771
|
/**
|
|
1607
1772
|
* Extract a user ID from a user object. Accepts `id` or `_id` — returns
|
|
@@ -1620,4 +1785,4 @@ function getUserId(user) {
|
|
|
1620
1785
|
return id ? String(id) : void 0;
|
|
1621
1786
|
}
|
|
1622
1787
|
//#endregion
|
|
1623
|
-
export {
|
|
1788
|
+
export { CircuitState as A, defineGuard as C, CircuitBreaker as D, withCompensation as E, validateResourceConfig as F, simpleEqualityMatcher as I, createCircuitBreakerRegistry as M, assertValidConfig as N, CircuitBreakerError as O, formatValidationErrors as P, handleRaw as S, defineCompensation as T, paginationSchema as _, createStateMachine as a, ArcQueryParser as b, bareListResponse as c, errorDetailSchema$1 as d, getDefaultCrudSchemas as f, offsetListResponse as g, listResponse as h, pipeUIMessageStreamToReply as i, createCircuitBreaker as j, CircuitBreakerRegistry as k, deleteResponse as l, keysetListResponse as m, UI_MESSAGE_STREAM_HEADERS as n, scheduleBackground as o, getListQueryParams as p, isReadableStream as r, aggregateListResponse as s, getUserId as t, errorContractSchema$1 as u, queryParams as v, defineErrorMapper as w, createQueryParser as x, responses as y };
|
|
@@ -11,6 +11,13 @@ var ArcAggregationConfigError = class extends Error {
|
|
|
11
11
|
name = "ArcAggregationConfigError";
|
|
12
12
|
};
|
|
13
13
|
/**
|
|
14
|
+
* Framework cap on URL-driven aggregation limits when the host opts
|
|
15
|
+
* in to `defaultLimit` without explicit `maxLimit`. Set to the same
|
|
16
|
+
* order of magnitude as a "large dashboard page" — bigger than every
|
|
17
|
+
* realistic grouped tile, smaller than "we returned the whole table."
|
|
18
|
+
*/
|
|
19
|
+
const AGG_FRAMEWORK_MAX_LIMIT = 1e3;
|
|
20
|
+
/**
|
|
14
21
|
* Validate + normalize all aggregations on a resource. Throws on first
|
|
15
22
|
* misconfig with the offending aggregation name in the message — hosts
|
|
16
23
|
* see exactly which entry needs fixing.
|
|
@@ -53,6 +60,8 @@ function validateAggregations(resourceName, aggregations, schemaOptions) {
|
|
|
53
60
|
bucketAliases
|
|
54
61
|
});
|
|
55
62
|
if (config.topN) validateTopNConfig(resourceName, name, config.topN, groupBy, measures, bucketAliases);
|
|
63
|
+
validateLimitConfig(resourceName, name, config);
|
|
64
|
+
const limitPolicy = resolveLimitPolicy(config);
|
|
56
65
|
out.push({
|
|
57
66
|
name,
|
|
58
67
|
base: config,
|
|
@@ -65,13 +74,47 @@ function validateAggregations(resourceName, aggregations, schemaOptions) {
|
|
|
65
74
|
having: config.having,
|
|
66
75
|
sort: config.sort,
|
|
67
76
|
limit: config.limit,
|
|
68
|
-
topN: config.topN
|
|
77
|
+
topN: config.topN,
|
|
78
|
+
...limitPolicy ? { limitPolicy } : {}
|
|
69
79
|
}
|
|
70
80
|
});
|
|
71
81
|
}
|
|
72
82
|
return out;
|
|
73
83
|
}
|
|
74
84
|
/**
|
|
85
|
+
* Validate `limit` / `defaultLimit` / `maxLimit` triad. Throws on
|
|
86
|
+
* misconfig at boot — boundary value, hosts see exactly what to fix.
|
|
87
|
+
*/
|
|
88
|
+
function validateLimitConfig(resourceName, aggName, config) {
|
|
89
|
+
const hasStatic = config.limit !== void 0;
|
|
90
|
+
const hasDynamic = config.defaultLimit !== void 0;
|
|
91
|
+
if (hasStatic && hasDynamic) throw new ArcAggregationConfigError(`Resource "${resourceName}" aggregation "${aggName}" sets both \`limit\` (static cap) and \`defaultLimit\` (URL-driven cap). Pick one — static \`limit\` ignores the URL; \`defaultLimit\` reads \`?limit=N\` and caps it at \`maxLimit\`.`);
|
|
92
|
+
if (config.maxLimit !== void 0 && !hasDynamic) throw new ArcAggregationConfigError(`Resource "${resourceName}" aggregation "${aggName}" sets \`maxLimit\` without \`defaultLimit\`. \`maxLimit\` only applies to URL-driven limits — set \`defaultLimit\` or remove \`maxLimit\`.`);
|
|
93
|
+
if (hasStatic) {
|
|
94
|
+
if (!Number.isInteger(config.limit) || config.limit <= 0) throw new ArcAggregationConfigError(`Resource "${resourceName}" aggregation "${aggName}" \`limit\` must be a positive integer — got ${String(config.limit)}.`);
|
|
95
|
+
}
|
|
96
|
+
if (hasDynamic) {
|
|
97
|
+
if (!Number.isInteger(config.defaultLimit) || config.defaultLimit <= 0) throw new ArcAggregationConfigError(`Resource "${resourceName}" aggregation "${aggName}" \`defaultLimit\` must be a positive integer — got ${String(config.defaultLimit)}.`);
|
|
98
|
+
if (config.maxLimit !== void 0) {
|
|
99
|
+
if (!Number.isInteger(config.maxLimit) || config.maxLimit <= 0) throw new ArcAggregationConfigError(`Resource "${resourceName}" aggregation "${aggName}" \`maxLimit\` must be a positive integer — got ${String(config.maxLimit)}.`);
|
|
100
|
+
if (config.maxLimit < config.defaultLimit) throw new ArcAggregationConfigError(`Resource "${resourceName}" aggregation "${aggName}" \`maxLimit\` (${config.maxLimit}) is less than \`defaultLimit\` (${config.defaultLimit}). The ceiling must be ≥ the default.`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Resolve the URL-driven limit policy into a concrete `{ default, max }`
|
|
106
|
+
* pair. Returns `undefined` when the host uses the static `limit` form
|
|
107
|
+
* (or no limit at all) — the request handler reads the absence to know
|
|
108
|
+
* "do not parse `?limit=N` from the URL."
|
|
109
|
+
*/
|
|
110
|
+
function resolveLimitPolicy(config) {
|
|
111
|
+
if (config.defaultLimit === void 0) return void 0;
|
|
112
|
+
return {
|
|
113
|
+
defaultLimit: config.defaultLimit,
|
|
114
|
+
maxLimit: config.maxLimit ?? AGG_FRAMEWORK_MAX_LIMIT
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
75
118
|
* Adapter feature-detect for `aggregate()`. Called at boot when the
|
|
76
119
|
* repository instance is available. Returns `true` when the kit ships
|
|
77
120
|
* `aggregate`; `false` when missing.
|
|
@@ -83,14 +126,33 @@ function adapterSupportsAggregate(repo) {
|
|
|
83
126
|
if (!repo || typeof repo !== "object") return false;
|
|
84
127
|
return typeof repo.aggregate === "function";
|
|
85
128
|
}
|
|
129
|
+
/**
|
|
130
|
+
* Resolve the effective per-request `limit` — three cases:
|
|
131
|
+
*
|
|
132
|
+
* 1. Host declared static `limit` → URL `?limit` ignored, static wins.
|
|
133
|
+
* 2. Host declared `defaultLimit` → parse `?limit=N`, cap at
|
|
134
|
+
* `maxLimit`, fall back to default on absent / invalid input.
|
|
135
|
+
* 3. Host declared neither → returns `undefined` (no limit applied,
|
|
136
|
+
* preserves pre-2.16.1 behavior).
|
|
137
|
+
*/
|
|
138
|
+
function resolveAggRequestLimit(normalized, query) {
|
|
139
|
+
const policy = normalized.compiled.limitPolicy;
|
|
140
|
+
if (!policy) return normalized.compiled.limit;
|
|
141
|
+
const raw = query["limit"];
|
|
142
|
+
if (raw === void 0 || raw === "") return policy.defaultLimit;
|
|
143
|
+
const parsed = typeof raw === "number" ? raw : Number.parseInt(String(raw), 10);
|
|
144
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return policy.defaultLimit;
|
|
145
|
+
return Math.min(Math.floor(parsed), policy.maxLimit);
|
|
146
|
+
}
|
|
86
147
|
/** Compile to canonical `AggRequest` for `repo.aggregate()` at request time. */
|
|
87
|
-
function compileAggRequest(normalized, callerFilter, tenantOptions) {
|
|
148
|
+
function compileAggRequest(normalized, callerFilter, tenantOptions, query) {
|
|
88
149
|
const filter = {
|
|
89
150
|
...normalized.compiled.filter ?? {},
|
|
90
151
|
...callerFilter
|
|
91
152
|
};
|
|
92
153
|
const executionHints = buildExecutionHints(normalized.base);
|
|
93
154
|
const cache = buildCacheOptions(normalized.base);
|
|
155
|
+
const limit = query ? resolveAggRequestLimit(normalized, query) : normalized.compiled.limit;
|
|
94
156
|
return {
|
|
95
157
|
measures: normalized.compiled.measures,
|
|
96
158
|
...Object.keys(filter).length > 0 ? { filter } : {},
|
|
@@ -99,7 +161,7 @@ function compileAggRequest(normalized, callerFilter, tenantOptions) {
|
|
|
99
161
|
...normalized.compiled.dateBuckets ? { dateBuckets: normalized.compiled.dateBuckets } : {},
|
|
100
162
|
...normalized.compiled.having ? { having: normalized.compiled.having } : {},
|
|
101
163
|
...normalized.compiled.sort ? { sort: normalized.compiled.sort } : {},
|
|
102
|
-
...
|
|
164
|
+
...limit !== void 0 ? { limit } : {},
|
|
103
165
|
...normalized.compiled.topN ? { topN: normalized.compiled.topN } : {},
|
|
104
166
|
...executionHints ? { executionHints } : {},
|
|
105
167
|
...cache ? { cache } : {}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@classytic/arc",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.17.0",
|
|
4
4
|
"description": "Resource-oriented backend framework for Fastify - clean, minimal, powerful, tree-shakable",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -231,7 +231,7 @@
|
|
|
231
231
|
"push": "classytic-push",
|
|
232
232
|
"release:tag": "node -e \"require('child_process').execSync('npm run push -- v'+require('./package.json').version,{stdio:'inherit'})\"",
|
|
233
233
|
"release": "npm run push -- main && npm run release:tag && npm publish",
|
|
234
|
-
"prepublishOnly": "npm run typecheck && npm run lint && npm run build && npm run test:
|
|
234
|
+
"prepublishOnly": "npm run typecheck && npm run lint && npm run build && npm run test:main && npm run smoke"
|
|
235
235
|
},
|
|
236
236
|
"engines": {
|
|
237
237
|
"node": ">=22"
|
|
@@ -352,7 +352,7 @@
|
|
|
352
352
|
"@classytic/mongokit": "^3.14.0",
|
|
353
353
|
"@classytic/primitives": "^0.6.0",
|
|
354
354
|
"@classytic/repo-core": "^0.5.0",
|
|
355
|
-
"@classytic/sqlitekit": "^0.
|
|
355
|
+
"@classytic/sqlitekit": "^0.5.0",
|
|
356
356
|
"@classytic/streamline": "^2.3.3",
|
|
357
357
|
"@fastify/cors": "^11.2.0",
|
|
358
358
|
"@fastify/helmet": "^13.0.2",
|
package/skills/arc/SKILL.md
CHANGED
|
@@ -134,6 +134,23 @@ export default defineResource({
|
|
|
134
134
|
|
|
135
135
|
**Auto-generated:** `GET /`, `GET /:id`, `POST /`, `PATCH /:id`, `DELETE /:id`. Presets add their own routes (see table below).
|
|
136
136
|
|
|
137
|
+
**Resource shorthands:**
|
|
138
|
+
|
|
139
|
+
```typescript
|
|
140
|
+
// "fetch-all" reference data — read-only crud (list+get), defaultLimit/maxLimit=1000,
|
|
141
|
+
// cache { staleTime: 300, gcTime: 600 }. Explicit narrow flags still win.
|
|
142
|
+
defineResource({ name: 'currency', adapter, referenceData: true });
|
|
143
|
+
|
|
144
|
+
// Service resource — no adapter, no auto-CRUD, no validation, no registry entry.
|
|
145
|
+
// Equivalent to disableDefaultRoutes + skipValidation + skipRegistry.
|
|
146
|
+
defineResource({ name: 'health', customRoutesOnly: true, routes: [...] });
|
|
147
|
+
|
|
148
|
+
// Resource-level pagination knobs (no custom queryParser needed):
|
|
149
|
+
defineResource({ name: 'pipeline', adapter, defaultLimit: 200, maxLimit: 500 });
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
400s on `?limit=` violations include the cap: `message: "Query parameter 'limit' must be <= 500 (got 800) (cap is 500)"` + `meta: { field: 'limit', cap: 500 }`.
|
|
153
|
+
|
|
137
154
|
## fieldRules
|
|
138
155
|
|
|
139
156
|
| Flag | Effect |
|
|
@@ -533,12 +550,37 @@ await app.register(mcpPlugin, {
|
|
|
533
550
|
|
|
534
551
|
**Per-resource opt-out:** `defineResource({ mcp: false })` — exclude a resource from MCP tool generation entirely. Useful for internal-admin or write-heavy resources where you don't want LLMs poking.
|
|
535
552
|
|
|
536
|
-
**Auth modes** — `false` | `getAuth()` (Better Auth OAuth 2.1) | custom function returning `{ userId, organizationId, roles }` (human) or `{ clientId, organizationId, scopes }` (service). `PermissionResult.filters` flow into MCP tools exactly like REST.
|
|
553
|
+
**Auth modes** — `false` | `getAuth()` (Better Auth OAuth 2.1) | `createMcpAuthFromBetterAuthApiKey(getAuth(), { orgFromMetadata: true })` (BA API-key plugin — handles `referenceId`/`userId` fallback + `metadata.organizationId` extraction) | custom function returning `{ userId, organizationId, roles }` (human) or `{ clientId, organizationId, scopes }` (service). `PermissionResult.filters` flow into MCP tools exactly like REST.
|
|
554
|
+
|
|
555
|
+
**Tool-name collisions** — preset vs user (e.g. `softDelete` route vs `actions.restore`) auto-namespace to `softdelete_restore_<resource>`; every other collision shape throws `ArcError('arc.mcp.tool_name_collision')` naming both sources.
|
|
537
556
|
|
|
538
557
|
**Custom tools** — co-locate with resources (`order.mcp.ts`), wire via `extraTools`. **AI SDK bridge** — expose AI SDK `tool()` defs via `buildMcpToolsFromBridges([...])`.
|
|
539
558
|
|
|
540
559
|
Connect Claude CLI: `claude mcp add --transport http my-api http://localhost:3000/mcp`. Full recipes → [references/mcp.md](references/mcp.md).
|
|
541
560
|
|
|
561
|
+
### AI SDK streaming → Fastify
|
|
562
|
+
|
|
563
|
+
`streamResponse: true` routes that return a Web `ReadableStream` (Vercel AI SDK's `result.toUIMessageStream()`, `fetch().body`) auto-pipe through `pipeUIMessageStreamToReply()` — no `JsonToSseTransformStream` boilerplate.
|
|
564
|
+
|
|
565
|
+
```typescript
|
|
566
|
+
import { streamText } from 'ai';
|
|
567
|
+
|
|
568
|
+
defineResource({
|
|
569
|
+
name: 'chat',
|
|
570
|
+
customRoutesOnly: true,
|
|
571
|
+
routes: [{
|
|
572
|
+
method: 'POST', path: '/', streamResponse: true, raw: true,
|
|
573
|
+
permissions: requireAuth(),
|
|
574
|
+
handler: async (req) => {
|
|
575
|
+
const result = streamText({ model, messages: req.body.messages });
|
|
576
|
+
return result.toUIMessageStream(); // arc pipes it for you
|
|
577
|
+
},
|
|
578
|
+
}],
|
|
579
|
+
});
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
For manual control: `import { pipeUIMessageStreamToReply, UI_MESSAGE_STREAM_HEADERS } from '@classytic/arc/utils'`. Client disconnect cancels the source stream (no zombie LLM spend).
|
|
583
|
+
|
|
542
584
|
## Tenant cleanup (GDPR / SOX / HIPAA)
|
|
543
585
|
|
|
544
586
|
Per-resource strategy + one auth-lifecycle wire-up:
|
|
@@ -167,6 +167,8 @@ import {
|
|
|
167
167
|
import {
|
|
168
168
|
createStateMachine, CircuitBreaker, withCompensation, defineGuard,
|
|
169
169
|
retry, queryParser,
|
|
170
|
+
// AI SDK / OpenAI streams → Fastify reply (no JsonToSseTransformStream boilerplate)
|
|
171
|
+
pipeUIMessageStreamToReply, UI_MESSAGE_STREAM_HEADERS, isReadableStream,
|
|
170
172
|
} from '@classytic/arc/utils';
|
|
171
173
|
import { defineMigration, MigrationRunner } from '@classytic/arc/migrations';
|
|
172
174
|
import { Type, ArcListResponse } from '@classytic/arc/schemas';
|
|
@@ -168,7 +168,27 @@ Arc → auth.api.getMcpSession({ headers }) → { userId, organizationId }
|
|
|
168
168
|
Arc → BaseController scopes by org automatically
|
|
169
169
|
```
|
|
170
170
|
|
|
171
|
-
### 3.
|
|
171
|
+
### 3. Better Auth API Key (machine clients, no OAuth dance)
|
|
172
|
+
|
|
173
|
+
Hosts using Better Auth's `apiKey()` plugin should NOT hand-wire `verifyApiKey` — the helper handles the `referenceId` vs `userId` fallback, `metadata.organizationId` extraction, disabled / expired guards, and fail-closed exception handling.
|
|
174
|
+
|
|
175
|
+
```typescript
|
|
176
|
+
import { createMcpAuthFromBetterAuthApiKey } from '@classytic/arc/mcp';
|
|
177
|
+
import { getAuth } from './auth.config.js';
|
|
178
|
+
|
|
179
|
+
await app.register(mcpPlugin, {
|
|
180
|
+
resources,
|
|
181
|
+
auth: createMcpAuthFromBetterAuthApiKey(getAuth(), {
|
|
182
|
+
orgFromMetadata: true, // reads key.metadata.organizationId (default)
|
|
183
|
+
// metadataOrgField: 'tenantId', // if you stored it under a different key
|
|
184
|
+
// permissionScope: 'mcp', // permissions[scope] → result.scopes (default 'mcp')
|
|
185
|
+
}),
|
|
186
|
+
});
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Header precedence: `Authorization: Bearer <key>` first, then `x-api-key`. Keys with neither `userId` nor `referenceId` are treated as service principals (`clientId = key.id`) — `RequestScope.kind` becomes `"service"`.
|
|
190
|
+
|
|
191
|
+
### 4. Custom Auth Function (API key, gateway, static org)
|
|
172
192
|
|
|
173
193
|
Pass an `McpAuthResolver` — a function that receives headers and returns identity:
|
|
174
194
|
|
|
@@ -242,6 +262,15 @@ auth resolver returns { userId: 'alice', organizationId: 'org-a' }
|
|
|
242
262
|
|
|
243
263
|
Works with any `tenantField` (`organizationId`, `workspaceId`, `teamId`). Resources with `tenantField: false` are visible to all authenticated users.
|
|
244
264
|
|
|
265
|
+
## Tool-Name Collisions
|
|
266
|
+
|
|
267
|
+
`createMcpServer()` resolves duplicate tool names BEFORE the MCP SDK sees them — the SDK's native `Tool already registered` error gave zero source attribution.
|
|
268
|
+
|
|
269
|
+
- **Preset vs user** → auto-namespace. `softDelete`'s `restore_post` becomes `softdelete_restore_post` when the user defines `actions.restore`; the user's tool keeps the canonical name.
|
|
270
|
+
- **User vs user / preset vs preset / 3-way+** → typed throw: `ArcError('arc.mcp.tool_name_collision')` naming both `source` strings (`crud:post:list`, `action:post:approve`, `route:post:GET /export`, `preset:softDelete:post:restore`).
|
|
271
|
+
|
|
272
|
+
`resolveToolCollisions(tools)` is exported from `@classytic/arc/mcp` for test harnesses that want to exercise collision behaviour without booting a server.
|
|
273
|
+
|
|
245
274
|
## Level 2 — Custom Tools
|
|
246
275
|
|
|
247
276
|
```typescript
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|