@classytic/arc 2.15.0 → 2.15.4
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 +44 -0
- package/dist/{BaseController-Cn8KykUn.mjs → BaseController-dx3m2J8V.mjs} +1 -0
- package/dist/{buildHandler-jSZ6Fdvi.mjs → buildHandler-CcFOpJLh.mjs} +2 -19
- package/dist/cli/commands/init.mjs +18 -9
- package/dist/core/index.mjs +2 -2
- package/dist/{core--_Dnl7n-.mjs → core-CvmOqEms.mjs} +5 -3
- package/dist/{createAggregationRouter-DhR-Ofiz.mjs → createAggregationRouter-B0bPDf5b.mjs} +7 -5
- package/dist/{createApp-BarYhXCZ.mjs → createApp-PFegs47-.mjs} +64 -4
- package/dist/factory/index.d.mts +2 -2
- package/dist/factory/index.mjs +1 -1
- package/dist/index.mjs +3 -3
- package/dist/integrations/mcp/index.mjs +1 -1
- package/dist/integrations/mcp/testing.mjs +1 -1
- package/dist/integrations/streamline.d.mts +71 -2
- package/dist/integrations/streamline.mjs +55 -7
- package/dist/plugins/index.d.mts +1 -34
- package/dist/plugins/tracing-entry.mjs +1 -1
- package/dist/presets/multiTenant.mjs +2 -1
- package/dist/{resourceToTools-D4pWzVGA.mjs → resourceToTools-tFYUNmM0.mjs} +2 -2
- package/dist/testing/index.d.mts +1 -1
- package/dist/testing/index.mjs +1 -1
- package/dist/{types-3YTpuLZ1.d.mts → types-DrBaUwyV.d.mts} +39 -3
- package/dist/{versioning-DTTvc80y.d.mts → versioning-hmkPcDlX.d.mts} +34 -1
- package/package.json +9 -9
- package/skills/arc/SKILL.md +103 -10
- package/skills/arc-code-review/references/anti-patterns.md +44 -0
- package/skills/arc-code-review/references/arc-cheatsheet.md +27 -0
package/README.md
CHANGED
|
@@ -155,6 +155,50 @@ Custom checks return `{ granted, reason?, filters?, scope? }` — `filters` prop
|
|
|
155
155
|
|
|
156
156
|
---
|
|
157
157
|
|
|
158
|
+
## Aggregations
|
|
159
|
+
|
|
160
|
+
Add `aggregations: { … }` to a resource and arc registers `GET /:prefix/aggregations/:name` per entry. Each runs a portable `$match → $group → $project → $sort → $limit` pipeline against the kit's `repo.aggregate(req, options)` — same shape across mongokit / sqlitekit / prismakit, so dashboards work unchanged across backends.
|
|
161
|
+
|
|
162
|
+
```typescript
|
|
163
|
+
import { defineResource, defineAggregation } from '@classytic/arc';
|
|
164
|
+
|
|
165
|
+
defineResource({
|
|
166
|
+
name: 'transaction',
|
|
167
|
+
adapter,
|
|
168
|
+
presets: [multiTenantPreset({ tenantField: 'organizationId' })],
|
|
169
|
+
permissions: { list: canViewRevenue() },
|
|
170
|
+
|
|
171
|
+
aggregations: {
|
|
172
|
+
byPaymentMethod: defineAggregation({
|
|
173
|
+
groupBy: 'method',
|
|
174
|
+
measures: { total: 'sum:amount', count: 'count' },
|
|
175
|
+
sort: { total: -1 },
|
|
176
|
+
cache: { staleTime: 60, swr: true, tags: ['revenue'] },
|
|
177
|
+
permissions: canViewRevenue(),
|
|
178
|
+
}),
|
|
179
|
+
byDay: defineAggregation({
|
|
180
|
+
dateBuckets: { day: { field: 'createdAt', interval: 'day' } },
|
|
181
|
+
groupBy: 'flow',
|
|
182
|
+
measures: { total: 'sum:amount', count: 'count' },
|
|
183
|
+
requireDateRange: { field: 'createdAt', maxRangeDays: 365 },
|
|
184
|
+
cache: { staleTime: 60, swr: true, tags: ['revenue'] },
|
|
185
|
+
permissions: canViewRevenue(),
|
|
186
|
+
}),
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Caller filters via query string compose with the declaration:
|
|
192
|
+
|
|
193
|
+
```
|
|
194
|
+
GET /api/transactions/aggregations/byPaymentMethod?status=verified
|
|
195
|
+
GET /api/transactions/aggregations/byDay?createdAt[gte]=2026-01-01&createdAt[lt]=2026-02-01
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Tenant scope flows through `repo.aggregate(req, options)` — the kit's multi-tenant plugin handles type-coercion (string → ObjectId for mongokit `fieldType: 'objectId'`, UUID/text for sqlitekit, etc.). Arc itself stays out of the filter slot because it's DB-agnostic. Safety guards on the declaration: `requireFilters`, `requireDateRange { maxRangeDays }`, `maxGroups`. SWR cache + tag invalidation tie aggregations to CRUD writes. Every aggregation auto-exports as an MCP tool with the same permissions and filter validation.
|
|
199
|
+
|
|
200
|
+
---
|
|
201
|
+
|
|
158
202
|
## Authentication
|
|
159
203
|
|
|
160
204
|
Discriminated union on `type`:
|
|
@@ -701,6 +701,7 @@ var BaseCrudController = class {
|
|
|
701
701
|
if (presetFields && typeof presetFields === "object") {
|
|
702
702
|
for (const [key, value] of Object.entries(presetFields)) if (value != null && out[key] == null) out[key] = value;
|
|
703
703
|
}
|
|
704
|
+
if (this.tenantField && out[this.tenantField] == null && scope && isElevated(scope)) out.bypassTenant = true;
|
|
704
705
|
const userId = getUserId(req.user);
|
|
705
706
|
if (userId) out.userId = userId;
|
|
706
707
|
if (req.user) out.user = req.user;
|
|
@@ -78,10 +78,8 @@ function adapterSupportsAggregate(repo) {
|
|
|
78
78
|
}
|
|
79
79
|
/** Compile to canonical `AggRequest` for `repo.aggregate()` at request time. */
|
|
80
80
|
function compileAggRequest(normalized, callerFilter, tenantOptions) {
|
|
81
|
-
const baseFilter = normalized.compiled.filter ?? {};
|
|
82
81
|
const filter = {
|
|
83
|
-
...
|
|
84
|
-
...baseFilter,
|
|
82
|
+
...normalized.compiled.filter ?? {},
|
|
85
83
|
...callerFilter
|
|
86
84
|
};
|
|
87
85
|
const executionHints = buildExecutionHints(normalized.base);
|
|
@@ -374,21 +372,6 @@ function assertFieldAllowed(context, ref, input) {
|
|
|
374
372
|
}
|
|
375
373
|
if (blockedFields.has(ref)) throw new ArcAggregationConfigError(`Resource "${resourceName}" aggregation "${aggregationName}" references field "${ref}" in ${context}, but the field is blocked from aggregation (\`hidden: true\` or \`aggregable: false\` in schemaOptions.fieldRules). Aggregating hidden fields would leak cardinality information.`);
|
|
376
374
|
}
|
|
377
|
-
function extractTenantFilter(tenantOptions) {
|
|
378
|
-
const out = {};
|
|
379
|
-
const optionOnlyKeys = new Set([
|
|
380
|
-
"userId",
|
|
381
|
-
"user",
|
|
382
|
-
"session",
|
|
383
|
-
"requestId"
|
|
384
|
-
]);
|
|
385
|
-
for (const [key, value] of Object.entries(tenantOptions)) {
|
|
386
|
-
if (optionOnlyKeys.has(key)) continue;
|
|
387
|
-
if (value === void 0 || value === null) continue;
|
|
388
|
-
out[key] = value;
|
|
389
|
-
}
|
|
390
|
-
return out;
|
|
391
|
-
}
|
|
392
375
|
//#endregion
|
|
393
376
|
//#region src/core/aggregation/buildHandler.ts
|
|
394
377
|
/**
|
|
@@ -444,7 +427,7 @@ async function executeAggregation(normalized, deps, ctx) {
|
|
|
444
427
|
};
|
|
445
428
|
let result;
|
|
446
429
|
try {
|
|
447
|
-
result = await repo.aggregate(aggReq);
|
|
430
|
+
result = await repo.aggregate(aggReq, tenantOptions);
|
|
448
431
|
} catch (err) {
|
|
449
432
|
return mapAggregateError(err, aggregationName);
|
|
450
433
|
}
|
|
@@ -376,20 +376,29 @@ function packageJsonTemplate(config) {
|
|
|
376
376
|
test: "vitest run",
|
|
377
377
|
"test:watch": "vitest"
|
|
378
378
|
};
|
|
379
|
+
const imports = config.typescript ? {
|
|
380
|
+
"#config/*": "./dist/config/*",
|
|
381
|
+
"#shared/*": "./dist/shared/*",
|
|
382
|
+
"#resources/*": "./dist/resources/*",
|
|
383
|
+
"#plugins/*": "./dist/plugins/*",
|
|
384
|
+
"#services/*": "./dist/services/*",
|
|
385
|
+
"#lib/*": "./dist/lib/*",
|
|
386
|
+
"#utils/*": "./dist/utils/*"
|
|
387
|
+
} : {
|
|
388
|
+
"#config/*": "./src/config/*",
|
|
389
|
+
"#shared/*": "./src/shared/*",
|
|
390
|
+
"#resources/*": "./src/resources/*",
|
|
391
|
+
"#plugins/*": "./src/plugins/*",
|
|
392
|
+
"#services/*": "./src/services/*",
|
|
393
|
+
"#lib/*": "./src/lib/*",
|
|
394
|
+
"#utils/*": "./src/utils/*"
|
|
395
|
+
};
|
|
379
396
|
return JSON.stringify({
|
|
380
397
|
name: config.name,
|
|
381
398
|
version: "1.0.0",
|
|
382
399
|
type: "module",
|
|
383
400
|
main: config.typescript ? "dist/index.js" : "src/index.js",
|
|
384
|
-
imports
|
|
385
|
-
"#config/*": "./src/config/*",
|
|
386
|
-
"#shared/*": "./src/shared/*",
|
|
387
|
-
"#resources/*": "./src/resources/*",
|
|
388
|
-
"#plugins/*": "./src/plugins/*",
|
|
389
|
-
"#services/*": "./src/services/*",
|
|
390
|
-
"#lib/*": "./src/lib/*",
|
|
391
|
-
"#utils/*": "./src/utils/*"
|
|
392
|
-
},
|
|
401
|
+
imports,
|
|
393
402
|
scripts,
|
|
394
403
|
dependencies,
|
|
395
404
|
devDependencies,
|
package/dist/core/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { a as DEFAULT_SORT, c as HOOK_OPERATIONS, d as MAX_REGEX_LENGTH, f as MAX_SEARCH_LENGTH, h as SYSTEM_FIELDS, i as DEFAULT_MAX_LIMIT, l as HOOK_PHASES, m as RESERVED_QUERY_PARAMS, n as DEFAULT_ID_FIELD, o as DEFAULT_TENANT_FIELD, p as MUTATION_OPERATIONS, r as DEFAULT_LIMIT, s as DEFAULT_UPDATE_METHOD, t as CRUD_OPERATIONS, u as MAX_FILTER_DEPTH } from "../constants-Cxde4rpC.mjs";
|
|
2
|
-
import { a as BulkMixin, c as collectReadBlockedFields, d as AccessControl, i as SlugMixin, l as isFieldReadable, n as TreeMixin, o as BaseCrudController, r as SoftDeleteMixin, s as QueryResolver, t as BaseController, u as BodySanitizer } from "../BaseController-
|
|
2
|
+
import { a as BulkMixin, c as collectReadBlockedFields, d as AccessControl, i as SlugMixin, l as isFieldReadable, n as TreeMixin, o as BaseCrudController, r as SoftDeleteMixin, s as QueryResolver, t as BaseController, u as BodySanitizer } from "../BaseController-dx3m2J8V.mjs";
|
|
3
3
|
import { _ as getControllerContext, g as createRequestContext, h as createFastifyHandler, m as createCrudHandlers, v as getControllerScope, y as sendControllerResponse } from "../routerShared-DrOa-26E.mjs";
|
|
4
|
-
import { a as defineResource, c as createPermissionMiddleware, i as defineResourceVariants, l as defineAggregation, n as getEntityIdField, o as ResourceDefinition, r as getEntityQuery, s as createCrudRouter, t as getEntityId } from "../core
|
|
4
|
+
import { a as defineResource, c as createPermissionMiddleware, i as defineResourceVariants, l as defineAggregation, n as getEntityIdField, o as ResourceDefinition, r as getEntityQuery, s as createCrudRouter, t as getEntityId } from "../core-CvmOqEms.mjs";
|
|
5
5
|
export { AccessControl, BaseController, BaseCrudController, BodySanitizer, BulkMixin, CRUD_OPERATIONS, DEFAULT_ID_FIELD, DEFAULT_LIMIT, DEFAULT_MAX_LIMIT, DEFAULT_SORT, DEFAULT_TENANT_FIELD, DEFAULT_UPDATE_METHOD, HOOK_OPERATIONS, HOOK_PHASES, MAX_FILTER_DEPTH, MAX_REGEX_LENGTH, MAX_SEARCH_LENGTH, MUTATION_OPERATIONS, QueryResolver, RESERVED_QUERY_PARAMS, ResourceDefinition, SYSTEM_FIELDS, SlugMixin, SoftDeleteMixin, TreeMixin, collectReadBlockedFields, createCrudHandlers, createCrudRouter, createFastifyHandler, createPermissionMiddleware, createRequestContext, defineAggregation, defineResource, defineResourceVariants, getControllerContext, getControllerScope, getEntityId, getEntityIdField, getEntityQuery, isFieldReadable, sendControllerResponse };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { s as DEFAULT_UPDATE_METHOD, t as CRUD_OPERATIONS } from "./constants-Cxde4rpC.mjs";
|
|
2
2
|
import { arcLog } from "./logger/index.mjs";
|
|
3
3
|
import { A as assertValidConfig, l as getDefaultCrudSchemas } from "./utils-_h9B3c57.mjs";
|
|
4
|
-
import { t as BaseController } from "./BaseController-
|
|
4
|
+
import { t as BaseController } from "./BaseController-dx3m2J8V.mjs";
|
|
5
5
|
import { t as applyPresets } from "./presets-BbkjdPeH.mjs";
|
|
6
6
|
import { n as convertRouteSchema, t as convertOpenApiSchemas } from "./schemaConverter-De34B1ZG.mjs";
|
|
7
7
|
import { t as hasEvents } from "./typeGuards-BzkXkvVv.mjs";
|
|
@@ -840,6 +840,7 @@ function normalizeListQuerySchema(listQuerySchema) {
|
|
|
840
840
|
for (const key of Object.keys(normalizedProps)) normalizedProps[key] = NORMALIZED_PROPS[key] ?? {};
|
|
841
841
|
}
|
|
842
842
|
return {
|
|
843
|
+
type: "object",
|
|
843
844
|
...listQuerySchema,
|
|
844
845
|
...normalizedProps ? { properties: normalizedProps } : {},
|
|
845
846
|
additionalProperties: listQuerySchema.additionalProperties ?? true
|
|
@@ -997,7 +998,7 @@ function buildResourcePlugin(resource) {
|
|
|
997
998
|
});
|
|
998
999
|
}
|
|
999
1000
|
if (resource.aggregations && Object.keys(resource.aggregations).length > 0) {
|
|
1000
|
-
const { createAggregationRouter } = await import("./createAggregationRouter-
|
|
1001
|
+
const { createAggregationRouter } = await import("./createAggregationRouter-B0bPDf5b.mjs");
|
|
1001
1002
|
const repoForAgg = resource.controller?.repository;
|
|
1002
1003
|
const buildOptions = (req) => {
|
|
1003
1004
|
const ctrl = resource.controller;
|
|
@@ -1014,7 +1015,8 @@ function buildResourcePlugin(resource) {
|
|
|
1014
1015
|
permissions: resource.permissions,
|
|
1015
1016
|
routeGuards: resource.routeGuards,
|
|
1016
1017
|
repository: repoForAgg,
|
|
1017
|
-
buildOptions
|
|
1018
|
+
buildOptions,
|
|
1019
|
+
middlewares: resource.middlewares?.aggregations
|
|
1018
1020
|
});
|
|
1019
1021
|
}
|
|
1020
1022
|
if (resource.events && Object.keys(resource.events).length > 0) typedInstance.log?.debug?.(`Resource '${resource.name}' defined ${Object.keys(resource.events).length} events`);
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { f as createError, l as UnauthorizedError, r as ForbiddenError } from "./errors-j4aJm1Wg.mjs";
|
|
2
2
|
import { c as buildPreHandlerChain, f as resolveRouterPluginMw, i as buildAuthMiddleware, l as buildRateLimitConfig, p as selectPluginMw, r as buildArcDecorator } from "./routerShared-DrOa-26E.mjs";
|
|
3
|
-
import { r as validateAggregations, t as buildAggregationHandler } from "./buildHandler-
|
|
3
|
+
import { r as validateAggregations, t as buildAggregationHandler } from "./buildHandler-CcFOpJLh.mjs";
|
|
4
4
|
//#region src/core/aggregation/createAggregationRouter.ts
|
|
5
5
|
/**
|
|
6
6
|
* Register one Fastify route per aggregation. No-op when the map is
|
|
7
7
|
* empty — same convention `createActionRouter` follows.
|
|
8
8
|
*/
|
|
9
9
|
function createAggregationRouter(fastify, config) {
|
|
10
|
-
const { tag, resourceName, aggregations, fields: fieldPermissions, schemaOptions, permissions: resourcePermissions, routeGuards = [], repository, buildOptions } = config;
|
|
10
|
+
const { tag, resourceName, aggregations, fields: fieldPermissions, schemaOptions, permissions: resourcePermissions, routeGuards = [], repository, buildOptions, middlewares = [] } = config;
|
|
11
11
|
if (!aggregations || Object.keys(aggregations).length === 0) return;
|
|
12
12
|
const normalized = validateAggregations(resourceName, aggregations, schemaOptions);
|
|
13
13
|
const arcDecorator = buildArcDecorator({
|
|
@@ -23,7 +23,8 @@ function createAggregationRouter(fastify, config) {
|
|
|
23
23
|
arcDecorator,
|
|
24
24
|
routeGuards,
|
|
25
25
|
repository,
|
|
26
|
-
buildOptions
|
|
26
|
+
buildOptions,
|
|
27
|
+
middlewares
|
|
27
28
|
});
|
|
28
29
|
fastify.log?.debug?.({
|
|
29
30
|
aggregations: normalized.map((a) => a.name),
|
|
@@ -31,7 +32,7 @@ function createAggregationRouter(fastify, config) {
|
|
|
31
32
|
}, `[createAggregationRouter] registered ${normalized.length} aggregation route(s)`);
|
|
32
33
|
}
|
|
33
34
|
function registerOne(fastify, normalized, ctx) {
|
|
34
|
-
const { tag, arcDecorator, routeGuards, repository, buildOptions } = ctx;
|
|
35
|
+
const { tag, arcDecorator, routeGuards, repository, buildOptions, middlewares } = ctx;
|
|
35
36
|
const { name } = normalized;
|
|
36
37
|
const config = normalized.base;
|
|
37
38
|
const authMw = buildAuthMiddleware(fastify, config.permissions);
|
|
@@ -50,7 +51,8 @@ function registerOne(fastify, normalized, ctx) {
|
|
|
50
51
|
authMw,
|
|
51
52
|
permissionMw,
|
|
52
53
|
pluginMw: selectPluginMw("GET", resolveRouterPluginMw(fastify, false)),
|
|
53
|
-
routeGuards
|
|
54
|
+
routeGuards,
|
|
55
|
+
customMws: middlewares
|
|
54
56
|
});
|
|
55
57
|
const rateLimitConfig = buildRateLimitConfig(config.rateLimit ? {
|
|
56
58
|
max: config.rateLimit.max,
|
|
@@ -230,8 +230,9 @@ async function registerArcPlugins(fastify, config, trackPlugin, modules) {
|
|
|
230
230
|
trackPlugin("arc-request-id");
|
|
231
231
|
}
|
|
232
232
|
if (config.arcPlugins?.health !== false) {
|
|
233
|
-
|
|
234
|
-
|
|
233
|
+
const healthOpts = typeof config.arcPlugins?.health === "object" && config.arcPlugins.health !== null ? config.arcPlugins.health : {};
|
|
234
|
+
await fastify.register(healthPlugin, healthOpts);
|
|
235
|
+
trackPlugin("arc-health", healthOpts);
|
|
235
236
|
}
|
|
236
237
|
if (config.arcPlugins?.gracefulShutdown !== false) {
|
|
237
238
|
await fastify.register(gracefulShutdownPlugin);
|
|
@@ -707,9 +708,65 @@ async function registerUtilityPlugins(fastify, config) {
|
|
|
707
708
|
*/
|
|
708
709
|
var createApp_exports = /* @__PURE__ */ __exportAll({
|
|
709
710
|
ArcFactory: () => ArcFactory,
|
|
710
|
-
|
|
711
|
+
DEFAULT_LOGGER_REDACT_PATHS: () => DEFAULT_LOGGER_REDACT_PATHS,
|
|
712
|
+
createApp: () => createApp,
|
|
713
|
+
resolveLoggerConfig: () => resolveLoggerConfig
|
|
711
714
|
});
|
|
712
715
|
const MEMORY_STORE_NAMES = new Set(["memory", "memory-cache"]);
|
|
716
|
+
/**
|
|
717
|
+
* Default redact paths layered into Fastify's pino logger when the host
|
|
718
|
+
* doesn't supply a `logger.redact` of their own. Covers the common token /
|
|
719
|
+
* cookie / password leak surfaces — `Authorization` and `Cookie` headers
|
|
720
|
+
* (and their case-variants because Node lower-cases incoming headers but
|
|
721
|
+
* outgoing logs may carry either), API-key headers, and any nested
|
|
722
|
+
* `password` / `token` / `secret` / `apiKey` fields anywhere in the log
|
|
723
|
+
* tree.
|
|
724
|
+
*
|
|
725
|
+
* Hosts that need NO redaction (test fixtures, security audits) opt out
|
|
726
|
+
* with `logger: { redact: [] }`. Hosts that need MORE redaction supply
|
|
727
|
+
* their own `redact` and arc steps aside — this default never overrides
|
|
728
|
+
* an explicit setting. (2.15.1)
|
|
729
|
+
*/
|
|
730
|
+
const DEFAULT_LOGGER_REDACT_PATHS = [
|
|
731
|
+
"req.headers.authorization",
|
|
732
|
+
"req.headers[\"x-api-key\"]",
|
|
733
|
+
"req.headers[\"x-internal-api-key\"]",
|
|
734
|
+
"req.headers.cookie",
|
|
735
|
+
"req.headers[\"set-cookie\"]",
|
|
736
|
+
"res.headers[\"set-cookie\"]",
|
|
737
|
+
"*.password",
|
|
738
|
+
"*.passwordHash",
|
|
739
|
+
"*.token",
|
|
740
|
+
"*.accessToken",
|
|
741
|
+
"*.refreshToken",
|
|
742
|
+
"*.secret",
|
|
743
|
+
"*.apiKey"
|
|
744
|
+
];
|
|
745
|
+
/**
|
|
746
|
+
* Resolve the host's `logger` option, layering safe redact defaults
|
|
747
|
+
* when the host hasn't supplied any. Returns the value Fastify expects
|
|
748
|
+
* for its `logger` server option.
|
|
749
|
+
*
|
|
750
|
+
* Three branches:
|
|
751
|
+
* - `logger === false` → pass through (no logger).
|
|
752
|
+
* - `logger === undefined` → enable with default redact paths.
|
|
753
|
+
* - `logger === true` → enable with default redact paths.
|
|
754
|
+
* - `logger` is an object → respect explicit `redact` (host wins);
|
|
755
|
+
* otherwise inject defaults.
|
|
756
|
+
*/
|
|
757
|
+
function resolveLoggerConfig(logger) {
|
|
758
|
+
if (logger === false) return false;
|
|
759
|
+
if (logger === true || logger === void 0) return { redact: [...DEFAULT_LOGGER_REDACT_PATHS] };
|
|
760
|
+
if (typeof logger === "object" && logger !== null) {
|
|
761
|
+
const objLogger = logger;
|
|
762
|
+
if (objLogger.redact !== void 0) return logger;
|
|
763
|
+
return {
|
|
764
|
+
...objLogger,
|
|
765
|
+
redact: [...DEFAULT_LOGGER_REDACT_PATHS]
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
return logger;
|
|
769
|
+
}
|
|
713
770
|
function validateAuthOptions(options) {
|
|
714
771
|
const authConfig = options.auth;
|
|
715
772
|
if (authConfig === false || !authConfig) return;
|
|
@@ -766,14 +823,17 @@ async function createApp(options) {
|
|
|
766
823
|
...options
|
|
767
824
|
};
|
|
768
825
|
const fastify = Fastify({
|
|
769
|
-
logger: config.logger
|
|
826
|
+
logger: resolveLoggerConfig(config.logger),
|
|
770
827
|
trustProxy: config.trustProxy ?? false,
|
|
771
828
|
pluginTimeout: config.pluginTimeout ?? 1e4,
|
|
829
|
+
...config.bodyLimit !== void 0 ? { bodyLimit: config.bodyLimit } : {},
|
|
830
|
+
allowErrorHandlerOverride: true,
|
|
772
831
|
routerOptions: { querystringParser: (str) => qs.parse(str) },
|
|
773
832
|
ajv: { customOptions: {
|
|
774
833
|
coerceTypes: true,
|
|
775
834
|
useDefaults: true,
|
|
776
835
|
removeAdditional: false,
|
|
836
|
+
strictTypes: false,
|
|
777
837
|
keywords: ["example", ...config.ajv?.keywords ?? []]
|
|
778
838
|
} }
|
|
779
839
|
});
|
package/dist/factory/index.d.mts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { a as CustomPluginAuthOption, c as RawBodyOptions, d as ResourceLike, f as ResourceModule, i as CustomAuthenticatorOption, l as UnderPressureOptions, n as BetterAuthOption, o as JwtAuthOption, p as loadResources, r as CreateAppOptions, s as MultipartOptions, t as AuthOption, u as LoadResourcesOptions } from "../types-
|
|
2
|
-
import { FastifyInstance } from "fastify";
|
|
1
|
+
import { a as CustomPluginAuthOption, c as RawBodyOptions, d as ResourceLike, f as ResourceModule, i as CustomAuthenticatorOption, l as UnderPressureOptions, n as BetterAuthOption, o as JwtAuthOption, p as loadResources, r as CreateAppOptions, s as MultipartOptions, t as AuthOption, u as LoadResourcesOptions } from "../types-DrBaUwyV.mjs";
|
|
2
|
+
import { FastifyInstance, FastifyServerOptions } from "fastify";
|
|
3
3
|
|
|
4
4
|
//#region src/factory/createApp.d.ts
|
|
5
5
|
/**
|
package/dist/factory/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as edgePreset, c as testingPreset, i as developmentPreset, n as createApp, o as getPreset, s as productionPreset, t as ArcFactory } from "../createApp-
|
|
1
|
+
import { a as edgePreset, c as testingPreset, i as developmentPreset, n as createApp, o as getPreset, s as productionPreset, t as ArcFactory } from "../createApp-PFegs47-.mjs";
|
|
2
2
|
import { t as loadResources } from "../loadResources-DBMQg_Aj.mjs";
|
|
3
3
|
//#region src/factory/edge.ts
|
|
4
4
|
/**
|
package/dist/index.mjs
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { a as DEFAULT_SORT, c as HOOK_OPERATIONS, d as MAX_REGEX_LENGTH, f as MAX_SEARCH_LENGTH, h as SYSTEM_FIELDS, i as DEFAULT_MAX_LIMIT, l as HOOK_PHASES, m as RESERVED_QUERY_PARAMS, n as DEFAULT_ID_FIELD, o as DEFAULT_TENANT_FIELD, p as MUTATION_OPERATIONS, r as DEFAULT_LIMIT, s as DEFAULT_UPDATE_METHOD, t as CRUD_OPERATIONS, u as MAX_FILTER_DEPTH } from "./constants-Cxde4rpC.mjs";
|
|
2
2
|
import { d as createDomainError, i as NotFoundError, l as UnauthorizedError, r as ForbiddenError, t as ArcError, u as ValidationError } from "./errors-j4aJm1Wg.mjs";
|
|
3
3
|
import { t as getUserId } from "./utils-_h9B3c57.mjs";
|
|
4
|
-
import { a as BulkMixin, i as SlugMixin, n as TreeMixin, o as BaseCrudController, r as SoftDeleteMixin, t as BaseController } from "./BaseController-
|
|
4
|
+
import { a as BulkMixin, i as SlugMixin, n as TreeMixin, o as BaseCrudController, r as SoftDeleteMixin, t as BaseController } from "./BaseController-dx3m2J8V.mjs";
|
|
5
5
|
import { C as allowPublic, D as requireAuth, O as requireOwnership, S as allOf, T as denyAll, _ as requireOrgMembership, a as presets_exports, b as requireServiceScope, c as readOnly, d as applyFieldWritePermissions, f as fields, g as requireOrgInScope, h as createOrgPermissions, i as ownerWithAdminBypass, j as when, k as requireRoles, m as createDynamicPermissionMatrix, n as authenticated, o as publicRead, r as fullPublic, s as publicReadAdminWrite, t as adminOnly, u as applyFieldReadPermissions, v as requireOrgRole, w as anyOf, x as requireTeamMembership, y as requireScopeContext } from "./permissions-ohQyv50e.mjs";
|
|
6
6
|
import { v as getControllerScope } from "./routerShared-DrOa-26E.mjs";
|
|
7
|
-
import { a as defineResource, i as defineResourceVariants, l as defineAggregation, o as ResourceDefinition } from "./core
|
|
7
|
+
import { a as defineResource, i as defineResourceVariants, l as defineAggregation, o as ResourceDefinition } from "./core-CvmOqEms.mjs";
|
|
8
8
|
//#region src/index.ts
|
|
9
|
-
const version = "2.15.
|
|
9
|
+
const version = "2.15.4";
|
|
10
10
|
//#endregion
|
|
11
11
|
export { ArcError, BaseController, BaseCrudController, BulkMixin, CRUD_OPERATIONS, DEFAULT_ID_FIELD, DEFAULT_LIMIT, DEFAULT_MAX_LIMIT, DEFAULT_SORT, DEFAULT_TENANT_FIELD, DEFAULT_UPDATE_METHOD, ForbiddenError, HOOK_OPERATIONS, HOOK_PHASES, MAX_FILTER_DEPTH, MAX_REGEX_LENGTH, MAX_SEARCH_LENGTH, MUTATION_OPERATIONS, NotFoundError, RESERVED_QUERY_PARAMS, ResourceDefinition, SYSTEM_FIELDS, SlugMixin, SoftDeleteMixin, TreeMixin, UnauthorizedError, ValidationError, adminOnly, allOf, allowPublic, anyOf, applyFieldReadPermissions, applyFieldWritePermissions, authenticated, createDomainError, createDynamicPermissionMatrix, createOrgPermissions, defineAggregation, defineResource, defineResourceVariants, denyAll, fields, fullPublic, getControllerScope, getUserId, ownerWithAdminBypass, presets_exports as permissions, publicRead, publicReadAdminWrite, readOnly, requireAuth, requireOrgInScope, requireOrgMembership, requireOrgRole, requireOwnership, requireRoles, requireScopeContext, requireServiceScope, requireTeamMembership, version, when };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { n as fieldRulesToZod, r as createMcpServer, t as resourceToTools } from "../../resourceToTools-
|
|
1
|
+
import { n as fieldRulesToZod, r as createMcpServer, t as resourceToTools } from "../../resourceToTools-tFYUNmM0.mjs";
|
|
2
2
|
import { createHash, randomUUID } from "node:crypto";
|
|
3
3
|
import fp from "fastify-plugin";
|
|
4
4
|
//#region src/integrations/mcp/defineTool.ts
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { r as createMcpServer, t as resourceToTools } from "../../resourceToTools-
|
|
1
|
+
import { r as createMcpServer, t as resourceToTools } from "../../resourceToTools-tFYUNmM0.mjs";
|
|
2
2
|
//#region src/integrations/mcp/testing.ts
|
|
3
3
|
/**
|
|
4
4
|
* @classytic/arc/mcp/testing — MCP Test Utilities
|
|
@@ -50,7 +50,40 @@ interface WorkflowLike {
|
|
|
50
50
|
}; /** Repository — used by the list-runs endpoint to query workflow_runs. */
|
|
51
51
|
repository?: {
|
|
52
52
|
getAll(params: Record<string, unknown>, options?: Record<string, unknown>): Promise<unknown>;
|
|
53
|
+
/**
|
|
54
|
+
* Tenant-scoped lookup by id. Used by the DELETE handler for a
|
|
55
|
+
* defense-in-depth pre-flight: streamline 2.3.3's `wf.get(runId)` /
|
|
56
|
+
* `engine.get` does NOT accept tenant options, so a cross-tenant
|
|
57
|
+
* runId can leak data through the engine path. Going through the
|
|
58
|
+
* repository here means mongokit's tenant-filter plugin scopes the
|
|
59
|
+
* read — cross-tenant requests get a clean 404 and DELETEs only
|
|
60
|
+
* touch rows the caller actually owns.
|
|
61
|
+
*/
|
|
62
|
+
getById?(id: string, options?: Record<string, unknown>): Promise<WorkflowRunLike | null>;
|
|
63
|
+
/**
|
|
64
|
+
* Hard-delete a run by id. Routed through mongokit's inherited
|
|
65
|
+
* `Repository.delete()` so multi-tenant scope + audit/cache plugins
|
|
66
|
+
* fire. Wired into `DELETE /:workflowId/runs/:runId` — operator
|
|
67
|
+
* escape hatch for dead-lettered or stuck rows.
|
|
68
|
+
*/
|
|
69
|
+
delete?(id: string, options?: Record<string, unknown>): Promise<unknown>;
|
|
53
70
|
};
|
|
71
|
+
/**
|
|
72
|
+
* Streamline >= 2.3.2 — explicit deploy-time index sync (TTL on
|
|
73
|
+
* terminal runs + tenant compounds). When the host configured
|
|
74
|
+
* `createContainer({ retention })`, arc's app-level deploy hook
|
|
75
|
+
* should call `await container.syncRetentionIndexes()` after
|
|
76
|
+
* `mongoose.connect`. Optional so older streamline versions
|
|
77
|
+
* (and partial mocks) still satisfy the structural shape.
|
|
78
|
+
*/
|
|
79
|
+
syncRetentionIndexes?: () => Promise<void>;
|
|
80
|
+
/**
|
|
81
|
+
* Streamline >= 2.3.2 — stop background sweepers and release timers.
|
|
82
|
+
* Arc's `onClose` hook below calls this on every workflow's container
|
|
83
|
+
* during graceful shutdown so SIGTERM doesn't leave the stale-run
|
|
84
|
+
* sweeper running. Optional + idempotent.
|
|
85
|
+
*/
|
|
86
|
+
dispose?: () => void;
|
|
54
87
|
};
|
|
55
88
|
}
|
|
56
89
|
interface WorkflowRunLike {
|
|
@@ -67,7 +100,36 @@ interface WorkflowRunLike {
|
|
|
67
100
|
stepLogs?: unknown[];
|
|
68
101
|
createdAt?: Date;
|
|
69
102
|
updatedAt?: Date;
|
|
103
|
+
/**
|
|
104
|
+
* Streamline >= 2.3.3 — pinned definition version (semver) the run
|
|
105
|
+
* started under. Hosts surfacing a "stuck on old version" UI read this
|
|
106
|
+
* to decide whether to nudge a migration. Optional for back-compat
|
|
107
|
+
* with runs created before 2.3.3.
|
|
108
|
+
*/
|
|
109
|
+
definitionVersion?: string;
|
|
110
|
+
/**
|
|
111
|
+
* Streamline >= 2.3.3 — count of stale-recovery / sweeper transitions
|
|
112
|
+
* applied to this run. Sweeper dead-letters once this hits
|
|
113
|
+
* `RetentionOptions.maxStaleRecoveries`; UIs can highlight runs trending
|
|
114
|
+
* toward dead-letter.
|
|
115
|
+
*/
|
|
116
|
+
recoveryAttempts?: number;
|
|
70
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* Streamline >= 2.3.3 dead-letter discriminator. The run.status stays
|
|
120
|
+
* `'failed'`; the discrimination is `error.code`:
|
|
121
|
+
* - `'stale_heartbeat'` — sweeper terminated; transient crash signal.
|
|
122
|
+
* - `'dead_lettered'` — exceeded `maxStaleRecoveries`; permanent.
|
|
123
|
+
* - `'VERSION_MISMATCH'` — engine deployed a step graph the run can't
|
|
124
|
+
* resume against; admin must rewind / migrate / cancel.
|
|
125
|
+
*
|
|
126
|
+
* Hosts switch on `error.code` for dashboards / alerting.
|
|
127
|
+
*/
|
|
128
|
+
declare const STREAMLINE_FAILURE_CODES: {
|
|
129
|
+
readonly STALE_HEARTBEAT: "stale_heartbeat";
|
|
130
|
+
readonly DEAD_LETTERED: "dead_lettered";
|
|
131
|
+
readonly VERSION_MISMATCH: "VERSION_MISMATCH";
|
|
132
|
+
};
|
|
71
133
|
interface StreamlinePluginOptions {
|
|
72
134
|
/** Array of workflows created with createWorkflow() */
|
|
73
135
|
workflows: WorkflowLike[];
|
|
@@ -176,7 +238,14 @@ declare const STREAMLINE_BUS_EVENTS: readonly ["step:started", "step:completed",
|
|
|
176
238
|
* the run is still active after them.
|
|
177
239
|
*/
|
|
178
240
|
declare const STREAMLINE_TERMINAL_EVENTS: readonly ["workflow:completed", "workflow:failed", "workflow:cancelled"];
|
|
179
|
-
/**
|
|
241
|
+
/**
|
|
242
|
+
* Pluggable streamline integration for Arc.
|
|
243
|
+
*
|
|
244
|
+
* Wrapped in `fastify-plugin` so Fastify treats `options.prefix` as a
|
|
245
|
+
* plain plugin option (NOT an encapsulation prefix). Without the wrapper,
|
|
246
|
+
* Fastify would prepend `options.prefix` to every route, then the plugin
|
|
247
|
+
* code would prepend it again — the duplicate-prefix bug.
|
|
248
|
+
*/
|
|
180
249
|
declare const streamlinePlugin: FastifyPluginAsync<StreamlinePluginOptions>;
|
|
181
250
|
//#endregion
|
|
182
|
-
export { STREAMLINE_BUS_EVENTS, STREAMLINE_TERMINAL_EVENTS, StreamlinePluginOptions, WorkflowLike, WorkflowRunLike, WorkflowStartOptions, streamlinePlugin };
|
|
251
|
+
export { STREAMLINE_BUS_EVENTS, STREAMLINE_FAILURE_CODES, STREAMLINE_TERMINAL_EVENTS, StreamlinePluginOptions, WorkflowLike, WorkflowRunLike, WorkflowStartOptions, streamlinePlugin };
|
|
@@ -1,6 +1,22 @@
|
|
|
1
1
|
import { f as createError, i as NotFoundError, r as ForbiddenError } from "../errors-j4aJm1Wg.mjs";
|
|
2
|
+
import fp from "fastify-plugin";
|
|
2
3
|
//#region src/integrations/streamline.ts
|
|
3
4
|
/**
|
|
5
|
+
* Streamline >= 2.3.3 dead-letter discriminator. The run.status stays
|
|
6
|
+
* `'failed'`; the discrimination is `error.code`:
|
|
7
|
+
* - `'stale_heartbeat'` — sweeper terminated; transient crash signal.
|
|
8
|
+
* - `'dead_lettered'` — exceeded `maxStaleRecoveries`; permanent.
|
|
9
|
+
* - `'VERSION_MISMATCH'` — engine deployed a step graph the run can't
|
|
10
|
+
* resume against; admin must rewind / migrate / cancel.
|
|
11
|
+
*
|
|
12
|
+
* Hosts switch on `error.code` for dashboards / alerting.
|
|
13
|
+
*/
|
|
14
|
+
const STREAMLINE_FAILURE_CODES = {
|
|
15
|
+
STALE_HEARTBEAT: "stale_heartbeat",
|
|
16
|
+
DEAD_LETTERED: "dead_lettered",
|
|
17
|
+
VERSION_MISMATCH: "VERSION_MISMATCH"
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
4
20
|
* Full event list published on a streamline workflow's internal `eventBus`
|
|
5
21
|
* (tracks streamline 2.3's `EventPayloadMap` in
|
|
6
22
|
* `@classytic/streamline/src/core/events.ts`).
|
|
@@ -44,6 +60,7 @@ const STREAMLINE_TERMINAL_EVENTS = [
|
|
|
44
60
|
];
|
|
45
61
|
const streamlinePluginImpl = async (fastify, options) => {
|
|
46
62
|
const { workflows, prefix = "/workflows", auth = true, bridgeEvents = true, enableStreaming = false, enableHookEndpoint = false, tenantResolver, bypassTenantResolver, permissions: perms } = options;
|
|
63
|
+
const routeScope = prefix;
|
|
47
64
|
const bridgeBus = options.bridgeBusEvents ?? false;
|
|
48
65
|
const registry = /* @__PURE__ */ new Map();
|
|
49
66
|
for (const wf of workflows) {
|
|
@@ -70,7 +87,7 @@ const streamlinePluginImpl = async (fastify, options) => {
|
|
|
70
87
|
return check(request);
|
|
71
88
|
};
|
|
72
89
|
for (const [id, wf] of registry) {
|
|
73
|
-
const routePrefix = `${
|
|
90
|
+
const routePrefix = `${routeScope}/${id}`;
|
|
74
91
|
fastify.post(`${routePrefix}/start`, { preHandler: authPreHandler }, async (request, reply) => {
|
|
75
92
|
if (!await checkPerm("start", request)) throw new ForbiddenError();
|
|
76
93
|
const { input, meta, idempotencyKey, priority } = request.body ?? {};
|
|
@@ -161,6 +178,24 @@ const streamlinePluginImpl = async (fastify, options) => {
|
|
|
161
178
|
const { runId } = request.params;
|
|
162
179
|
return await wf.engine.execute(runId);
|
|
163
180
|
});
|
|
181
|
+
const deleteRepo = wf.container?.repository;
|
|
182
|
+
const repoDeleteFn = deleteRepo?.delete;
|
|
183
|
+
const repoGetByIdFn = deleteRepo?.getById;
|
|
184
|
+
if (repoDeleteFn && repoGetByIdFn) fastify.delete(`${routePrefix}/runs/:runId`, { preHandler: authPreHandler }, async (request, reply) => {
|
|
185
|
+
if (!await checkPerm("cancel", request)) throw new ForbiddenError();
|
|
186
|
+
const { runId } = request.params;
|
|
187
|
+
const tenantOpts = resolveTenantOpts(request);
|
|
188
|
+
const repoOpts = {
|
|
189
|
+
...tenantOpts.tenantId !== void 0 ? { tenantId: tenantOpts.tenantId } : {},
|
|
190
|
+
...tenantOpts.bypassTenant ? { bypassTenant: true } : {}
|
|
191
|
+
};
|
|
192
|
+
if (!await repoGetByIdFn(runId, repoOpts)) throw new NotFoundError(`Workflow run ${runId} not found`);
|
|
193
|
+
try {
|
|
194
|
+
await wf.cancel(runId);
|
|
195
|
+
} catch {}
|
|
196
|
+
await repoDeleteFn(runId, repoOpts);
|
|
197
|
+
return reply.status(204).send();
|
|
198
|
+
});
|
|
164
199
|
if (wf.engine.waitFor) fastify.get(`${routePrefix}/runs/:runId/wait`, { preHandler: authPreHandler }, async (request, _reply) => {
|
|
165
200
|
if (!await checkPerm("get", request)) throw new ForbiddenError();
|
|
166
201
|
const { runId } = request.params;
|
|
@@ -239,7 +274,7 @@ const streamlinePluginImpl = async (fastify, options) => {
|
|
|
239
274
|
}
|
|
240
275
|
if (enableHookEndpoint) {
|
|
241
276
|
let resumeHookFn;
|
|
242
|
-
fastify.post(`${
|
|
277
|
+
fastify.post(`${routeScope}/hooks/:token`, { preHandler: authPreHandler }, async (request, _reply) => {
|
|
243
278
|
if (!resumeHookFn) resumeHookFn = (await import("@classytic/streamline")).resumeHook;
|
|
244
279
|
const { token } = request.params;
|
|
245
280
|
const result = await resumeHookFn(token, request.body);
|
|
@@ -249,7 +284,7 @@ const streamlinePluginImpl = async (fastify, options) => {
|
|
|
249
284
|
};
|
|
250
285
|
});
|
|
251
286
|
}
|
|
252
|
-
fastify.get(
|
|
287
|
+
fastify.get(routeScope || "/", { preHandler: authPreHandler }, async () => {
|
|
253
288
|
return Array.from(registry.entries()).map(([id, wf]) => ({
|
|
254
289
|
id,
|
|
255
290
|
name: wf.definition.name ?? id,
|
|
@@ -257,10 +292,23 @@ const streamlinePluginImpl = async (fastify, options) => {
|
|
|
257
292
|
}));
|
|
258
293
|
});
|
|
259
294
|
fastify.addHook("onClose", async () => {
|
|
260
|
-
for (const wf of registry.values())
|
|
295
|
+
for (const wf of registry.values()) {
|
|
296
|
+
wf.shutdown?.();
|
|
297
|
+
wf.container?.dispose?.();
|
|
298
|
+
}
|
|
261
299
|
});
|
|
262
300
|
};
|
|
263
|
-
/**
|
|
264
|
-
|
|
301
|
+
/**
|
|
302
|
+
* Pluggable streamline integration for Arc.
|
|
303
|
+
*
|
|
304
|
+
* Wrapped in `fastify-plugin` so Fastify treats `options.prefix` as a
|
|
305
|
+
* plain plugin option (NOT an encapsulation prefix). Without the wrapper,
|
|
306
|
+
* Fastify would prepend `options.prefix` to every route, then the plugin
|
|
307
|
+
* code would prepend it again — the duplicate-prefix bug.
|
|
308
|
+
*/
|
|
309
|
+
const streamlinePlugin = fp(streamlinePluginImpl, {
|
|
310
|
+
name: "streamline-routes",
|
|
311
|
+
fastify: "5.x"
|
|
312
|
+
});
|
|
265
313
|
//#endregion
|
|
266
|
-
export { STREAMLINE_BUS_EVENTS, STREAMLINE_TERMINAL_EVENTS, streamlinePlugin };
|
|
314
|
+
export { STREAMLINE_BUS_EVENTS, STREAMLINE_FAILURE_CODES, STREAMLINE_TERMINAL_EVENTS, streamlinePlugin };
|
package/dist/plugins/index.d.mts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { On as HookSystem, Q as MiddlewareConfig, Wt as AnyRecord, ft as RouteSchemaOptions, lt as RouteDefinition, tt as PresetHook, z as ResourceRegistry } from "../index-BswOSJCE.mjs";
|
|
2
2
|
import { t as ExternalOpenApiPaths } from "../externalPaths-BD5nw6St.mjs";
|
|
3
|
-
import { a as MetricsCollector, c as metricsPlugin, d as ssePlugin, f as CachingOptions, h as cachingPlugin, i as MetricEntry, l as SSEOptions, m as _default$1, n as _default$7, o as MetricsOptions, p as CachingRule, r as versioningPlugin, s as _default$4, t as VersioningOptions, u as _default$6 } from "../versioning-
|
|
3
|
+
import { _ as HealthOptions, a as MetricsCollector, c as metricsPlugin, d as ssePlugin, f as CachingOptions, g as HealthCheck, h as cachingPlugin, i as MetricEntry, l as SSEOptions, m as _default$1, n as _default$7, o as MetricsOptions, p as CachingRule, r as versioningPlugin, s as _default$4, t as VersioningOptions, u as _default$6, v as _default$3, y as healthPlugin } from "../versioning-hmkPcDlX.mjs";
|
|
4
4
|
import { i as errorHandlerPlugin, n as ErrorMapper, r as defaultIsDuplicateKeyError, t as ErrorHandlerOptions } from "../errorHandler-DFr45ZG4.mjs";
|
|
5
5
|
import { t as TracingOptions } from "../tracing-QJVprktp.mjs";
|
|
6
6
|
import { PaginatedResult } from "@classytic/repo-core/pagination";
|
|
@@ -137,39 +137,6 @@ declare module "fastify" {
|
|
|
137
137
|
}
|
|
138
138
|
declare const _default$2: FastifyPluginAsync<GracefulShutdownOptions>;
|
|
139
139
|
//#endregion
|
|
140
|
-
//#region src/plugins/health.d.ts
|
|
141
|
-
declare module "fastify" {
|
|
142
|
-
interface FastifyRequest {
|
|
143
|
-
_startTime?: number;
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
interface HealthCheck {
|
|
147
|
-
/** Name of the dependency */
|
|
148
|
-
name: string;
|
|
149
|
-
/** Function that returns true if healthy, false otherwise */
|
|
150
|
-
check: () => Promise<boolean> | boolean;
|
|
151
|
-
/** Optional timeout in ms (default: 5000) */
|
|
152
|
-
timeout?: number;
|
|
153
|
-
/** Whether this check is critical for readiness (default: true) */
|
|
154
|
-
critical?: boolean;
|
|
155
|
-
}
|
|
156
|
-
interface HealthOptions {
|
|
157
|
-
/** Route prefix (default: '/_health') */
|
|
158
|
-
prefix?: string;
|
|
159
|
-
/** Health check dependencies */
|
|
160
|
-
checks?: HealthCheck[];
|
|
161
|
-
/** Enable metrics endpoint (default: false) */
|
|
162
|
-
metrics?: boolean;
|
|
163
|
-
/** Custom metrics collector function */
|
|
164
|
-
metricsCollector?: () => Promise<string> | string;
|
|
165
|
-
/** Version info to include in responses */
|
|
166
|
-
version?: string;
|
|
167
|
-
/** Collect HTTP request metrics (default: true if metrics enabled) */
|
|
168
|
-
collectHttpMetrics?: boolean;
|
|
169
|
-
}
|
|
170
|
-
declare const healthPlugin: FastifyPluginAsync<HealthOptions>;
|
|
171
|
-
declare const _default$3: FastifyPluginAsync<HealthOptions>;
|
|
172
|
-
//#endregion
|
|
173
140
|
//#region src/plugins/replyHelpers.d.ts
|
|
174
141
|
declare module "fastify" {
|
|
175
142
|
interface FastifyReply {
|
|
@@ -58,7 +58,7 @@ try {
|
|
|
58
58
|
function createTracerProvider(options) {
|
|
59
59
|
if (!isAvailable || !NodeTracerProvider || !BatchSpanProcessor || !OTLPTraceExporter) return null;
|
|
60
60
|
const { serviceName = "@classytic/arc", serviceVersion, exporterUrl = "http://localhost:4318/v1/traces" } = options;
|
|
61
|
-
const resolvedVersion = serviceVersion ?? "2.15.
|
|
61
|
+
const resolvedVersion = serviceVersion ?? "2.15.4";
|
|
62
62
|
const exporter = new OTLPTraceExporter({ url: exporterUrl });
|
|
63
63
|
const provider = new NodeTracerProvider({ resource: { attributes: {
|
|
64
64
|
"service.name": serviceName,
|
|
@@ -185,7 +185,8 @@ function multiTenantPreset(options = {}) {
|
|
|
185
185
|
get: [getFilter("get")],
|
|
186
186
|
create: [tenantInjection],
|
|
187
187
|
update: [getFilter("update"), tenantInjection],
|
|
188
|
-
delete: [getFilter("delete")]
|
|
188
|
+
delete: [getFilter("delete")],
|
|
189
|
+
aggregations: [strictTenantFilter]
|
|
189
190
|
}
|
|
190
191
|
};
|
|
191
192
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { p as isArcError } from "./errors-j4aJm1Wg.mjs";
|
|
2
|
-
import { t as BaseController } from "./BaseController-
|
|
2
|
+
import { t as BaseController } from "./BaseController-dx3m2J8V.mjs";
|
|
3
3
|
import { L as normalizePermissionResult } from "./permissions-ohQyv50e.mjs";
|
|
4
4
|
import { t as executePipeline } from "./pipe-Zr0KXjQe.mjs";
|
|
5
5
|
import { u as resolvePipelineSteps } from "./routerShared-DrOa-26E.mjs";
|
|
6
|
-
import { n as executeAggregation, r as validateAggregations } from "./buildHandler-
|
|
6
|
+
import { n as executeAggregation, r as validateAggregations } from "./buildHandler-CcFOpJLh.mjs";
|
|
7
7
|
import { t as resolveActionPermission } from "./actionPermissions-CyUkQu6O.mjs";
|
|
8
8
|
import { i as shouldRejectAdditionalProperties, r as schemaIRToZodShape, t as normalizeSchemaIR } from "./schemaIR-lYhC2gE5.mjs";
|
|
9
9
|
import { t as pluralize } from "./pluralize-DQgqgifU.mjs";
|
package/dist/testing/index.d.mts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { V as ResourceDefinition, Wt as AnyRecord } from "../index-BswOSJCE.mjs";
|
|
2
|
-
import { d as ResourceLike, r as CreateAppOptions } from "../types-
|
|
2
|
+
import { d as ResourceLike, r as CreateAppOptions } from "../types-DrBaUwyV.mjs";
|
|
3
3
|
import { StorageContractSetup, StorageContractSetupResult, runStorageContract } from "./storageContract.mjs";
|
|
4
4
|
import { FastifyInstance, FastifyServerOptions } from "fastify";
|
|
5
5
|
import { Mock } from "vitest";
|
package/dist/testing/index.mjs
CHANGED
|
@@ -1070,7 +1070,7 @@ function pickDefaultAuth(authMode, callerAuth) {
|
|
|
1070
1070
|
};
|
|
1071
1071
|
}
|
|
1072
1072
|
async function createTestApp(options = {}) {
|
|
1073
|
-
const { createApp } = await import("../createApp-
|
|
1073
|
+
const { createApp } = await import("../createApp-PFegs47-.mjs").then((n) => n.r);
|
|
1074
1074
|
const { resources = [], db = "in-memory", connectMongoose = false, authMode = "jwt", defaultOrgId, plugins, auth: callerAuth, ...appOptions } = options;
|
|
1075
1075
|
let dbHandle;
|
|
1076
1076
|
let dbUri;
|
|
@@ -5,7 +5,7 @@ import { a as EventTransport } from "./EventTransport-CT_52aWU.mjs";
|
|
|
5
5
|
import { t as ExternalOpenApiPaths } from "./externalPaths-BD5nw6St.mjs";
|
|
6
6
|
import { r as QueryCachePluginOptions } from "./queryCachePlugin-CqMdLI2-.mjs";
|
|
7
7
|
import { t as EventPluginOptions } from "./eventPlugin-qXpqTebY.mjs";
|
|
8
|
-
import { f as CachingOptions, l as SSEOptions, o as MetricsOptions, t as VersioningOptions } from "./versioning-
|
|
8
|
+
import { _ as HealthOptions, f as CachingOptions, l as SSEOptions, o as MetricsOptions, t as VersioningOptions } from "./versioning-hmkPcDlX.mjs";
|
|
9
9
|
import { t as ErrorHandlerOptions } from "./errorHandler-DFr45ZG4.mjs";
|
|
10
10
|
import { r as IdempotencyStore } from "./interface-DfLGcus7.mjs";
|
|
11
11
|
import { FastifyInstance, FastifyPluginAsync, FastifyReply, FastifyRequest, FastifyServerOptions } from "fastify";
|
|
@@ -488,6 +488,16 @@ interface CreateAppOptions {
|
|
|
488
488
|
trustProxy?: boolean;
|
|
489
489
|
/** Fastify plugin/onReady timeout in ms (default: 10_000). Raise for slow boot work (index materialisation, WAL replay, external warm-up). */
|
|
490
490
|
pluginTimeout?: number;
|
|
491
|
+
/**
|
|
492
|
+
* Maximum JSON body size in bytes. Pass-through to Fastify's
|
|
493
|
+
* server-level `bodyLimit` option; default is Fastify's 1 MiB
|
|
494
|
+
* (1_048_576 bytes). Raise for hosts shipping bulk-import / CSV ingest
|
|
495
|
+
* / JSON-RPC batch endpoints — without this, Fastify rejects oversized
|
|
496
|
+
* payloads with `FST_ERR_CTP_BODY_TOO_LARGE` (413) before any route
|
|
497
|
+
* handler runs. File uploads on `multipart` routes are governed
|
|
498
|
+
* separately by `multipart.limits.fileSize`. (2.15.1)
|
|
499
|
+
*/
|
|
500
|
+
bodyLimit?: number;
|
|
491
501
|
/**
|
|
492
502
|
* Auth configuration
|
|
493
503
|
*
|
|
@@ -578,8 +588,34 @@ interface CreateAppOptions {
|
|
|
578
588
|
rawBody?: RawBodyOptions | false;
|
|
579
589
|
/** Enable Arc plugins (requestId, health, gracefulShutdown, events, caching, sse) */
|
|
580
590
|
arcPlugins?: {
|
|
581
|
-
/** Request ID tracking (default: true) */requestId?: boolean;
|
|
582
|
-
|
|
591
|
+
/** Request ID tracking (default: true) */requestId?: boolean;
|
|
592
|
+
/**
|
|
593
|
+
* Health endpoints (default: true).
|
|
594
|
+
*
|
|
595
|
+
* Three forms:
|
|
596
|
+
* - `true` (default) — register Arc's health plugin with no extra checks
|
|
597
|
+
* (`/_health/live` always 200, `/_health/ready` 200 unless explicit
|
|
598
|
+
* readiness probes are added later).
|
|
599
|
+
* - `false` — disable Arc's health plugin entirely; the host registers
|
|
600
|
+
* its own (or none).
|
|
601
|
+
* - `{ checks: HealthCheck[] }` — register Arc's health plugin AND
|
|
602
|
+
* attach the supplied readiness probes (Mongo connectivity, engine
|
|
603
|
+
* warmup, queue connectivity, etc.). Closes the pre-2.15.1 hole
|
|
604
|
+
* where adding checks meant `health: false` + manual re-registration.
|
|
605
|
+
*
|
|
606
|
+
* @example
|
|
607
|
+
* ```typescript
|
|
608
|
+
* arcPlugins: {
|
|
609
|
+
* health: {
|
|
610
|
+
* checks: [
|
|
611
|
+
* { name: 'mongo', check: async () => mongoose.connection.readyState === 1 },
|
|
612
|
+
* { name: 'catalog-engine', check: async () => catalog.isReady() },
|
|
613
|
+
* ],
|
|
614
|
+
* },
|
|
615
|
+
* }
|
|
616
|
+
* ```
|
|
617
|
+
*/
|
|
618
|
+
health?: boolean | HealthOptions; /** Graceful shutdown handling (default: true) */
|
|
583
619
|
gracefulShutdown?: boolean; /** Emit events for CRUD operations (default: true) */
|
|
584
620
|
emitEvents?: boolean;
|
|
585
621
|
/**
|
|
@@ -1,6 +1,39 @@
|
|
|
1
1
|
import { n as DomainEvent } from "./EventTransport-CT_52aWU.mjs";
|
|
2
2
|
import { FastifyPluginAsync, FastifyRequest } from "fastify";
|
|
3
3
|
|
|
4
|
+
//#region src/plugins/health.d.ts
|
|
5
|
+
declare module "fastify" {
|
|
6
|
+
interface FastifyRequest {
|
|
7
|
+
_startTime?: number;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
interface HealthCheck {
|
|
11
|
+
/** Name of the dependency */
|
|
12
|
+
name: string;
|
|
13
|
+
/** Function that returns true if healthy, false otherwise */
|
|
14
|
+
check: () => Promise<boolean> | boolean;
|
|
15
|
+
/** Optional timeout in ms (default: 5000) */
|
|
16
|
+
timeout?: number;
|
|
17
|
+
/** Whether this check is critical for readiness (default: true) */
|
|
18
|
+
critical?: boolean;
|
|
19
|
+
}
|
|
20
|
+
interface HealthOptions {
|
|
21
|
+
/** Route prefix (default: '/_health') */
|
|
22
|
+
prefix?: string;
|
|
23
|
+
/** Health check dependencies */
|
|
24
|
+
checks?: HealthCheck[];
|
|
25
|
+
/** Enable metrics endpoint (default: false) */
|
|
26
|
+
metrics?: boolean;
|
|
27
|
+
/** Custom metrics collector function */
|
|
28
|
+
metricsCollector?: () => Promise<string> | string;
|
|
29
|
+
/** Version info to include in responses */
|
|
30
|
+
version?: string;
|
|
31
|
+
/** Collect HTTP request metrics (default: true if metrics enabled) */
|
|
32
|
+
collectHttpMetrics?: boolean;
|
|
33
|
+
}
|
|
34
|
+
declare const healthPlugin: FastifyPluginAsync<HealthOptions>;
|
|
35
|
+
declare const _default$4: FastifyPluginAsync<HealthOptions>;
|
|
36
|
+
//#endregion
|
|
4
37
|
//#region src/plugins/caching.d.ts
|
|
5
38
|
interface CachingRule {
|
|
6
39
|
/** Path prefix to match (e.g., '/api/products') */
|
|
@@ -114,4 +147,4 @@ declare module "fastify" {
|
|
|
114
147
|
declare const versioningPlugin: FastifyPluginAsync<VersioningOptions>;
|
|
115
148
|
declare const _default: FastifyPluginAsync<VersioningOptions>;
|
|
116
149
|
//#endregion
|
|
117
|
-
export { MetricsCollector as a, metricsPlugin as c, ssePlugin as d, CachingOptions as f, cachingPlugin as h, MetricEntry as i, SSEOptions as l, _default$3 as m, _default as n, MetricsOptions as o, CachingRule as p, versioningPlugin as r, _default$1 as s, VersioningOptions as t, _default$2 as u };
|
|
150
|
+
export { HealthOptions as _, MetricsCollector as a, metricsPlugin as c, ssePlugin as d, CachingOptions as f, HealthCheck as g, cachingPlugin as h, MetricEntry as i, SSEOptions as l, _default$3 as m, _default as n, MetricsOptions as o, CachingRule as p, versioningPlugin as r, _default$1 as s, VersioningOptions as t, _default$2 as u, _default$4 as v, healthPlugin as y };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@classytic/arc",
|
|
3
|
-
"version": "2.15.
|
|
3
|
+
"version": "2.15.4",
|
|
4
4
|
"description": "Resource-oriented backend framework for Fastify - clean, minimal, powerful, tree-shakable",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -245,9 +245,9 @@
|
|
|
245
245
|
"node": ">=22"
|
|
246
246
|
},
|
|
247
247
|
"peerDependencies": {
|
|
248
|
-
"@classytic/primitives": ">=0.
|
|
249
|
-
"@classytic/repo-core": ">=0.4.
|
|
250
|
-
"@classytic/streamline": ">=2.3.
|
|
248
|
+
"@classytic/primitives": ">=0.5.0",
|
|
249
|
+
"@classytic/repo-core": ">=0.4.1",
|
|
250
|
+
"@classytic/streamline": ">=2.3.3",
|
|
251
251
|
"@fastify/cors": ">=11.0.0",
|
|
252
252
|
"@fastify/helmet": ">=13.0.0",
|
|
253
253
|
"@fastify/jwt": ">=10.0.0",
|
|
@@ -357,11 +357,11 @@
|
|
|
357
357
|
"@better-auth/mongo-adapter": "^1.6.9",
|
|
358
358
|
"@biomejs/biome": "^2.4.11",
|
|
359
359
|
"@classytic/dev-tools": "^0.2.0",
|
|
360
|
-
"@classytic/mongokit": "^3.13.
|
|
360
|
+
"@classytic/mongokit": "^3.13.2",
|
|
361
361
|
"@classytic/primitives": "^0.4.0",
|
|
362
|
-
"@classytic/repo-core": "^0.4.
|
|
363
|
-
"@classytic/sqlitekit": "^0.3.
|
|
364
|
-
"@classytic/streamline": "^2.3.
|
|
362
|
+
"@classytic/repo-core": "^0.4.1",
|
|
363
|
+
"@classytic/sqlitekit": "^0.3.1",
|
|
364
|
+
"@classytic/streamline": "^2.3.3",
|
|
365
365
|
"@fastify/cors": "^11.2.0",
|
|
366
366
|
"@fastify/helmet": "^13.0.2",
|
|
367
367
|
"@fastify/jwt": "^10.0.0",
|
|
@@ -420,4 +420,4 @@
|
|
|
420
420
|
"type": "git",
|
|
421
421
|
"url": "https://github.com/classytic/arc.git"
|
|
422
422
|
}
|
|
423
|
-
}
|
|
423
|
+
}
|
package/skills/arc/SKILL.md
CHANGED
|
@@ -27,7 +27,7 @@ progressive_disclosure:
|
|
|
27
27
|
entry_point:
|
|
28
28
|
summary: "Resource-oriented Fastify framework: defineResource(), presets, permissions, QueryCache, events, multi-tenant, OpenAPI, MCP"
|
|
29
29
|
when_to_use: "Building REST APIs with Fastify, resource CRUD, authentication, presets, caching, events, or production deployment"
|
|
30
|
-
quick_start: "1. arc init my-api --mongokit --
|
|
30
|
+
quick_start: "1. npx @classytic/arc init my-api --mongokit --better-auth --single --ts 2. defineResource({ name, adapter, presets, permissions }) 3. createApp({ preset: 'production', resources, auth })"
|
|
31
31
|
---
|
|
32
32
|
|
|
33
33
|
# @classytic/arc
|
|
@@ -39,11 +39,11 @@ One `defineResource()` call → REST + auth + permissions + events + cache + Ope
|
|
|
39
39
|
## Scaffold a project
|
|
40
40
|
|
|
41
41
|
```bash
|
|
42
|
-
npx @classytic/arc@latest init my-api --mongokit --
|
|
42
|
+
npx @classytic/arc@latest init my-api --mongokit --better-auth --single --ts
|
|
43
43
|
cd my-api && npm install && npm run dev
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
-
Flags: `--mongokit | --custom`, `--
|
|
46
|
+
Flags: `--mongokit | --custom`, `--better-auth | --jwt`, `--single | --multi`, `--ts | --js`, `--edge`, `--force`, `--skip-install`. Defaults: `--mongokit --better-auth --single --ts`. The scaffold seeds full `dependencies` + `devDependencies` so `npm install` works without the CLI's pre-pass.
|
|
47
47
|
|
|
48
48
|
## createApp()
|
|
49
49
|
|
|
@@ -639,6 +639,96 @@ defineResource({
|
|
|
639
639
|
|
|
640
640
|
MCP auto-derives `filterableFields` from `queryParser`.
|
|
641
641
|
|
|
642
|
+
## Aggregations — dashboards in declarative form
|
|
643
|
+
|
|
644
|
+
Add `aggregations: { … }` to a resource and Arc registers `GET
|
|
645
|
+
/{prefix}/aggregations/:name` per entry. Each runs a portable `$match
|
|
646
|
+
→ $group → $project → $sort → $limit` pipeline against the kit's
|
|
647
|
+
`repo.aggregate(req, options)` — same shape across mongokit /
|
|
648
|
+
sqlitekit / prismakit, so dashboards work unchanged across backends.
|
|
649
|
+
|
|
650
|
+
```typescript
|
|
651
|
+
import { defineResource, defineAggregation } from '@classytic/arc';
|
|
652
|
+
|
|
653
|
+
defineResource({
|
|
654
|
+
name: 'transaction',
|
|
655
|
+
adapter,
|
|
656
|
+
presets: [multiTenantPreset({ tenantField: 'organizationId' })],
|
|
657
|
+
permissions: { list: canViewRevenue() },
|
|
658
|
+
|
|
659
|
+
aggregations: {
|
|
660
|
+
byPaymentMethod: defineAggregation({
|
|
661
|
+
groupBy: 'method',
|
|
662
|
+
measures: { total: 'sum:amount', count: 'count' },
|
|
663
|
+
sort: { total: -1 },
|
|
664
|
+
cache: { staleTime: 60, swr: true, tags: ['revenue'] },
|
|
665
|
+
permissions: canViewRevenue(),
|
|
666
|
+
}),
|
|
667
|
+
byFlow: defineAggregation({
|
|
668
|
+
groupBy: 'flow',
|
|
669
|
+
measures: { total: 'sum:amount', count: 'count' },
|
|
670
|
+
cache: { staleTime: 60, swr: true, tags: ['revenue'] },
|
|
671
|
+
permissions: canViewRevenue(),
|
|
672
|
+
}),
|
|
673
|
+
byDay: defineAggregation({
|
|
674
|
+
dateBuckets: { day: { field: 'createdAt', interval: 'day' } },
|
|
675
|
+
groupBy: 'flow',
|
|
676
|
+
measures: { total: 'sum:amount', count: 'count' },
|
|
677
|
+
sort: { day: 1 },
|
|
678
|
+
requireDateRange: { field: 'createdAt', maxRangeDays: 365 },
|
|
679
|
+
cache: { staleTime: 60, swr: true, tags: ['revenue'] },
|
|
680
|
+
permissions: canViewRevenue(),
|
|
681
|
+
}),
|
|
682
|
+
},
|
|
683
|
+
});
|
|
684
|
+
```
|
|
685
|
+
|
|
686
|
+
**Tenant scope flows through the second arg, NOT the filter.** Arc is
|
|
687
|
+
DB-agnostic — type-coercion (string → ObjectId for mongokit
|
|
688
|
+
`fieldType: 'objectId'`, UUID/text for sqlitekit, etc.) belongs to the
|
|
689
|
+
kit. Arc threads `tenantOptions` to `repo.aggregate(req, options)`;
|
|
690
|
+
the kit's multi-tenant plugin reads `context.organizationId`, casts
|
|
691
|
+
correctly, and merges into the request. Authors never inject the
|
|
692
|
+
tenant key into `aggReq.filter` themselves.
|
|
693
|
+
|
|
694
|
+
**2.15.3 — `multiTenantPreset` now wires `/aggregations/:name`.** Pre-2.15.3
|
|
695
|
+
the preset only scoped CRUD; aggregation routes leaked across orgs for any
|
|
696
|
+
caller whose `scope.kind !== 'member'`. Adding `multiTenantPreset({ tenantField: 'organizationId' })`
|
|
697
|
+
now emits an `aggregations` middleware slot alongside the five CRUD slots, so
|
|
698
|
+
member callers see only their org and `kind: 'elevated'` callers WITHOUT a
|
|
699
|
+
target org get `bypassTenant: true` (platform-admin cross-tenant dashboards).
|
|
700
|
+
**Kit config note:** set `scope: true` (or `scope: { fieldType: 'objectId' }`)
|
|
701
|
+
on revenue/order/etc. engines — the pre-2.15.2 advice to use `scope: false`
|
|
702
|
+
"to avoid double-scoping with arc" is no longer correct; arc 2.15.2+
|
|
703
|
+
deliberately leaves `aggReq.filter` clean and relies on the kit. Required
|
|
704
|
+
peers: `@classytic/repo-core ≥ 0.4.1`, `@classytic/mongokit ≥ 3.13.2`,
|
|
705
|
+
`@classytic/sqlitekit ≥ 0.3.1`.
|
|
706
|
+
|
|
707
|
+
**Caller filters via query string compose with `groupBy` / measures:**
|
|
708
|
+
|
|
709
|
+
```
|
|
710
|
+
GET /api/transactions/aggregations/byPaymentMethod?status=verified
|
|
711
|
+
GET /api/transactions/aggregations/byDay?createdAt[gte]=2026-01-01&createdAt[lt]=2026-02-01
|
|
712
|
+
```
|
|
713
|
+
|
|
714
|
+
**Safety guards on the declaration:**
|
|
715
|
+
- `requireDateRange: { field, maxRangeDays }` — bounded range mandatory; kills accidental all-time scans
|
|
716
|
+
- `requireFilters: ['orgId']` — mandatory scope keys
|
|
717
|
+
- `maxGroups: 1000` — post-execution row cap; 422 on overflow
|
|
718
|
+
|
|
719
|
+
**Cache invalidation:** writes through resource CRUD bump the
|
|
720
|
+
matching tag. Aggregations cached with the same `tags` invalidate
|
|
721
|
+
together. SWR mode serves stale immediately while revalidating in
|
|
722
|
+
background.
|
|
723
|
+
|
|
724
|
+
**MCP auto-export:** every aggregation surfaces as an MCP tool
|
|
725
|
+
named `{resource}_aggregations_{name}` with the same permission gate
|
|
726
|
+
and filter validation as the HTTP route.
|
|
727
|
+
|
|
728
|
+
For backends without `repo.aggregate` (custom adapters), declare a
|
|
729
|
+
`materialized` hook on the aggregation — Arc routes through it
|
|
730
|
+
instead of the kit and returns the same `{ rows }` envelope.
|
|
731
|
+
|
|
642
732
|
## QueryCache
|
|
643
733
|
|
|
644
734
|
TanStack Query-style server cache, stale-while-revalidate, auto-invalidation on mutations.
|
|
@@ -756,14 +846,17 @@ Full testing recipes → [references/testing.md](references/testing.md).
|
|
|
756
846
|
|
|
757
847
|
## CLI
|
|
758
848
|
|
|
849
|
+
The bin is `arc` (registered by `@classytic/arc`). Outside an arc project use `npx @classytic/arc <cmd>`; inside one (devDep installed) bare `arc` resolves through `node_modules/.bin`.
|
|
850
|
+
|
|
759
851
|
```bash
|
|
760
|
-
arc init my-api --mongokit --
|
|
761
|
-
arc generate resource product
|
|
762
|
-
arc generate resource product --mcp
|
|
763
|
-
arc generate mcp analytics
|
|
764
|
-
arc docs ./openapi.json --entry ./dist/index.js
|
|
765
|
-
arc introspect --entry ./dist/index.js
|
|
766
|
-
arc
|
|
852
|
+
npx @classytic/arc init my-api --mongokit --better-auth --single --ts # scaffold (also: --custom, --jwt, --multi, --js, --edge)
|
|
853
|
+
npx @classytic/arc generate resource product # generate a resource (alias: arc g r product)
|
|
854
|
+
npx @classytic/arc generate resource product --mcp # + MCP tools file
|
|
855
|
+
npx @classytic/arc generate mcp analytics # standalone MCP tools file
|
|
856
|
+
npx @classytic/arc docs ./openapi.json --entry ./dist/index.js # emit OpenAPI
|
|
857
|
+
npx @classytic/arc introspect --entry ./dist/index.js
|
|
858
|
+
npx @classytic/arc describe ./dist/resources.js --json # JSON metadata for AI agents
|
|
859
|
+
npx @classytic/arc doctor
|
|
767
860
|
```
|
|
768
861
|
|
|
769
862
|
Set `"mcp": true` in `.arcrc` to always generate `.mcp.ts` alongside resources.
|
|
@@ -894,6 +894,49 @@ Then check whether the hook body sets headers (`reply.header(`, `reply.headers[`
|
|
|
894
894
|
|
|
895
895
|
---
|
|
896
896
|
|
|
897
|
+
## §33. Hand-rolled aggregation routes when `aggregations: { … }` would do 🟠
|
|
898
|
+
|
|
899
|
+
**Detection:**
|
|
900
|
+
```
|
|
901
|
+
pattern: "Model\\.aggregate\\(\\["
|
|
902
|
+
output_mode: content
|
|
903
|
+
```
|
|
904
|
+
Also: `\\.aggregate\\(\\s*\\[`, `\\$group\\b`, `\\$match\\b` inside resource handler files. Plus inline mongoose pipeline imports inside route handlers (vs. inside the kit's repository).
|
|
905
|
+
|
|
906
|
+
**Anti-pattern:**
|
|
907
|
+
```typescript
|
|
908
|
+
// In a custom GET /aggregate/byPaymentMethod route:
|
|
909
|
+
const orgId = getOrgId(getScope(req));
|
|
910
|
+
const rows = await Transaction.aggregate([
|
|
911
|
+
{ $match: { organizationId: new mongoose.Types.ObjectId(orgId), status: 'verified' } },
|
|
912
|
+
{ $group: { _id: '$method', total: { $sum: '$amount' }, count: { $sum: 1 } } },
|
|
913
|
+
{ $sort: { total: -1 } },
|
|
914
|
+
]);
|
|
915
|
+
return reply.send({ rows });
|
|
916
|
+
```
|
|
917
|
+
|
|
918
|
+
**Why high:** loses every cross-cutting feature arc's aggregation router gives for free — per-aggregation permissions, OpenAPI schema, MCP tool export, SWR cache + tag invalidation, `requireFilters` / `requireDateRange` safety guards, `maxGroups` cap, multi-tenant scope without manual `ObjectId` casting. The hand-rolled version also drifts: every new bucket needs a new route, no central declaration, no unified shape across kits.
|
|
919
|
+
|
|
920
|
+
**Fix:** declare `aggregations: { name: defineAggregation({ groupBy, measures, sort, cache, permissions }) }` on the resource. Arc registers `GET /:prefix/aggregations/:name` per entry, threads `tenantOptions` to `repo.aggregate(req, options)`, and the kit's multi-tenant plugin handles type-coercion. Type-coercion (string → ObjectId / UUID / text) is **the kit's responsibility, not the framework's** — never inject the tenant key into `aggReq.filter` from arc-host code.
|
|
921
|
+
|
|
922
|
+
```typescript
|
|
923
|
+
import { defineAggregation } from '@classytic/arc';
|
|
924
|
+
|
|
925
|
+
aggregations: {
|
|
926
|
+
byPaymentMethod: defineAggregation({
|
|
927
|
+
groupBy: 'method',
|
|
928
|
+
measures: { total: 'sum:amount', count: 'count' },
|
|
929
|
+
sort: { total: -1 },
|
|
930
|
+
cache: { staleTime: 60, swr: true, tags: ['revenue'] },
|
|
931
|
+
permissions: canViewRevenue(),
|
|
932
|
+
}),
|
|
933
|
+
}
|
|
934
|
+
```
|
|
935
|
+
|
|
936
|
+
For backends without `repo.aggregate`, declare a `materialized` hook on the aggregation — the router calls it instead of the kit and keeps the same `{ rows }` envelope, permission gate, and cache contract.
|
|
937
|
+
|
|
938
|
+
---
|
|
939
|
+
|
|
897
940
|
## Detection checklist (run-order)
|
|
898
941
|
|
|
899
942
|
Run sweeps in this order — early hits often invalidate later context:
|
|
@@ -909,3 +952,4 @@ Run sweeps in this order — early hits often invalidate later context:
|
|
|
909
952
|
9. §14, §18, §19 — preset adoption + custom controller wiring
|
|
910
953
|
10. §11, §12, §13, §29, §30, §31 — style and edges
|
|
911
954
|
11. §32a–§32g — canonical-contract drift (pagination / events / tenant / errors / adapter imports), missing `requireOrgId` accessors, missing `schemaGenerator`, kit-specific adapter imports from arc (3.0 break)
|
|
955
|
+
12. §33 — hand-rolled `Model.aggregate([...])` in resource routes vs declarative `aggregations: { … }`
|
|
@@ -246,6 +246,33 @@ Modes: `memory` (default) | `distributed` (`stores.queryCache: RedisCacheStore`)
|
|
|
246
246
|
|
|
247
247
|
---
|
|
248
248
|
|
|
249
|
+
## Aggregations (declarative dashboards)
|
|
250
|
+
|
|
251
|
+
```typescript
|
|
252
|
+
import { defineAggregation } from '@classytic/arc';
|
|
253
|
+
|
|
254
|
+
aggregations: {
|
|
255
|
+
byMethod: defineAggregation({
|
|
256
|
+
groupBy: 'method',
|
|
257
|
+
measures: { total: 'sum:amount', count: 'count' },
|
|
258
|
+
sort: { total: -1 },
|
|
259
|
+
cache: { staleTime: 60, swr: true, tags: ['revenue'] },
|
|
260
|
+
permissions: canViewRevenue(),
|
|
261
|
+
}),
|
|
262
|
+
byDay: defineAggregation({
|
|
263
|
+
dateBuckets: { day: { field: 'createdAt', interval: 'day' } },
|
|
264
|
+
groupBy: 'flow',
|
|
265
|
+
measures: { total: 'sum:amount' },
|
|
266
|
+
requireDateRange: { field: 'createdAt', maxRangeDays: 365 },
|
|
267
|
+
permissions: canViewRevenue(),
|
|
268
|
+
}),
|
|
269
|
+
}
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
Registers `GET /:prefix/aggregations/:name` per entry. Same permissions, OpenAPI, MCP tool, cache + tag invalidation as CRUD. Tenant flows via the kit's multi-tenant plugin (string → ObjectId casting handled by the kit, **not** by hand). Caller filters via query string (`?status=verified&createdAt[gte]=...`) compose with the declaration. Safety: `requireFilters`, `requireDateRange`, `maxGroups`. **Anti-pattern:** custom routes calling `Model.aggregate([...])` directly — see anti-patterns §33.
|
|
273
|
+
|
|
274
|
+
---
|
|
275
|
+
|
|
249
276
|
## CLI
|
|
250
277
|
|
|
251
278
|
```bash
|