@classytic/arc 1.1.0 → 2.1.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 +247 -794
- package/bin/arc.js +91 -52
- package/dist/EventTransport-BkUDYZEb.d.mts +99 -0
- package/dist/HookSystem-BsGV-j2l.mjs +404 -0
- package/dist/ResourceRegistry-7Ic20ZMw.mjs +249 -0
- package/dist/adapters/index.d.mts +5 -0
- package/dist/adapters/index.mjs +3 -0
- package/dist/audit/index.d.mts +81 -0
- package/dist/audit/index.mjs +275 -0
- package/dist/audit/mongodb.d.mts +5 -0
- package/dist/audit/mongodb.mjs +3 -0
- package/dist/audited-CGdLiSlE.mjs +140 -0
- package/dist/auth/index.d.mts +188 -0
- package/dist/auth/index.mjs +1096 -0
- package/dist/auth/redis-session.d.mts +43 -0
- package/dist/auth/redis-session.mjs +75 -0
- package/dist/betterAuthOpenApi-DjWDddNc.mjs +249 -0
- package/dist/cache/index.d.mts +145 -0
- package/dist/cache/index.mjs +91 -0
- package/dist/caching-GSDJcA6-.mjs +93 -0
- package/dist/chunk-C7Uep-_p.mjs +20 -0
- package/dist/circuitBreaker-DYhWBW_D.mjs +1096 -0
- package/dist/cli/commands/describe.d.mts +18 -0
- package/dist/cli/commands/describe.mjs +238 -0
- package/dist/cli/commands/docs.d.mts +13 -0
- package/dist/cli/commands/docs.mjs +52 -0
- package/dist/cli/commands/{generate.d.ts → generate.d.mts} +3 -2
- package/dist/cli/commands/generate.mjs +357 -0
- package/dist/cli/commands/{init.d.ts → init.d.mts} +11 -8
- package/dist/cli/commands/{init.js → init.mjs} +807 -617
- package/dist/cli/commands/introspect.d.mts +10 -0
- package/dist/cli/commands/introspect.mjs +75 -0
- package/dist/cli/index.d.mts +16 -0
- package/dist/cli/index.mjs +156 -0
- package/dist/constants-DdXFXQtN.mjs +84 -0
- package/dist/core/index.d.mts +5 -0
- package/dist/core/index.mjs +4 -0
- package/dist/createApp-D2D5XXaV.mjs +559 -0
- package/dist/defineResource-PXzSJ15_.mjs +2197 -0
- package/dist/discovery/index.d.mts +46 -0
- package/dist/discovery/index.mjs +109 -0
- package/dist/docs/index.d.mts +162 -0
- package/dist/docs/index.mjs +74 -0
- package/dist/elevation-DGo5shaX.d.mts +87 -0
- package/dist/elevation-DSTbVvYj.mjs +113 -0
- package/dist/errorHandler-C3GY3_ow.mjs +108 -0
- package/dist/errorHandler-CW3OOeYq.d.mts +72 -0
- package/dist/errors-DAWRdiYP.d.mts +124 -0
- package/dist/errors-DBANPbGr.mjs +211 -0
- package/dist/eventPlugin-BEOvaDqo.mjs +229 -0
- package/dist/eventPlugin-H6wDDjGO.d.mts +124 -0
- package/dist/events/index.d.mts +53 -0
- package/dist/events/index.mjs +51 -0
- package/dist/events/transports/redis-stream-entry.d.mts +2 -0
- package/dist/events/transports/redis-stream-entry.mjs +177 -0
- package/dist/events/transports/redis.d.mts +76 -0
- package/dist/events/transports/redis.mjs +124 -0
- package/dist/externalPaths-SyPF2tgK.d.mts +50 -0
- package/dist/factory/index.d.mts +63 -0
- package/dist/factory/index.mjs +3 -0
- package/dist/fastifyAdapter-C8DlE0YH.d.mts +216 -0
- package/dist/fields-Bi_AVKSo.d.mts +109 -0
- package/dist/fields-CTd_CrKr.mjs +114 -0
- package/dist/hooks/index.d.mts +4 -0
- package/dist/hooks/index.mjs +3 -0
- package/dist/idempotency/index.d.mts +96 -0
- package/dist/idempotency/index.mjs +319 -0
- package/dist/idempotency/mongodb.d.mts +2 -0
- package/dist/idempotency/mongodb.mjs +114 -0
- package/dist/idempotency/redis.d.mts +2 -0
- package/dist/idempotency/redis.mjs +103 -0
- package/dist/index.d.mts +260 -0
- package/dist/index.mjs +104 -0
- package/dist/integrations/event-gateway.d.mts +46 -0
- package/dist/integrations/event-gateway.mjs +43 -0
- package/dist/integrations/index.d.mts +5 -0
- package/dist/integrations/index.mjs +1 -0
- package/dist/integrations/jobs.d.mts +103 -0
- package/dist/integrations/jobs.mjs +123 -0
- package/dist/integrations/streamline.d.mts +60 -0
- package/dist/integrations/streamline.mjs +125 -0
- package/dist/integrations/websocket.d.mts +82 -0
- package/dist/integrations/websocket.mjs +288 -0
- package/dist/interface-CSNjltAc.d.mts +77 -0
- package/dist/interface-DTbsvIWe.d.mts +54 -0
- package/dist/interface-e9XfSsUV.d.mts +1097 -0
- package/dist/introspectionPlugin-B3JkrjwU.mjs +53 -0
- package/dist/keys-DhqDRxv3.mjs +42 -0
- package/dist/logger-ByrvQWZO.mjs +78 -0
- package/dist/memory-B2v7KrCB.mjs +143 -0
- package/dist/migrations/index.d.mts +156 -0
- package/dist/migrations/index.mjs +260 -0
- package/dist/mongodb-ClykrfGo.d.mts +118 -0
- package/dist/mongodb-DNKEExbf.mjs +93 -0
- package/dist/mongodb-Dg8O_gvd.d.mts +71 -0
- package/dist/openapi-9nB_kiuR.mjs +525 -0
- package/dist/org/index.d.mts +68 -0
- package/dist/org/index.mjs +513 -0
- package/dist/org/types.d.mts +82 -0
- package/dist/org/types.mjs +1 -0
- package/dist/permissions/index.d.mts +278 -0
- package/dist/permissions/index.mjs +579 -0
- package/dist/plugins/index.d.mts +172 -0
- package/dist/plugins/index.mjs +522 -0
- package/dist/plugins/response-cache.d.mts +87 -0
- package/dist/plugins/response-cache.mjs +283 -0
- package/dist/plugins/tracing-entry.d.mts +2 -0
- package/dist/plugins/tracing-entry.mjs +185 -0
- package/dist/pluralize-CM-jZg7p.mjs +86 -0
- package/dist/policies/{index.d.ts → index.d.mts} +204 -170
- package/dist/policies/index.mjs +321 -0
- package/dist/presets/{index.d.ts → index.d.mts} +62 -131
- package/dist/presets/index.mjs +143 -0
- package/dist/presets/multiTenant.d.mts +24 -0
- package/dist/presets/multiTenant.mjs +113 -0
- package/dist/presets-BTeYbw7h.d.mts +57 -0
- package/dist/presets-CeFtfDR8.mjs +119 -0
- package/dist/prisma-C3iornoK.d.mts +274 -0
- package/dist/prisma-DJbMt3yf.mjs +627 -0
- package/dist/queryCachePlugin-B6R0d4av.mjs +138 -0
- package/dist/queryCachePlugin-Q6SYuHZ6.d.mts +71 -0
- package/dist/redis-UwjEp8Ea.d.mts +49 -0
- package/dist/redis-stream-CBg0upHI.d.mts +103 -0
- package/dist/registry/index.d.mts +11 -0
- package/dist/registry/index.mjs +4 -0
- package/dist/requestContext-xi6OKBL-.mjs +55 -0
- package/dist/schemaConverter-Dtg0Kt9T.mjs +98 -0
- package/dist/schemas/index.d.mts +63 -0
- package/dist/schemas/index.mjs +82 -0
- package/dist/scope/index.d.mts +21 -0
- package/dist/scope/index.mjs +65 -0
- package/dist/sessionManager-D_iEHjQl.d.mts +186 -0
- package/dist/sse-DkqQ1uxb.mjs +123 -0
- package/dist/testing/index.d.mts +907 -0
- package/dist/testing/index.mjs +1976 -0
- package/dist/tracing-8CEbhF0w.d.mts +70 -0
- package/dist/typeGuards-DwxA1t_L.mjs +9 -0
- package/dist/types/index.d.mts +946 -0
- package/dist/types/index.mjs +14 -0
- package/dist/types-B0dhNrnd.d.mts +445 -0
- package/dist/types-Beqn1Un7.mjs +38 -0
- package/dist/types-DelU6kln.mjs +25 -0
- package/dist/types-RLkFVgaw.d.mts +101 -0
- package/dist/utils/index.d.mts +747 -0
- package/dist/utils/index.mjs +6 -0
- package/package.json +194 -68
- package/dist/BaseController-DVAiHxEQ.d.ts +0 -233
- package/dist/adapters/index.d.ts +0 -237
- package/dist/adapters/index.js +0 -668
- package/dist/arcCorePlugin-CsShQdyP.d.ts +0 -273
- package/dist/audit/index.d.ts +0 -195
- package/dist/audit/index.js +0 -319
- package/dist/auth/index.d.ts +0 -47
- package/dist/auth/index.js +0 -174
- package/dist/cli/commands/docs.d.ts +0 -11
- package/dist/cli/commands/docs.js +0 -474
- package/dist/cli/commands/generate.js +0 -334
- package/dist/cli/commands/introspect.d.ts +0 -8
- package/dist/cli/commands/introspect.js +0 -338
- package/dist/cli/index.d.ts +0 -4
- package/dist/cli/index.js +0 -3269
- package/dist/core/index.d.ts +0 -220
- package/dist/core/index.js +0 -2786
- package/dist/createApp-Ce9wl8W9.d.ts +0 -77
- package/dist/docs/index.d.ts +0 -166
- package/dist/docs/index.js +0 -658
- package/dist/errors-8WIxGS_6.d.ts +0 -122
- package/dist/events/index.d.ts +0 -117
- package/dist/events/index.js +0 -89
- package/dist/factory/index.d.ts +0 -38
- package/dist/factory/index.js +0 -1652
- package/dist/hooks/index.d.ts +0 -4
- package/dist/hooks/index.js +0 -199
- package/dist/idempotency/index.d.ts +0 -323
- package/dist/idempotency/index.js +0 -500
- package/dist/index-B4t03KQ0.d.ts +0 -1366
- package/dist/index.d.ts +0 -135
- package/dist/index.js +0 -4756
- package/dist/migrations/index.d.ts +0 -185
- package/dist/migrations/index.js +0 -274
- package/dist/org/index.d.ts +0 -129
- package/dist/org/index.js +0 -220
- package/dist/permissions/index.d.ts +0 -144
- package/dist/permissions/index.js +0 -103
- package/dist/plugins/index.d.ts +0 -46
- package/dist/plugins/index.js +0 -1069
- package/dist/policies/index.js +0 -196
- package/dist/presets/index.js +0 -384
- package/dist/presets/multiTenant.d.ts +0 -39
- package/dist/presets/multiTenant.js +0 -112
- package/dist/registry/index.d.ts +0 -16
- package/dist/registry/index.js +0 -253
- package/dist/testing/index.d.ts +0 -618
- package/dist/testing/index.js +0 -48020
- package/dist/types/index.d.ts +0 -4
- package/dist/types/index.js +0 -8
- package/dist/types-B99TBmFV.d.ts +0 -76
- package/dist/types-BvckRbs2.d.ts +0 -143
- package/dist/utils/index.d.ts +0 -679
- package/dist/utils/index.js +0 -931
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { FastifyPluginAsync, FastifyRequest } from "fastify";
|
|
2
|
+
|
|
3
|
+
//#region src/plugins/response-cache.d.ts
|
|
4
|
+
interface ResponseCacheRule {
|
|
5
|
+
/** Path prefix to match (e.g., '/api/products') */
|
|
6
|
+
match: string;
|
|
7
|
+
/** TTL in seconds for this path (0 = don't cache) */
|
|
8
|
+
ttl: number;
|
|
9
|
+
}
|
|
10
|
+
interface ResponseCacheOptions {
|
|
11
|
+
/** Maximum number of cached entries (default: 500). LRU eviction when exceeded. */
|
|
12
|
+
maxEntries?: number;
|
|
13
|
+
/** Default TTL in seconds (default: 30). Set to 0 to require explicit rules. */
|
|
14
|
+
defaultTTL?: number;
|
|
15
|
+
/** Per-path cache rules */
|
|
16
|
+
rules?: ResponseCacheRule[];
|
|
17
|
+
/** Paths to exclude from caching (prefix match) */
|
|
18
|
+
exclude?: string[];
|
|
19
|
+
/** HTTP methods that trigger cache invalidation (default: POST, PUT, PATCH, DELETE) */
|
|
20
|
+
invalidateOn?: string[];
|
|
21
|
+
/** Whether to add X-Cache header (HIT/MISS) to responses (default: true) */
|
|
22
|
+
xCacheHeader?: boolean;
|
|
23
|
+
/** Enable stats endpoint at this path (default: null = disabled) */
|
|
24
|
+
statsPath?: string | null;
|
|
25
|
+
/** Custom cache key function (default: method + url + userId + orgId) */
|
|
26
|
+
keyFn?: (request: FastifyRequest) => string | null;
|
|
27
|
+
/**
|
|
28
|
+
* Auto-invalidate cache entries when CRUD domain events fire (requires eventPlugin).
|
|
29
|
+
*
|
|
30
|
+
* - `true`: Invalidate resource prefix on its own CRUD events
|
|
31
|
+
* - `{ patterns: { 'order.*': ['/api/products'] } }`: Cross-resource invalidation rules
|
|
32
|
+
* - `false` / omitted: Disabled (default)
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* ```typescript
|
|
36
|
+
* await fastify.register(responseCachePlugin, {
|
|
37
|
+
* eventInvalidation: {
|
|
38
|
+
* patterns: {
|
|
39
|
+
* 'order.*': ['/api/products', '/api/inventory'],
|
|
40
|
+
* },
|
|
41
|
+
* },
|
|
42
|
+
* });
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
eventInvalidation?: boolean | {
|
|
46
|
+
patterns?: Record<string, string[]>;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
interface ResponseCacheStats {
|
|
50
|
+
entries: number;
|
|
51
|
+
maxEntries: number;
|
|
52
|
+
hits: number;
|
|
53
|
+
misses: number;
|
|
54
|
+
hitRate: number;
|
|
55
|
+
evictions: number;
|
|
56
|
+
}
|
|
57
|
+
declare module "fastify" {
|
|
58
|
+
interface FastifyInstance {
|
|
59
|
+
responseCache: {
|
|
60
|
+
/** Invalidate all cached responses matching a path prefix */invalidate: (pathPrefix: string) => number; /** Clear the entire cache */
|
|
61
|
+
invalidateAll: () => void; /** Get cache statistics */
|
|
62
|
+
stats: () => ResponseCacheStats;
|
|
63
|
+
/**
|
|
64
|
+
* Route-level preHandler for cache lookup.
|
|
65
|
+
* Wire AFTER authenticate in the preHandler chain so that
|
|
66
|
+
* `request.user` / `request.scope` are populated before the
|
|
67
|
+
* cache key is computed.
|
|
68
|
+
*
|
|
69
|
+
* `createCrudRouter` injects this automatically for GET routes.
|
|
70
|
+
* For custom routes, add it manually:
|
|
71
|
+
* ```typescript
|
|
72
|
+
* fastify.get('/data', {
|
|
73
|
+
* preHandler: [fastify.authenticate, fastify.responseCache.middleware],
|
|
74
|
+
* }, handler);
|
|
75
|
+
* ```
|
|
76
|
+
*/
|
|
77
|
+
middleware: (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
interface FastifyRequest {
|
|
81
|
+
/** @internal Cache TTL in seconds — set by onRequest, consumed by middleware + onSend */
|
|
82
|
+
__arcCacheTTL?: number;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
declare const responseCachePlugin: FastifyPluginAsync<ResponseCacheOptions>;
|
|
86
|
+
//#endregion
|
|
87
|
+
export { ResponseCacheOptions, ResponseCacheRule, ResponseCacheStats, responseCachePlugin as default, responseCachePlugin };
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { t as hasEvents } from "../typeGuards-DwxA1t_L.mjs";
|
|
2
|
+
import fp from "fastify-plugin";
|
|
3
|
+
|
|
4
|
+
//#region src/plugins/response-cache.ts
|
|
5
|
+
/**
|
|
6
|
+
* Response Cache Plugin for Arc
|
|
7
|
+
*
|
|
8
|
+
* In-memory LRU/TTL response cache that sits in front of your database.
|
|
9
|
+
* Caches serialized responses for GET requests, dramatically reducing DB load
|
|
10
|
+
* for frequently accessed resources.
|
|
11
|
+
*
|
|
12
|
+
* Features:
|
|
13
|
+
* - LRU eviction with configurable max entries
|
|
14
|
+
* - Per-route TTL configuration
|
|
15
|
+
* - Automatic invalidation on mutations (POST/PUT/PATCH/DELETE)
|
|
16
|
+
* - Manual invalidation via `fastify.responseCache.invalidate()`
|
|
17
|
+
* - Cache stats endpoint for monitoring
|
|
18
|
+
* - Zero external deps — pure in-memory, serverless-safe
|
|
19
|
+
*
|
|
20
|
+
* NOTE: This cache is per-instance (in-memory). In multi-instance deployments,
|
|
21
|
+
* each instance maintains its own cache. For cross-instance invalidation,
|
|
22
|
+
* wire `fastify.responseCache.invalidate()` to your event bus manually.
|
|
23
|
+
*
|
|
24
|
+
* ## Auth Safety
|
|
25
|
+
*
|
|
26
|
+
* The cache check runs as a **route-level middleware** (`responseCache.middleware`)
|
|
27
|
+
* that must be wired AFTER authentication in the preHandler chain. Arc's
|
|
28
|
+
* `createCrudRouter` does this automatically. For custom routes, wire it
|
|
29
|
+
* manually:
|
|
30
|
+
*
|
|
31
|
+
* ```typescript
|
|
32
|
+
* fastify.get('/data', {
|
|
33
|
+
* preHandler: [fastify.authenticate, fastify.responseCache.middleware],
|
|
34
|
+
* }, handler);
|
|
35
|
+
* ```
|
|
36
|
+
*
|
|
37
|
+
* This ensures cached responses are never served before auth validates the
|
|
38
|
+
* caller's identity. The default cache key includes `userId` and `orgId`
|
|
39
|
+
* to prevent cross-caller data leaks.
|
|
40
|
+
*
|
|
41
|
+
* This is a SEPARATE subpath import — only loaded when explicitly used:
|
|
42
|
+
* import { responseCachePlugin } from '@classytic/arc/plugins/response-cache';
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* ```typescript
|
|
46
|
+
* import { responseCachePlugin } from '@classytic/arc/plugins/response-cache';
|
|
47
|
+
*
|
|
48
|
+
* await fastify.register(responseCachePlugin, {
|
|
49
|
+
* maxEntries: 1000,
|
|
50
|
+
* defaultTTL: 30, // 30 seconds
|
|
51
|
+
* rules: [
|
|
52
|
+
* { match: '/api/products', ttl: 120 }, // 2 min for products
|
|
53
|
+
* { match: '/api/categories', ttl: 300 }, // 5 min for categories
|
|
54
|
+
* { match: '/api/users', ttl: 0 }, // never cache users
|
|
55
|
+
* ],
|
|
56
|
+
* invalidateOn: ['POST', 'PUT', 'PATCH', 'DELETE'],
|
|
57
|
+
* });
|
|
58
|
+
*
|
|
59
|
+
* // Manual invalidation
|
|
60
|
+
* fastify.responseCache.invalidate('/api/products');
|
|
61
|
+
* fastify.responseCache.invalidateAll();
|
|
62
|
+
*
|
|
63
|
+
* // Stats
|
|
64
|
+
* const stats = fastify.responseCache.stats();
|
|
65
|
+
* // { entries: 42, hits: 1250, misses: 180, hitRate: 0.87, evictions: 5 }
|
|
66
|
+
* ```
|
|
67
|
+
*/
|
|
68
|
+
/**
|
|
69
|
+
* Simple LRU cache using Map iteration order.
|
|
70
|
+
* Map in JS preserves insertion order — we re-insert on access to make it LRU.
|
|
71
|
+
*/
|
|
72
|
+
var LRUCache = class {
|
|
73
|
+
cache = /* @__PURE__ */ new Map();
|
|
74
|
+
maxEntries;
|
|
75
|
+
invalidatedPrefixes = /* @__PURE__ */ new Map();
|
|
76
|
+
hits = 0;
|
|
77
|
+
misses = 0;
|
|
78
|
+
evictions = 0;
|
|
79
|
+
constructor(maxEntries) {
|
|
80
|
+
this.maxEntries = maxEntries;
|
|
81
|
+
}
|
|
82
|
+
get(key) {
|
|
83
|
+
const entry = this.cache.get(key);
|
|
84
|
+
if (!entry) {
|
|
85
|
+
this.misses++;
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
if (Date.now() - entry.createdAt > entry.ttl) {
|
|
89
|
+
this.cache.delete(key);
|
|
90
|
+
this.misses++;
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
this.cache.delete(key);
|
|
94
|
+
this.cache.set(key, entry);
|
|
95
|
+
this.hits++;
|
|
96
|
+
return entry;
|
|
97
|
+
}
|
|
98
|
+
set(key, entry) {
|
|
99
|
+
if (this.isPrefixLocked(key)) return;
|
|
100
|
+
if (this.cache.has(key)) this.cache.delete(key);
|
|
101
|
+
while (this.cache.size >= this.maxEntries) {
|
|
102
|
+
const firstKey = this.cache.keys().next().value;
|
|
103
|
+
if (firstKey !== void 0) {
|
|
104
|
+
this.cache.delete(firstKey);
|
|
105
|
+
this.evictions++;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
this.cache.set(key, entry);
|
|
109
|
+
}
|
|
110
|
+
/** Invalidate entries matching a path prefix and lock it from caching to allow DB replicas to catch up */
|
|
111
|
+
invalidatePrefix(prefix, jitterMs = 1500) {
|
|
112
|
+
let count = 0;
|
|
113
|
+
for (const key of this.cache.keys()) {
|
|
114
|
+
const colonIdx = key.indexOf(":");
|
|
115
|
+
if ((colonIdx >= 0 ? key.slice(colonIdx + 1) : key).split("?")[0].startsWith(prefix)) {
|
|
116
|
+
this.cache.delete(key);
|
|
117
|
+
count++;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (jitterMs > 0) this.invalidatedPrefixes.set(prefix, Date.now() + jitterMs);
|
|
121
|
+
return count;
|
|
122
|
+
}
|
|
123
|
+
/** Check if a key falls under a recently invalidated prefix */
|
|
124
|
+
isPrefixLocked(key) {
|
|
125
|
+
if (this.invalidatedPrefixes.size === 0) return false;
|
|
126
|
+
const colonIdx = key.indexOf(":");
|
|
127
|
+
const pathOnly = (colonIdx >= 0 ? key.slice(colonIdx + 1) : key).split("?")[0];
|
|
128
|
+
const now = Date.now();
|
|
129
|
+
for (const [prefix, expiresAt] of this.invalidatedPrefixes.entries()) if (now > expiresAt) this.invalidatedPrefixes.delete(prefix);
|
|
130
|
+
else if (pathOnly.startsWith(prefix)) return true;
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
/** Clear all entries */
|
|
134
|
+
clear() {
|
|
135
|
+
this.cache.clear();
|
|
136
|
+
}
|
|
137
|
+
get size() {
|
|
138
|
+
return this.cache.size;
|
|
139
|
+
}
|
|
140
|
+
getStats(maxEntries) {
|
|
141
|
+
const total = this.hits + this.misses;
|
|
142
|
+
return {
|
|
143
|
+
entries: this.cache.size,
|
|
144
|
+
maxEntries,
|
|
145
|
+
hits: this.hits,
|
|
146
|
+
misses: this.misses,
|
|
147
|
+
hitRate: total > 0 ? Math.round(this.hits / total * 100) / 100 : 0,
|
|
148
|
+
evictions: this.evictions
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
const responseCachePluginImpl = async (fastify, opts = {}) => {
|
|
153
|
+
const { maxEntries = 500, defaultTTL = 30, rules = [], exclude = [], invalidateOn = [
|
|
154
|
+
"POST",
|
|
155
|
+
"PUT",
|
|
156
|
+
"PATCH",
|
|
157
|
+
"DELETE"
|
|
158
|
+
], xCacheHeader = true, statsPath = null, keyFn } = opts;
|
|
159
|
+
const cache = new LRUCache(maxEntries);
|
|
160
|
+
const invalidateMethods = new Set(invalidateOn.map((m) => m.toUpperCase()));
|
|
161
|
+
/** Find TTL for a given URL path (seconds) */
|
|
162
|
+
function getTTL(url) {
|
|
163
|
+
const path = url.split("?")[0];
|
|
164
|
+
for (const rule of rules) if (path.startsWith(rule.match)) return rule.ttl;
|
|
165
|
+
return defaultTTL;
|
|
166
|
+
}
|
|
167
|
+
/** Check if a URL should be excluded */
|
|
168
|
+
function isExcluded(url) {
|
|
169
|
+
return exclude.some((p) => url.startsWith(p));
|
|
170
|
+
}
|
|
171
|
+
/** Build cache key — includes user/org scope by default to prevent cross-caller leaks */
|
|
172
|
+
function buildKey(request) {
|
|
173
|
+
if (keyFn) return keyFn(request);
|
|
174
|
+
const user = request.user;
|
|
175
|
+
const userId = user?.id ?? user?._id ?? "anon";
|
|
176
|
+
const orgId = request.scope?.organizationId ?? "no-org";
|
|
177
|
+
return `${request.method}:${request.url}:u=${userId}:o=${orgId}`;
|
|
178
|
+
}
|
|
179
|
+
fastify.addHook("onRequest", async (request) => {
|
|
180
|
+
if (request.method !== "GET" && request.method !== "HEAD") return;
|
|
181
|
+
if (isExcluded(request.url)) return;
|
|
182
|
+
const ttl = getTTL(request.url);
|
|
183
|
+
if (ttl <= 0) return;
|
|
184
|
+
request.__arcCacheTTL = ttl;
|
|
185
|
+
});
|
|
186
|
+
fastify.addHook("onResponse", async (request, reply) => {
|
|
187
|
+
if (!invalidateMethods.has(request.method.toUpperCase())) return;
|
|
188
|
+
const statusCode = reply.statusCode;
|
|
189
|
+
if (statusCode < 200 || statusCode >= 300) return;
|
|
190
|
+
const path = request.url.split("?")[0];
|
|
191
|
+
const segments = path.split("/").filter(Boolean);
|
|
192
|
+
const lastSegment = segments[segments.length - 1];
|
|
193
|
+
if (segments.length >= 2 && lastSegment != null && /^[0-9a-f]{8,}$|^\d+$/.test(lastSegment)) {
|
|
194
|
+
const resourceRoot = "/" + segments.slice(0, -1).join("/");
|
|
195
|
+
cache.invalidatePrefix(resourceRoot);
|
|
196
|
+
cache.invalidatePrefix(path);
|
|
197
|
+
} else cache.invalidatePrefix(path);
|
|
198
|
+
});
|
|
199
|
+
const cacheMiddleware = async (request, reply) => {
|
|
200
|
+
const ttl = request.__arcCacheTTL;
|
|
201
|
+
if (!ttl || ttl <= 0) return;
|
|
202
|
+
if (request.method !== "GET" && request.method !== "HEAD") return;
|
|
203
|
+
const key = buildKey(request);
|
|
204
|
+
if (!key) return;
|
|
205
|
+
const entry = cache.get(key);
|
|
206
|
+
if (!entry) return;
|
|
207
|
+
if (xCacheHeader) reply.header("x-cache", "HIT");
|
|
208
|
+
for (const [name, value] of Object.entries(entry.headers)) reply.header(name, value);
|
|
209
|
+
request.__arcCacheTTL = 0;
|
|
210
|
+
reply.code(entry.statusCode).send(entry.body);
|
|
211
|
+
};
|
|
212
|
+
fastify.addHook("onSend", async (request, reply, payload) => {
|
|
213
|
+
const ttl = request.__arcCacheTTL;
|
|
214
|
+
if (!ttl || ttl <= 0) return payload;
|
|
215
|
+
if (request.method !== "GET" && request.method !== "HEAD") return payload;
|
|
216
|
+
const statusCode = reply.statusCode;
|
|
217
|
+
if (statusCode < 200 || statusCode >= 300) return payload;
|
|
218
|
+
const key = buildKey(request);
|
|
219
|
+
if (!key) return payload;
|
|
220
|
+
if (xCacheHeader) reply.header("x-cache", "MISS");
|
|
221
|
+
let body;
|
|
222
|
+
if (typeof payload === "string") body = payload;
|
|
223
|
+
else if (Buffer.isBuffer(payload)) body = payload.toString("utf-8");
|
|
224
|
+
else if (payload != null) body = JSON.stringify(payload);
|
|
225
|
+
else body = "";
|
|
226
|
+
const headers = {};
|
|
227
|
+
const contentType = reply.getHeader("content-type");
|
|
228
|
+
if (contentType) headers["content-type"] = String(contentType);
|
|
229
|
+
const etag = reply.getHeader("etag");
|
|
230
|
+
if (etag) headers["etag"] = String(etag);
|
|
231
|
+
cache.set(key, {
|
|
232
|
+
body,
|
|
233
|
+
statusCode,
|
|
234
|
+
headers,
|
|
235
|
+
createdAt: Date.now(),
|
|
236
|
+
ttl: ttl * 1e3
|
|
237
|
+
});
|
|
238
|
+
return payload;
|
|
239
|
+
});
|
|
240
|
+
fastify.decorate("responseCache", {
|
|
241
|
+
invalidate: (pathPrefix) => cache.invalidatePrefix(pathPrefix),
|
|
242
|
+
invalidateAll: () => cache.clear(),
|
|
243
|
+
stats: () => cache.getStats(maxEntries),
|
|
244
|
+
middleware: cacheMiddleware
|
|
245
|
+
});
|
|
246
|
+
if (statsPath) fastify.get(statsPath, async () => {
|
|
247
|
+
return cache.getStats(maxEntries);
|
|
248
|
+
});
|
|
249
|
+
const evtInv = opts.eventInvalidation;
|
|
250
|
+
if (evtInv && hasEvents(fastify)) {
|
|
251
|
+
const crossResourcePatterns = typeof evtInv === "object" ? evtInv.patterns ?? {} : {};
|
|
252
|
+
fastify.events.subscribe("*", async (event) => {
|
|
253
|
+
const parts = event.type.split(".");
|
|
254
|
+
if (parts.length !== 2) return;
|
|
255
|
+
const [resource, action] = parts;
|
|
256
|
+
if (!resource || ![
|
|
257
|
+
"created",
|
|
258
|
+
"updated",
|
|
259
|
+
"deleted"
|
|
260
|
+
].includes(action)) return;
|
|
261
|
+
cache.invalidatePrefix(`/${resource}s`);
|
|
262
|
+
cache.invalidatePrefix(`/${resource}`);
|
|
263
|
+
for (const [pattern, prefixes] of Object.entries(crossResourcePatterns)) if (eventMatchesPattern(event.type, pattern)) for (const prefix of prefixes) cache.invalidatePrefix(prefix);
|
|
264
|
+
}).catch((err) => {
|
|
265
|
+
fastify.log?.warn?.({ err }, "Response cache: failed to subscribe to events for invalidation");
|
|
266
|
+
});
|
|
267
|
+
fastify.log?.debug?.("Response cache: event-driven invalidation enabled");
|
|
268
|
+
} else if (evtInv) fastify.log?.warn?.("Response cache: eventInvalidation enabled but eventPlugin not registered.");
|
|
269
|
+
fastify.log?.debug?.(`Response cache: registered (max=${maxEntries}, defaultTTL=${defaultTTL}s, rules=${rules.length})`);
|
|
270
|
+
};
|
|
271
|
+
/** Check if an event type matches a pattern (supports wildcards) */
|
|
272
|
+
function eventMatchesPattern(type, pattern) {
|
|
273
|
+
if (pattern === "*") return true;
|
|
274
|
+
if (pattern.endsWith(".*")) return type.startsWith(pattern.slice(0, -1));
|
|
275
|
+
return type === pattern;
|
|
276
|
+
}
|
|
277
|
+
const responseCachePlugin = fp(responseCachePluginImpl, {
|
|
278
|
+
name: "arc-response-cache",
|
|
279
|
+
fastify: "5.x"
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
//#endregion
|
|
283
|
+
export { responseCachePlugin as default, responseCachePlugin };
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import fp from "fastify-plugin";
|
|
3
|
+
|
|
4
|
+
//#region src/plugins/tracing.ts
|
|
5
|
+
const require = createRequire(import.meta.url);
|
|
6
|
+
let trace;
|
|
7
|
+
let context;
|
|
8
|
+
let SpanStatusCode;
|
|
9
|
+
let NodeTracerProvider;
|
|
10
|
+
let BatchSpanProcessor;
|
|
11
|
+
let OTLPTraceExporter;
|
|
12
|
+
let getNodeAutoInstrumentations;
|
|
13
|
+
let isAvailable = false;
|
|
14
|
+
try {
|
|
15
|
+
const api = require("@opentelemetry/api");
|
|
16
|
+
trace = api.trace;
|
|
17
|
+
context = api.context;
|
|
18
|
+
SpanStatusCode = api.SpanStatusCode;
|
|
19
|
+
const sdkNode = require("@opentelemetry/sdk-node");
|
|
20
|
+
NodeTracerProvider = sdkNode.NodeTracerProvider;
|
|
21
|
+
BatchSpanProcessor = sdkNode.BatchSpanProcessor;
|
|
22
|
+
OTLPTraceExporter = require("@opentelemetry/exporter-trace-otlp-http").OTLPTraceExporter;
|
|
23
|
+
require("@opentelemetry/instrumentation-http").HttpInstrumentation;
|
|
24
|
+
require("@opentelemetry/instrumentation-mongodb").MongoDBInstrumentation;
|
|
25
|
+
getNodeAutoInstrumentations = require("@opentelemetry/auto-instrumentations-node").getNodeAutoInstrumentations;
|
|
26
|
+
isAvailable = true;
|
|
27
|
+
} catch (e) {}
|
|
28
|
+
/**
|
|
29
|
+
* Create a tracer provider
|
|
30
|
+
*/
|
|
31
|
+
function createTracerProvider(options) {
|
|
32
|
+
if (!isAvailable) return null;
|
|
33
|
+
const { serviceName = "@classytic/arc", exporterUrl = "http://localhost:4318/v1/traces" } = options;
|
|
34
|
+
const exporter = new OTLPTraceExporter({ url: exporterUrl });
|
|
35
|
+
const provider = new NodeTracerProvider({ resource: { attributes: {
|
|
36
|
+
"service.name": serviceName,
|
|
37
|
+
"service.version": "1.0.0"
|
|
38
|
+
} } });
|
|
39
|
+
provider.addSpanProcessor(new BatchSpanProcessor(exporter));
|
|
40
|
+
provider.register();
|
|
41
|
+
return provider;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* OpenTelemetry Distributed Tracing Plugin
|
|
45
|
+
*/
|
|
46
|
+
async function tracingPlugin(fastify, options = {}) {
|
|
47
|
+
const { serviceName = "@classytic/arc", autoInstrumentation = true, sampleRate = 1 } = options;
|
|
48
|
+
if (!isAvailable) {
|
|
49
|
+
fastify.log.warn("OpenTelemetry not installed. Tracing disabled.");
|
|
50
|
+
fastify.log.warn("Install: npm install @opentelemetry/api @opentelemetry/sdk-node");
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (!createTracerProvider(options)) return;
|
|
54
|
+
if (autoInstrumentation && getNodeAutoInstrumentations) {
|
|
55
|
+
const instrumentations = getNodeAutoInstrumentations({
|
|
56
|
+
"@opentelemetry/instrumentation-http": { enabled: true },
|
|
57
|
+
"@opentelemetry/instrumentation-mongodb": { enabled: true }
|
|
58
|
+
});
|
|
59
|
+
for (const instrumentation of instrumentations) instrumentation.enable();
|
|
60
|
+
fastify.log.debug("OpenTelemetry auto-instrumentation enabled");
|
|
61
|
+
}
|
|
62
|
+
const tracer = trace.getTracer(serviceName);
|
|
63
|
+
fastify.decorateRequest("tracer", void 0);
|
|
64
|
+
fastify.addHook("onRequest", async (request, reply) => {
|
|
65
|
+
if (Math.random() > sampleRate) return;
|
|
66
|
+
const span = tracer.startSpan(`HTTP ${request.method} ${request.url}`, {
|
|
67
|
+
kind: 1,
|
|
68
|
+
attributes: {
|
|
69
|
+
"http.method": request.method,
|
|
70
|
+
"http.url": request.url,
|
|
71
|
+
"http.target": request.routeOptions?.url ?? request.url,
|
|
72
|
+
"http.host": request.hostname,
|
|
73
|
+
"http.scheme": request.protocol,
|
|
74
|
+
"http.user_agent": request.headers["user-agent"]
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
request.tracer = {
|
|
78
|
+
tracer,
|
|
79
|
+
currentSpan: span
|
|
80
|
+
};
|
|
81
|
+
context.with(trace.setSpan(context.active(), span), () => {});
|
|
82
|
+
});
|
|
83
|
+
fastify.addHook("onResponse", async (request, reply) => {
|
|
84
|
+
if (!request.tracer?.currentSpan) return;
|
|
85
|
+
const span = request.tracer.currentSpan;
|
|
86
|
+
span.setAttributes({
|
|
87
|
+
"http.status_code": reply.statusCode,
|
|
88
|
+
"http.response_content_length": reply.getHeader("content-length")
|
|
89
|
+
});
|
|
90
|
+
if (reply.statusCode >= 500) span.setStatus({
|
|
91
|
+
code: SpanStatusCode.ERROR,
|
|
92
|
+
message: `HTTP ${reply.statusCode}`
|
|
93
|
+
});
|
|
94
|
+
else span.setStatus({ code: SpanStatusCode.OK });
|
|
95
|
+
span.end();
|
|
96
|
+
});
|
|
97
|
+
fastify.addHook("onError", async (request, reply, error) => {
|
|
98
|
+
if (!request.tracer?.currentSpan) return;
|
|
99
|
+
const span = request.tracer.currentSpan;
|
|
100
|
+
span.recordException(error);
|
|
101
|
+
span.setStatus({
|
|
102
|
+
code: SpanStatusCode.ERROR,
|
|
103
|
+
message: error.message
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
fastify.log.debug({ serviceName }, "OpenTelemetry tracing enabled");
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Utility to create custom spans in your code
|
|
110
|
+
*
|
|
111
|
+
* @example
|
|
112
|
+
* import { createSpan } from '@classytic/arc/plugins';
|
|
113
|
+
*
|
|
114
|
+
* async function expensiveOperation(req) {
|
|
115
|
+
* return createSpan(req, 'expensiveOperation', async (span) => {
|
|
116
|
+
* span.setAttribute('custom.attribute', 'value');
|
|
117
|
+
* return await doWork();
|
|
118
|
+
* });
|
|
119
|
+
* }
|
|
120
|
+
*/
|
|
121
|
+
function createSpan(request, name, fn, attributes) {
|
|
122
|
+
if (!isAvailable || !request.tracer) return fn(null);
|
|
123
|
+
const { tracer, currentSpan } = request.tracer;
|
|
124
|
+
const span = tracer.startSpan(name, {
|
|
125
|
+
parent: currentSpan,
|
|
126
|
+
attributes: attributes || {}
|
|
127
|
+
}, trace.setSpan(context.active(), currentSpan));
|
|
128
|
+
return context.with(trace.setSpan(context.active(), span), async () => {
|
|
129
|
+
try {
|
|
130
|
+
const result = await fn(span);
|
|
131
|
+
span.setStatus({ code: SpanStatusCode.OK });
|
|
132
|
+
return result;
|
|
133
|
+
} catch (error) {
|
|
134
|
+
span.recordException(error);
|
|
135
|
+
span.setStatus({
|
|
136
|
+
code: SpanStatusCode.ERROR,
|
|
137
|
+
message: error.message
|
|
138
|
+
});
|
|
139
|
+
throw error;
|
|
140
|
+
} finally {
|
|
141
|
+
span.end();
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Decorator to automatically trace repository methods
|
|
147
|
+
*
|
|
148
|
+
* @example
|
|
149
|
+
* class ProductRepository extends Repository {
|
|
150
|
+
* @traced()
|
|
151
|
+
* async findActive() {
|
|
152
|
+
* return this.findAll({ filter: { isActive: true } });
|
|
153
|
+
* }
|
|
154
|
+
* }
|
|
155
|
+
*/
|
|
156
|
+
function traced(spanName) {
|
|
157
|
+
return function(target, propertyKey, descriptor) {
|
|
158
|
+
const originalMethod = descriptor.value;
|
|
159
|
+
descriptor.value = async function(...args) {
|
|
160
|
+
const request = args.find((arg) => arg && arg.tracer);
|
|
161
|
+
if (!request?.tracer) return originalMethod.apply(this, args);
|
|
162
|
+
return createSpan(request, spanName || `${target.constructor.name}.${propertyKey}`, async (span) => {
|
|
163
|
+
if (span) {
|
|
164
|
+
span.setAttribute("db.operation", propertyKey);
|
|
165
|
+
span.setAttribute("db.system", "mongodb");
|
|
166
|
+
}
|
|
167
|
+
return originalMethod.apply(this, args);
|
|
168
|
+
});
|
|
169
|
+
};
|
|
170
|
+
return descriptor;
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Check if OpenTelemetry is available
|
|
175
|
+
*/
|
|
176
|
+
function isTracingAvailable() {
|
|
177
|
+
return isAvailable;
|
|
178
|
+
}
|
|
179
|
+
var tracing_default = fp(tracingPlugin, {
|
|
180
|
+
name: "arc-tracing",
|
|
181
|
+
fastify: "5.x"
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
//#endregion
|
|
185
|
+
export { createSpan, isTracingAvailable, traced, tracing_default as tracingPlugin };
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
//#region src/cli/utils/pluralize.ts
|
|
2
|
+
/**
|
|
3
|
+
* Lightweight English pluralization for the Arc CLI.
|
|
4
|
+
*
|
|
5
|
+
* Covers the common cases developers hit when naming resources:
|
|
6
|
+
* company → companies
|
|
7
|
+
* category → categories
|
|
8
|
+
* status → statuses
|
|
9
|
+
* address → addresses
|
|
10
|
+
* person → people
|
|
11
|
+
* child → children
|
|
12
|
+
* bus → buses
|
|
13
|
+
* box → boxes
|
|
14
|
+
* quiz → quizzes
|
|
15
|
+
* leaf → leaves
|
|
16
|
+
* wolf → wolves
|
|
17
|
+
*
|
|
18
|
+
* No external dependencies — designed to keep the CLI install-free.
|
|
19
|
+
*/
|
|
20
|
+
const IRREGULARS = {
|
|
21
|
+
person: "people",
|
|
22
|
+
child: "children",
|
|
23
|
+
man: "men",
|
|
24
|
+
woman: "women",
|
|
25
|
+
mouse: "mice",
|
|
26
|
+
goose: "geese",
|
|
27
|
+
tooth: "teeth",
|
|
28
|
+
foot: "feet",
|
|
29
|
+
ox: "oxen",
|
|
30
|
+
datum: "data",
|
|
31
|
+
medium: "media",
|
|
32
|
+
index: "indices",
|
|
33
|
+
matrix: "matrices",
|
|
34
|
+
vertex: "vertices",
|
|
35
|
+
criterion: "criteria"
|
|
36
|
+
};
|
|
37
|
+
const UNCOUNTABLES = new Set([
|
|
38
|
+
"sheep",
|
|
39
|
+
"fish",
|
|
40
|
+
"deer",
|
|
41
|
+
"series",
|
|
42
|
+
"species",
|
|
43
|
+
"money",
|
|
44
|
+
"rice",
|
|
45
|
+
"information",
|
|
46
|
+
"equipment",
|
|
47
|
+
"media",
|
|
48
|
+
"data"
|
|
49
|
+
]);
|
|
50
|
+
/**
|
|
51
|
+
* Pluralize an English word.
|
|
52
|
+
*
|
|
53
|
+
* @param word - Singular noun (e.g. "company", "product", "person")
|
|
54
|
+
* @returns Plural form (e.g. "companies", "products", "people")
|
|
55
|
+
*/
|
|
56
|
+
function pluralize(word) {
|
|
57
|
+
const lower = word.toLowerCase();
|
|
58
|
+
if (UNCOUNTABLES.has(lower)) return word;
|
|
59
|
+
if (IRREGULARS[lower]) {
|
|
60
|
+
const plural = IRREGULARS[lower];
|
|
61
|
+
return word[0] === word[0].toUpperCase() ? plural.charAt(0).toUpperCase() + plural.slice(1) : plural;
|
|
62
|
+
}
|
|
63
|
+
if (lower.endsWith("fe")) return word.slice(0, -2) + "ves";
|
|
64
|
+
if (lower.endsWith("f") && !lower.endsWith("ff") && !lower.endsWith("roof") && !lower.endsWith("chief") && !lower.endsWith("belief")) return word.slice(0, -1) + "ves";
|
|
65
|
+
if (lower.endsWith("y") && !/[aeiou]y$/i.test(lower)) return word.slice(0, -1) + "ies";
|
|
66
|
+
if (lower.endsWith("is")) return word.slice(0, -2) + "es";
|
|
67
|
+
if (new Set([
|
|
68
|
+
"cactus",
|
|
69
|
+
"stimulus",
|
|
70
|
+
"focus",
|
|
71
|
+
"fungus",
|
|
72
|
+
"nucleus",
|
|
73
|
+
"syllabus",
|
|
74
|
+
"radius",
|
|
75
|
+
"alumnus",
|
|
76
|
+
"terminus",
|
|
77
|
+
"bacillus"
|
|
78
|
+
]).has(lower)) return word.slice(0, -2) + "i";
|
|
79
|
+
if (lower.endsWith("z") && !lower.endsWith("zz")) return word + "zes";
|
|
80
|
+
if (/(?:s|sh|ch|x|zz)$/i.test(lower)) return word + "es";
|
|
81
|
+
if (lower.endsWith("o") && !/[aeiou]o$/i.test(lower)) return word + "es";
|
|
82
|
+
return word + "s";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
//#endregion
|
|
86
|
+
export { pluralize as t };
|