@classytic/arc 2.15.0 → 2.15.3
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/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/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 +5 -5
- package/skills/arc/SKILL.md +77 -0
- 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
|
}
|
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.3";
|
|
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
|
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.3";
|
|
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.3",
|
|
4
4
|
"description": "Resource-oriented backend framework for Fastify - clean, minimal, powerful, tree-shakable",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -246,7 +246,7 @@
|
|
|
246
246
|
},
|
|
247
247
|
"peerDependencies": {
|
|
248
248
|
"@classytic/primitives": ">=0.4.0",
|
|
249
|
-
"@classytic/repo-core": ">=0.4.
|
|
249
|
+
"@classytic/repo-core": ">=0.4.1",
|
|
250
250
|
"@classytic/streamline": ">=2.3.0",
|
|
251
251
|
"@fastify/cors": ">=11.0.0",
|
|
252
252
|
"@fastify/helmet": ">=13.0.0",
|
|
@@ -357,10 +357,10 @@
|
|
|
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.
|
|
362
|
+
"@classytic/repo-core": "^0.4.1",
|
|
363
|
+
"@classytic/sqlitekit": "^0.3.1",
|
|
364
364
|
"@classytic/streamline": "^2.3.0",
|
|
365
365
|
"@fastify/cors": "^11.2.0",
|
|
366
366
|
"@fastify/helmet": "^13.0.2",
|
package/skills/arc/SKILL.md
CHANGED
|
@@ -639,6 +639,83 @@ 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
|
+
**Caller filters via query string compose with `groupBy` / measures:**
|
|
695
|
+
|
|
696
|
+
```
|
|
697
|
+
GET /api/transactions/aggregations/byPaymentMethod?status=verified
|
|
698
|
+
GET /api/transactions/aggregations/byDay?createdAt[gte]=2026-01-01&createdAt[lt]=2026-02-01
|
|
699
|
+
```
|
|
700
|
+
|
|
701
|
+
**Safety guards on the declaration:**
|
|
702
|
+
- `requireDateRange: { field, maxRangeDays }` — bounded range mandatory; kills accidental all-time scans
|
|
703
|
+
- `requireFilters: ['orgId']` — mandatory scope keys
|
|
704
|
+
- `maxGroups: 1000` — post-execution row cap; 422 on overflow
|
|
705
|
+
|
|
706
|
+
**Cache invalidation:** writes through resource CRUD bump the
|
|
707
|
+
matching tag. Aggregations cached with the same `tags` invalidate
|
|
708
|
+
together. SWR mode serves stale immediately while revalidating in
|
|
709
|
+
background.
|
|
710
|
+
|
|
711
|
+
**MCP auto-export:** every aggregation surfaces as an MCP tool
|
|
712
|
+
named `{resource}_aggregations_{name}` with the same permission gate
|
|
713
|
+
and filter validation as the HTTP route.
|
|
714
|
+
|
|
715
|
+
For backends without `repo.aggregate` (custom adapters), declare a
|
|
716
|
+
`materialized` hook on the aggregation — Arc routes through it
|
|
717
|
+
instead of the kit and returns the same `{ rows }` envelope.
|
|
718
|
+
|
|
642
719
|
## QueryCache
|
|
643
720
|
|
|
644
721
|
TanStack Query-style server cache, stale-while-revalidate, auto-invalidation on mutations.
|
|
@@ -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
|