@classytic/repo-core 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +363 -0
- package/README.md +28 -7
- package/dist/adapter/index.d.mts +3 -0
- package/dist/adapter/index.mjs +2 -0
- package/dist/adapter/types.d.mts +222 -0
- package/dist/adapter/widen.d.mts +22 -0
- package/dist/adapter/widen.mjs +26 -0
- package/dist/aggregate/index.d.mts +3 -0
- package/dist/aggregate/index.mjs +3 -0
- package/dist/aggregate/keyset.d.mts +57 -0
- package/dist/aggregate/keyset.mjs +45 -0
- package/dist/aggregate/normalize.d.mts +24 -0
- package/dist/aggregate/normalize.mjs +28 -0
- package/dist/better-auth/index.d.mts +110 -0
- package/dist/better-auth/index.mjs +71 -0
- package/dist/cache/engine.d.mts +127 -0
- package/dist/cache/engine.mjs +235 -0
- package/dist/cache/envelope.mjs +32 -0
- package/dist/cache/index.d.mts +7 -2
- package/dist/cache/index.mjs +6 -2
- package/dist/cache/keys.mjs +131 -0
- package/dist/cache/memory-adapter.mjs +41 -7
- package/dist/cache/options.d.mts +112 -0
- package/dist/cache/options.mjs +25 -0
- package/dist/cache/plugin/context.d.mts +18 -0
- package/dist/cache/plugin/context.mjs +121 -0
- package/dist/cache/plugin/index.d.mts +86 -0
- package/dist/cache/plugin/index.mjs +78 -0
- package/dist/cache/plugin/invalidation-hooks.mjs +35 -0
- package/dist/cache/plugin/read-hooks.mjs +96 -0
- package/dist/cache/plugin/swr.mjs +20 -0
- package/dist/cache/runtime.d.mts +43 -0
- package/dist/cache/runtime.mjs +14 -0
- package/dist/cache/tag-index.mjs +84 -0
- package/dist/cache/timeout-adapter.d.mts +30 -0
- package/dist/cache/timeout-adapter.mjs +58 -0
- package/dist/cache/types.d.mts +45 -0
- package/dist/cache/version-store.mjs +57 -0
- package/dist/errors/contract.d.mts +37 -0
- package/dist/errors/contract.mjs +75 -0
- package/dist/errors/index.d.mts +4 -2
- package/dist/errors/index.mjs +4 -1
- package/dist/errors/schema.d.mts +101 -0
- package/dist/errors/schema.mjs +78 -0
- package/dist/errors/types.d.mts +113 -8
- package/dist/errors/types.mjs +29 -0
- package/dist/filter/match.mjs +38 -2
- package/dist/pagination/canonical.d.mts +35 -0
- package/dist/pagination/canonical.mjs +26 -0
- package/dist/pagination/cursor.mjs +4 -1
- package/dist/pagination/index.d.mts +3 -2
- package/dist/pagination/index.mjs +2 -1
- package/dist/pagination/types.d.mts +57 -3
- package/dist/plugins/index.d.mts +2 -0
- package/dist/plugins/index.mjs +2 -0
- package/dist/plugins/tenant-helpers.d.mts +63 -0
- package/dist/plugins/tenant-helpers.mjs +84 -0
- package/dist/query-parser/index.d.mts +2 -1
- package/dist/query-parser/index.mjs +2 -1
- package/dist/query-parser/parse-url.mjs +13 -11
- package/dist/query-parser/reserved.d.mts +43 -0
- package/dist/query-parser/reserved.mjs +56 -0
- package/dist/repository/agg-output.d.mts +63 -0
- package/dist/repository/agg-output.mjs +89 -0
- package/dist/repository/base.mjs +21 -0
- package/dist/repository/index.d.mts +4 -2
- package/dist/repository/index.mjs +3 -1
- package/dist/repository/options.d.mts +62 -0
- package/dist/repository/options.mjs +57 -0
- package/dist/repository/types.d.mts +935 -48
- package/dist/schema/field-rules.d.mts +60 -9
- package/dist/schema/field-rules.mjs +121 -10
- package/dist/schema/generator.d.mts +72 -0
- package/dist/schema/generator.mjs +16 -0
- package/dist/schema/index.d.mts +3 -2
- package/dist/schema/index.mjs +3 -2
- package/dist/schema/types.d.mts +77 -3
- package/dist/tenant/index.d.mts +3 -0
- package/dist/tenant/index.mjs +2 -0
- package/dist/tenant/resolve.d.mts +27 -0
- package/dist/tenant/resolve.mjs +69 -0
- package/dist/tenant/types.d.mts +142 -0
- package/dist/testing/conformance.mjs +666 -17
- package/dist/testing/index.d.mts +2 -2
- package/dist/testing/types.d.mts +99 -2
- package/package.json +27 -1
- package/dist/cache/stable-stringify.d.mts +0 -15
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
//#region src/cache/options.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Canonical per-call cache options. TanStack Query-shaped — the same
|
|
4
|
+
* `staleTime` / `gcTime` mental model browser developers already know,
|
|
5
|
+
* lifted to server-side multi-tenant safe.
|
|
6
|
+
*
|
|
7
|
+
* **Scope discipline.** This is the ONE shape used across every read
|
|
8
|
+
* method on every kit (`getById`, `getAll`, `aggregate`, `count`, ...).
|
|
9
|
+
* Replaces mongokit's `AggCacheOptions`, sqlitekit's per-op overrides,
|
|
10
|
+
* and arc's `QueryCacheConfig`. One mental model, one wire shape.
|
|
11
|
+
*
|
|
12
|
+
* **Freshness semantics:**
|
|
13
|
+
* - `0 ≤ age < staleTime` → fresh; serve cache, no refresh
|
|
14
|
+
* - `staleTime ≤ age < staleTime + gcTime`
|
|
15
|
+
* - `swr: true` → serve cache, kick off background refresh
|
|
16
|
+
* - `swr: false` → treat as miss, fetch fresh inline
|
|
17
|
+
* - `age ≥ staleTime + gcTime` → expired; entry evicted
|
|
18
|
+
*
|
|
19
|
+
* Defaults match TanStack v5 / Next.js App Router idioms — short
|
|
20
|
+
* `staleTime` (data is stale immediately, refetch on next request),
|
|
21
|
+
* generous `gcTime` (keep entries around so the next request can serve
|
|
22
|
+
* stale-while-revalidate).
|
|
23
|
+
*/
|
|
24
|
+
interface CacheOptions {
|
|
25
|
+
/**
|
|
26
|
+
* Seconds the entry is considered fresh. `0` (default) means "always
|
|
27
|
+
* stale" — the entry serves via SWR if `swr: true`, otherwise every
|
|
28
|
+
* call re-fetches.
|
|
29
|
+
*/
|
|
30
|
+
staleTime?: number;
|
|
31
|
+
/**
|
|
32
|
+
* Seconds the entry stays in cache after it goes stale. Past this
|
|
33
|
+
* point the entry is evicted entirely. Default: `60`.
|
|
34
|
+
*/
|
|
35
|
+
gcTime?: number;
|
|
36
|
+
/**
|
|
37
|
+
* Group invalidation tags. Pass to `repo.invalidateCache(tags)` to
|
|
38
|
+
* wipe every entry tagged with any of the matching tags. Convention:
|
|
39
|
+
* namespace by domain — `'orders'`, `'org:abc123'`, `'user:42'`.
|
|
40
|
+
*
|
|
41
|
+
* The plugin auto-injects scope tags (`org:<id>` / `user:<id>`) when
|
|
42
|
+
* `autoTagsFromScope: true` (default), so hosts only declare
|
|
43
|
+
* domain-level tags.
|
|
44
|
+
*/
|
|
45
|
+
tags?: readonly string[];
|
|
46
|
+
/**
|
|
47
|
+
* Force a fresh fetch + cache write. Use for "Refresh" buttons or
|
|
48
|
+
* explicit revalidation flows where the user expects up-to-the-second
|
|
49
|
+
* data. Bypasses both the read AND any in-flight SWR refresh — the
|
|
50
|
+
* cached entry is overwritten with the fresh result.
|
|
51
|
+
*/
|
|
52
|
+
bypass?: boolean;
|
|
53
|
+
/**
|
|
54
|
+
* Stale-while-revalidate. When `true`, stale entries serve
|
|
55
|
+
* immediately and a background refresh updates the cache for the
|
|
56
|
+
* next request. When `false` (default), stale entries trigger an
|
|
57
|
+
* inline refetch — caller waits for fresh data.
|
|
58
|
+
*
|
|
59
|
+
* **Recommended `true`** for high-traffic dashboards where
|
|
60
|
+
* cache-edge latency matters more than strict freshness.
|
|
61
|
+
*/
|
|
62
|
+
swr?: boolean;
|
|
63
|
+
/**
|
|
64
|
+
* Skip cache entirely for this call. Equivalent to TanStack Query's
|
|
65
|
+
* `enabled: false`. Default: `true` (cache active).
|
|
66
|
+
*
|
|
67
|
+
* Distinguished from `bypass`: `enabled: false` skips both read AND
|
|
68
|
+
* write, so a fresh fetch doesn't pollute the cache. `bypass: true`
|
|
69
|
+
* skips read but DOES write the fresh result.
|
|
70
|
+
*/
|
|
71
|
+
enabled?: boolean;
|
|
72
|
+
/**
|
|
73
|
+
* Override the auto-derived cache key. The default key includes
|
|
74
|
+
* model, op, version, params hash, and scope tags — sufficient for
|
|
75
|
+
* 99% of cases. Override only when you want explicit control
|
|
76
|
+
* (cross-call sharing, debugging).
|
|
77
|
+
*
|
|
78
|
+
* **Caller owns uniqueness.** Two calls passing the same `key` for
|
|
79
|
+
* different request shapes get the first writer's result.
|
|
80
|
+
*/
|
|
81
|
+
key?: string;
|
|
82
|
+
}
|
|
83
|
+
/** Per-call result envelope returned by `CacheEngine.get()`. */
|
|
84
|
+
interface CacheReadResult<TData = unknown> {
|
|
85
|
+
/**
|
|
86
|
+
* - `'fresh'` — within `staleTime`; cache is authoritative
|
|
87
|
+
* - `'stale'` — past `staleTime`, within gc window; serve + revalidate
|
|
88
|
+
* - `'miss'` — no entry, expired entry, or cache disabled
|
|
89
|
+
* - `'disabled'` — `enabled: false` for this call
|
|
90
|
+
* - `'bypass'` — `bypass: true` for this call
|
|
91
|
+
*/
|
|
92
|
+
readonly status: 'fresh' | 'stale' | 'miss' | 'disabled' | 'bypass';
|
|
93
|
+
/** Cached data when `status` is `'fresh'` or `'stale'`; otherwise `undefined`. */
|
|
94
|
+
readonly data: TData | undefined;
|
|
95
|
+
/** Age of the cached entry in seconds (only when status is fresh/stale). */
|
|
96
|
+
readonly age?: number;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Resolved cache options after merging plugin defaults + per-op
|
|
100
|
+
* defaults + per-call overrides. All fields populated; never undefined.
|
|
101
|
+
*/
|
|
102
|
+
interface ResolvedCacheOptions {
|
|
103
|
+
staleTime: number;
|
|
104
|
+
gcTime: number;
|
|
105
|
+
tags: readonly string[];
|
|
106
|
+
bypass: boolean;
|
|
107
|
+
swr: boolean;
|
|
108
|
+
enabled: boolean;
|
|
109
|
+
key?: string;
|
|
110
|
+
}
|
|
111
|
+
//#endregion
|
|
112
|
+
export { CacheOptions, CacheReadResult, ResolvedCacheOptions };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
//#region src/cache/options.ts
|
|
2
|
+
/**
|
|
3
|
+
* Merge precedence (highest wins): per-call > per-op-default > plugin-default > built-in.
|
|
4
|
+
*
|
|
5
|
+
* Built-in defaults: `staleTime=0`, `gcTime=60`, `swr=false`,
|
|
6
|
+
* `enabled=true`, `bypass=false`, `tags=[]`.
|
|
7
|
+
*/
|
|
8
|
+
function resolveCacheOptions(callOpts, perOpDefaults, pluginDefaults) {
|
|
9
|
+
const merged = {
|
|
10
|
+
...pluginDefaults,
|
|
11
|
+
...perOpDefaults,
|
|
12
|
+
...callOpts
|
|
13
|
+
};
|
|
14
|
+
return {
|
|
15
|
+
staleTime: Math.max(0, merged.staleTime ?? 0),
|
|
16
|
+
gcTime: Math.max(0, merged.gcTime ?? 60),
|
|
17
|
+
tags: merged.tags ?? [],
|
|
18
|
+
bypass: merged.bypass ?? false,
|
|
19
|
+
swr: merged.swr ?? false,
|
|
20
|
+
enabled: merged.enabled ?? true,
|
|
21
|
+
...merged.key !== void 0 ? { key: merged.key } : {}
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
//#endregion
|
|
25
|
+
export { resolveCacheOptions };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
//#region src/cache/plugin/context.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Default per-op allowlist of context fields that affect result shape
|
|
4
|
+
* (and therefore must participate in the cache key). Hosts can extend
|
|
5
|
+
* via `cachePlugin({ shapeKeysByOp })`.
|
|
6
|
+
*
|
|
7
|
+
* **Allowlist beats denylist** because:
|
|
8
|
+
* - New context fields don't silently slip into the cache key (which
|
|
9
|
+
* would explode the miss rate when ANY non-shape field varies
|
|
10
|
+
* per request — request IDs, trace headers, timestamps).
|
|
11
|
+
* - New ops require explicit registration (forces thinking about
|
|
12
|
+
* what affects shape).
|
|
13
|
+
* - Hosts can override per-kit when their kit accepts a non-standard
|
|
14
|
+
* option (e.g. mongoose's `lean`, prisma's `select` shape, etc.).
|
|
15
|
+
*/
|
|
16
|
+
declare const DEFAULT_SHAPE_KEYS_BY_OP: Readonly<Record<string, readonly string[]>>;
|
|
17
|
+
//#endregion
|
|
18
|
+
export { DEFAULT_SHAPE_KEYS_BY_OP };
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
//#region src/cache/plugin/context.ts
|
|
2
|
+
/**
|
|
3
|
+
* Cast a raw hook context into the typed slots view. One call per
|
|
4
|
+
* hook entry; subsequent slot reads/writes use plain dotted access.
|
|
5
|
+
*/
|
|
6
|
+
function ctx(raw) {
|
|
7
|
+
return raw;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Default per-op allowlist of context fields that affect result shape
|
|
11
|
+
* (and therefore must participate in the cache key). Hosts can extend
|
|
12
|
+
* via `cachePlugin({ shapeKeysByOp })`.
|
|
13
|
+
*
|
|
14
|
+
* **Allowlist beats denylist** because:
|
|
15
|
+
* - New context fields don't silently slip into the cache key (which
|
|
16
|
+
* would explode the miss rate when ANY non-shape field varies
|
|
17
|
+
* per request — request IDs, trace headers, timestamps).
|
|
18
|
+
* - New ops require explicit registration (forces thinking about
|
|
19
|
+
* what affects shape).
|
|
20
|
+
* - Hosts can override per-kit when their kit accepts a non-standard
|
|
21
|
+
* option (e.g. mongoose's `lean`, prisma's `select` shape, etc.).
|
|
22
|
+
*/
|
|
23
|
+
const DEFAULT_SHAPE_KEYS_BY_OP = Object.freeze({
|
|
24
|
+
getById: [
|
|
25
|
+
"id",
|
|
26
|
+
"lean",
|
|
27
|
+
"select",
|
|
28
|
+
"populate",
|
|
29
|
+
"populateOptions"
|
|
30
|
+
],
|
|
31
|
+
getOne: [
|
|
32
|
+
"filter",
|
|
33
|
+
"select",
|
|
34
|
+
"populate",
|
|
35
|
+
"populateOptions",
|
|
36
|
+
"lean"
|
|
37
|
+
],
|
|
38
|
+
getByQuery: [
|
|
39
|
+
"query",
|
|
40
|
+
"select",
|
|
41
|
+
"populate",
|
|
42
|
+
"populateOptions",
|
|
43
|
+
"lean"
|
|
44
|
+
],
|
|
45
|
+
getAll: [
|
|
46
|
+
"filter",
|
|
47
|
+
"filters",
|
|
48
|
+
"sort",
|
|
49
|
+
"select",
|
|
50
|
+
"populate",
|
|
51
|
+
"populateOptions",
|
|
52
|
+
"lean",
|
|
53
|
+
"page",
|
|
54
|
+
"limit",
|
|
55
|
+
"after"
|
|
56
|
+
],
|
|
57
|
+
count: ["filter", "filters"],
|
|
58
|
+
exists: ["filter", "filters"],
|
|
59
|
+
distinct: [
|
|
60
|
+
"filter",
|
|
61
|
+
"filters",
|
|
62
|
+
"field"
|
|
63
|
+
],
|
|
64
|
+
aggregate: ["aggRequest"],
|
|
65
|
+
aggregatePaginate: ["aggRequest"]
|
|
66
|
+
});
|
|
67
|
+
/**
|
|
68
|
+
* Pick the shape-affecting fields from a context for the given op.
|
|
69
|
+
* Returns `undefined` when no shape keys are registered for the op or
|
|
70
|
+
* none of them have values — the caller then keys solely on op + model.
|
|
71
|
+
*
|
|
72
|
+
* Aggregate ops get their `aggRequest` field auto-stripped of
|
|
73
|
+
* operational slots (`cache`, `executionHints`) before hashing —
|
|
74
|
+
* those don't affect the result, only HOW the call runs. Without
|
|
75
|
+
* stripping, `bypass: true` and `bypass: false` would produce
|
|
76
|
+
* different keys, defeating the "bypass overwrites cache" semantic.
|
|
77
|
+
*/
|
|
78
|
+
function extractShapeFields(context, op, shapeKeysByOp) {
|
|
79
|
+
const keys = shapeKeysByOp[op];
|
|
80
|
+
if (!keys || keys.length === 0) return void 0;
|
|
81
|
+
const isAggregate = AGGREGATE_OPS.has(op);
|
|
82
|
+
const out = {};
|
|
83
|
+
for (const k of keys) {
|
|
84
|
+
const v = context[k];
|
|
85
|
+
if (v === void 0) continue;
|
|
86
|
+
out[k] = isAggregate && k === "aggRequest" ? stripAggOperationalSlots(v) : v;
|
|
87
|
+
}
|
|
88
|
+
return Object.keys(out).length > 0 ? out : void 0;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Strip operational slots from an `AggRequest` before hashing.
|
|
92
|
+
* `cache` and `executionHints` describe HOW the call runs, not WHAT
|
|
93
|
+
* it computes — including them in the key would cause spurious
|
|
94
|
+
* misses on per-call config tweaks.
|
|
95
|
+
*/
|
|
96
|
+
function stripAggOperationalSlots(value) {
|
|
97
|
+
if (!value || typeof value !== "object") return value;
|
|
98
|
+
const { cache: _c, executionHints: _eh, ...rest } = value;
|
|
99
|
+
return rest;
|
|
100
|
+
}
|
|
101
|
+
/** Aggregate ops carry their cache slot inside the AggRequest IR. */
|
|
102
|
+
const AGGREGATE_OPS = new Set(["aggregate", "aggregatePaginate"]);
|
|
103
|
+
/**
|
|
104
|
+
* Extract per-call `CacheOptions` from a hook context. Three valid
|
|
105
|
+
* locations covered (kit-dependent):
|
|
106
|
+
* 1. Aggregate ops: `context.aggRequest.cache`
|
|
107
|
+
* 2. CRUD with merged options: `context.cache` (mongokit spreads)
|
|
108
|
+
* 3. CRUD with options bag: `context.options.cache` (sqlitekit-style)
|
|
109
|
+
*
|
|
110
|
+
* Returns `undefined` when no per-call options are set; caller falls
|
|
111
|
+
* back to plugin defaults / per-op defaults.
|
|
112
|
+
*/
|
|
113
|
+
function extractCallCacheOptions(context, op) {
|
|
114
|
+
if (AGGREGATE_OPS.has(op)) return context["aggRequest"]?.cache;
|
|
115
|
+
const direct = context["cache"];
|
|
116
|
+
if (direct && typeof direct === "object") return direct;
|
|
117
|
+
const inOptions = context["options"]?.["cache"];
|
|
118
|
+
if (inOptions && typeof inOptions === "object") return inOptions;
|
|
119
|
+
}
|
|
120
|
+
//#endregion
|
|
121
|
+
export { AGGREGATE_OPS, DEFAULT_SHAPE_KEYS_BY_OP, ctx, extractCallCacheOptions, extractShapeFields };
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { Plugin } from "../../repository/plugin-types.mjs";
|
|
2
|
+
import { CacheOptions, CacheReadResult } from "../options.mjs";
|
|
3
|
+
import { CacheAdapter } from "../types.mjs";
|
|
4
|
+
import { CacheEngine } from "../engine.mjs";
|
|
5
|
+
import { DEFAULT_SHAPE_KEYS_BY_OP } from "./context.mjs";
|
|
6
|
+
|
|
7
|
+
//#region src/cache/plugin/index.d.ts
|
|
8
|
+
/** Default read ops the plugin caches. Kits may override per resource. */
|
|
9
|
+
declare const DEFAULT_CACHEABLE_OPS: readonly ["getById", "getAll", "getOne", "getByQuery", "count", "exists", "distinct", "aggregate", "aggregatePaginate"];
|
|
10
|
+
/** Default mutating ops that bump version + invalidate tags on success. */
|
|
11
|
+
declare const DEFAULT_INVALIDATING_OPS: readonly ["create", "createMany", "update", "updateMany", "findOneAndUpdate", "upsert", "delete", "deleteMany", "restore", "claim", "claimVersion", "increment", "bulkWrite"];
|
|
12
|
+
/** Observability callbacks — pipe to metrics, traces, or stdout. */
|
|
13
|
+
interface LogCallbacks {
|
|
14
|
+
onHit?: (key: string, op: string, ageSeconds: number) => void;
|
|
15
|
+
onStale?: (key: string, op: string, ageSeconds: number) => void;
|
|
16
|
+
onMiss?: (key: string, op: string) => void;
|
|
17
|
+
/**
|
|
18
|
+
* Single-flight coalesce — the caller awaited an in-flight fetch
|
|
19
|
+
* for the same key instead of running its own executor. High
|
|
20
|
+
* coalesce rates indicate burst load on uncached entries — useful
|
|
21
|
+
* signal for capacity planning + cache-warming.
|
|
22
|
+
*/
|
|
23
|
+
onCoalesce?: (key: string, op: string) => void;
|
|
24
|
+
onWrite?: (key: string, op: string, tags: readonly string[]) => void;
|
|
25
|
+
onInvalidate?: (model: string, version: number, tagCount: number) => void;
|
|
26
|
+
}
|
|
27
|
+
interface RepositoryCachePluginOptions {
|
|
28
|
+
/** Concrete adapter — Redis, in-memory, custom KV. */
|
|
29
|
+
readonly adapter: CacheAdapter;
|
|
30
|
+
/** Read ops the plugin caches. Default: every op in `DEFAULT_CACHEABLE_OPS`. */
|
|
31
|
+
readonly enabled?: readonly string[];
|
|
32
|
+
/** Mutating ops that trigger invalidation. Default: every op in `DEFAULT_INVALIDATING_OPS`. */
|
|
33
|
+
readonly invalidating?: readonly string[];
|
|
34
|
+
/** Plugin-level defaults — applied to every call where the caller didn't override. */
|
|
35
|
+
readonly defaults?: Partial<CacheOptions>;
|
|
36
|
+
/** Per-op default overrides — `{ getById: { staleTime: 600 } }`. */
|
|
37
|
+
readonly perOpDefaults?: Record<string, Partial<CacheOptions>>;
|
|
38
|
+
/** Cache key namespace prefix. Default: `'rc'`. */
|
|
39
|
+
readonly prefix?: string;
|
|
40
|
+
/**
|
|
41
|
+
* Auto-inject scope tags (`org:<id>`, `user:<id>`) extracted from
|
|
42
|
+
* `context.filter`. Default: `true`. Disable when the host wires
|
|
43
|
+
* scope manually via the `tags` option per-call.
|
|
44
|
+
*/
|
|
45
|
+
readonly autoTagsFromScope?: boolean;
|
|
46
|
+
/** TTL jitter — symmetric fractional or custom function. Default: `0`. */
|
|
47
|
+
readonly jitter?: number | ((ttl: number) => number);
|
|
48
|
+
/**
|
|
49
|
+
* Per-op shape-key allowlist — overrides / extends defaults. Use
|
|
50
|
+
* to register kit-specific shape-affecting fields (e.g. mongoose
|
|
51
|
+
* `lean`, custom projection flags). Hosts merge with defaults via
|
|
52
|
+
* standard object spread.
|
|
53
|
+
*
|
|
54
|
+
* @example
|
|
55
|
+
* ```ts
|
|
56
|
+
* cachePlugin({
|
|
57
|
+
* adapter,
|
|
58
|
+
* shapeKeysByOp: {
|
|
59
|
+
* ...DEFAULT_SHAPE_KEYS_BY_OP,
|
|
60
|
+
* getById: ['id', 'lean', 'select', 'myCustomFlag'],
|
|
61
|
+
* },
|
|
62
|
+
* });
|
|
63
|
+
* ```
|
|
64
|
+
*/
|
|
65
|
+
readonly shapeKeysByOp?: Readonly<Record<string, readonly string[]>>;
|
|
66
|
+
/** Observability callbacks. Each fires per cache event. */
|
|
67
|
+
readonly log?: LogCallbacks;
|
|
68
|
+
}
|
|
69
|
+
/** Plugin-attached cache surface — exposed so kits can offer convenience methods. */
|
|
70
|
+
interface RepositoryCacheHandle {
|
|
71
|
+
readonly engine: CacheEngine;
|
|
72
|
+
/** Invalidate every entry tagged with ANY of the provided tags. */
|
|
73
|
+
invalidateByTags(tags: readonly string[]): Promise<number>;
|
|
74
|
+
/** Bump the model's version — wipes every cached read for it. */
|
|
75
|
+
bumpModelVersion(model: string): Promise<number>;
|
|
76
|
+
/** Wipe the entire cache namespace (when the adapter supports it). */
|
|
77
|
+
clear(): Promise<void>;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Build the unified cache plugin. Compose alongside other plugins via
|
|
81
|
+
* `repo.use(cachePlugin({ adapter }))` — install AFTER `multiTenant`
|
|
82
|
+
* and `softDelete` so policy filters land in the cache key.
|
|
83
|
+
*/
|
|
84
|
+
declare function cachePlugin(options: RepositoryCachePluginOptions): Plugin;
|
|
85
|
+
//#endregion
|
|
86
|
+
export { DEFAULT_CACHEABLE_OPS, DEFAULT_INVALIDATING_OPS, LogCallbacks, RepositoryCacheHandle, RepositoryCachePluginOptions, cachePlugin };
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { CacheEngine } from "../engine.mjs";
|
|
2
|
+
import { DEFAULT_SHAPE_KEYS_BY_OP } from "./context.mjs";
|
|
3
|
+
import { registerInvalidationHooks } from "./invalidation-hooks.mjs";
|
|
4
|
+
import { registerReadHooks } from "./read-hooks.mjs";
|
|
5
|
+
//#region src/cache/plugin/index.ts
|
|
6
|
+
/** Default read ops the plugin caches. Kits may override per resource. */
|
|
7
|
+
const DEFAULT_CACHEABLE_OPS = [
|
|
8
|
+
"getById",
|
|
9
|
+
"getAll",
|
|
10
|
+
"getOne",
|
|
11
|
+
"getByQuery",
|
|
12
|
+
"count",
|
|
13
|
+
"exists",
|
|
14
|
+
"distinct",
|
|
15
|
+
"aggregate",
|
|
16
|
+
"aggregatePaginate"
|
|
17
|
+
];
|
|
18
|
+
/** Default mutating ops that bump version + invalidate tags on success. */
|
|
19
|
+
const DEFAULT_INVALIDATING_OPS = [
|
|
20
|
+
"create",
|
|
21
|
+
"createMany",
|
|
22
|
+
"update",
|
|
23
|
+
"updateMany",
|
|
24
|
+
"findOneAndUpdate",
|
|
25
|
+
"upsert",
|
|
26
|
+
"delete",
|
|
27
|
+
"deleteMany",
|
|
28
|
+
"restore",
|
|
29
|
+
"claim",
|
|
30
|
+
"claimVersion",
|
|
31
|
+
"increment",
|
|
32
|
+
"bulkWrite"
|
|
33
|
+
];
|
|
34
|
+
/**
|
|
35
|
+
* Build the unified cache plugin. Compose alongside other plugins via
|
|
36
|
+
* `repo.use(cachePlugin({ adapter }))` — install AFTER `multiTenant`
|
|
37
|
+
* and `softDelete` so policy filters land in the cache key.
|
|
38
|
+
*/
|
|
39
|
+
function cachePlugin(options) {
|
|
40
|
+
const enabled = new Set(options.enabled ?? DEFAULT_CACHEABLE_OPS);
|
|
41
|
+
const invalidating = new Set(options.invalidating ?? DEFAULT_INVALIDATING_OPS);
|
|
42
|
+
const defaults = options.defaults;
|
|
43
|
+
const perOpDefaults = options.perOpDefaults ?? {};
|
|
44
|
+
const autoTagsFromScope = options.autoTagsFromScope ?? true;
|
|
45
|
+
const log = options.log ?? {};
|
|
46
|
+
const prefix = options.prefix ?? "rc";
|
|
47
|
+
const shapeKeysByOp = options.shapeKeysByOp ?? DEFAULT_SHAPE_KEYS_BY_OP;
|
|
48
|
+
const engine = new CacheEngine(options.adapter, {
|
|
49
|
+
prefix,
|
|
50
|
+
...options.jitter !== void 0 ? { jitter: options.jitter } : {}
|
|
51
|
+
});
|
|
52
|
+
return {
|
|
53
|
+
name: "cache",
|
|
54
|
+
apply(repo) {
|
|
55
|
+
repo.cache = {
|
|
56
|
+
engine,
|
|
57
|
+
invalidateByTags: (tags) => engine.invalidateByTags(tags),
|
|
58
|
+
bumpModelVersion: (model) => engine.bumpVersion(model),
|
|
59
|
+
clear: () => engine.clear()
|
|
60
|
+
};
|
|
61
|
+
for (const op of enabled) registerReadHooks(repo, op, engine, {
|
|
62
|
+
defaults,
|
|
63
|
+
perOpDefaults: perOpDefaults[op],
|
|
64
|
+
autoTagsFromScope,
|
|
65
|
+
log,
|
|
66
|
+
prefix,
|
|
67
|
+
repo,
|
|
68
|
+
shapeKeysByOp
|
|
69
|
+
});
|
|
70
|
+
for (const op of invalidating) registerInvalidationHooks(repo, op, engine, {
|
|
71
|
+
autoTagsFromScope,
|
|
72
|
+
log
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
//#endregion
|
|
78
|
+
export { DEFAULT_CACHEABLE_OPS, DEFAULT_INVALIDATING_OPS, cachePlugin };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { HOOK_PRIORITY } from "../../hooks/priority.mjs";
|
|
2
|
+
import { extractScopeTags, mergeTags, scopeKeyFromTags } from "../keys.mjs";
|
|
3
|
+
import { ctx } from "./context.mjs";
|
|
4
|
+
//#region src/cache/plugin/invalidation-hooks.ts
|
|
5
|
+
/**
|
|
6
|
+
* Write-side hook registration: `after:<op>` for every mutating op
|
|
7
|
+
* bumps the model's version (per-scope when possible) and invalidates
|
|
8
|
+
* the model-tag.
|
|
9
|
+
*
|
|
10
|
+
* **Targeted invalidation (TanStack `exact: true` semantic).** Writes
|
|
11
|
+
* inside `org:abc` bump only that scope's version key, leaving
|
|
12
|
+
* `org:xyz`'s cached reads hot. Falls back to a global bump when no
|
|
13
|
+
* scope is present (single-tenant apps, public reads).
|
|
14
|
+
*
|
|
15
|
+
* **Cross-cutting tag invalidation runs alongside.** Hosts who tag
|
|
16
|
+
* aggregations with the model name (default behavior — `'<model>'` is
|
|
17
|
+
* always included) get implicit invalidation when any write to the
|
|
18
|
+
* model lands, including writes in OTHER resources that auto-invalidate
|
|
19
|
+
* by tag.
|
|
20
|
+
*/
|
|
21
|
+
function registerInvalidationHooks(repo, op, engine, hookCtx) {
|
|
22
|
+
repo.on(`after:${op}`, async (rawPayload) => {
|
|
23
|
+
const context = ctx(rawPayload.context);
|
|
24
|
+
const model = context["model"];
|
|
25
|
+
if (!model) return;
|
|
26
|
+
const scopeTags = hookCtx.autoTagsFromScope ? extractScopeTags(context) : [];
|
|
27
|
+
const scopeKey = scopeKeyFromTags(scopeTags);
|
|
28
|
+
const version = await engine.bumpVersion(model, scopeKey);
|
|
29
|
+
const tagsToInvalidate = mergeTags([model], scopeTags);
|
|
30
|
+
const count = await engine.invalidateByTags(tagsToInvalidate);
|
|
31
|
+
hookCtx.log.onInvalidate?.(model, version, count);
|
|
32
|
+
}, { priority: HOOK_PRIORITY.CACHE });
|
|
33
|
+
}
|
|
34
|
+
//#endregion
|
|
35
|
+
export { registerInvalidationHooks };
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { HOOK_PRIORITY } from "../../hooks/priority.mjs";
|
|
2
|
+
import { buildCacheKey, extractScopeTags, mergeTags, scopeKeyFromTags } from "../keys.mjs";
|
|
3
|
+
import { ctx, extractCallCacheOptions, extractShapeFields } from "./context.mjs";
|
|
4
|
+
import { resolveCacheOptions } from "../options.mjs";
|
|
5
|
+
import { scheduleSwrRefresh } from "./swr.mjs";
|
|
6
|
+
//#region src/cache/plugin/read-hooks.ts
|
|
7
|
+
/**
|
|
8
|
+
* Read-side hook registration: `before:<op>` (cache check + single-
|
|
9
|
+
* flight claim), `after:<op>` (cache write + resolve waiters), and
|
|
10
|
+
* `error:<op>` (reject waiters fail-fast).
|
|
11
|
+
*
|
|
12
|
+
* All three hooks coordinate via typed slots on the shared context bag
|
|
13
|
+
* (see `./context.ts`). The kit's read method short-circuits when
|
|
14
|
+
* `_cacheHit === true` via `RepositoryBase._cachedValue<T>(context)`.
|
|
15
|
+
*/
|
|
16
|
+
function registerReadHooks(repo, op, engine, hookCtx) {
|
|
17
|
+
repo.on(`before:${op}`, registerBefore(op, engine, hookCtx), { priority: HOOK_PRIORITY.CACHE });
|
|
18
|
+
repo.on(`after:${op}`, registerAfter(op, engine, hookCtx), { priority: HOOK_PRIORITY.CACHE });
|
|
19
|
+
repo.on(`error:${op}`, registerError(engine), { priority: HOOK_PRIORITY.CACHE });
|
|
20
|
+
}
|
|
21
|
+
function registerBefore(op, engine, hookCtx) {
|
|
22
|
+
return async (rawContext) => {
|
|
23
|
+
const context = ctx(rawContext);
|
|
24
|
+
const resolved = resolveCacheOptions(extractCallCacheOptions(context, op), hookCtx.perOpDefaults, hookCtx.defaults);
|
|
25
|
+
if (!resolved.enabled) return;
|
|
26
|
+
const scopeTags = hookCtx.autoTagsFromScope ? extractScopeTags(context) : [];
|
|
27
|
+
const allTags = mergeTags(resolved.tags, scopeTags);
|
|
28
|
+
const key = resolved.key ?? await deriveKey(engine, op, context, scopeTags, hookCtx);
|
|
29
|
+
context._cacheKey = key;
|
|
30
|
+
context._cacheResolved = {
|
|
31
|
+
...resolved,
|
|
32
|
+
tags: allTags
|
|
33
|
+
};
|
|
34
|
+
const result = await engine.get(key, resolved);
|
|
35
|
+
if (result.status === "fresh" || result.status === "stale") {
|
|
36
|
+
context._cacheHit = true;
|
|
37
|
+
context._cachedResult = result.data;
|
|
38
|
+
context._cacheStatus = result.status;
|
|
39
|
+
if (result.status === "fresh") hookCtx.log.onHit?.(key, op, result.age ?? 0);
|
|
40
|
+
else hookCtx.log.onStale?.(key, op, result.age ?? 0);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
hookCtx.log.onMiss?.(key, op);
|
|
44
|
+
if (resolved.bypass) return;
|
|
45
|
+
const claim = engine.claimPending(key);
|
|
46
|
+
if (claim.status === "wait") try {
|
|
47
|
+
const data = await claim.promise;
|
|
48
|
+
context._cacheHit = true;
|
|
49
|
+
context._cachedResult = data;
|
|
50
|
+
context._cacheStatus = "fresh";
|
|
51
|
+
context._cacheCoalesced = true;
|
|
52
|
+
hookCtx.log.onCoalesce?.(key, op);
|
|
53
|
+
} catch {}
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
function registerAfter(op, engine, hookCtx) {
|
|
57
|
+
return async (rawPayload) => {
|
|
58
|
+
const payload = rawPayload;
|
|
59
|
+
const context = ctx(payload.context);
|
|
60
|
+
if (context._cacheHit === true && context._cacheStatus === "stale") {
|
|
61
|
+
scheduleSwrRefresh(op, hookCtx.repo, context);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (context._cacheHit === true && context._cacheStatus === "fresh") return;
|
|
65
|
+
const key = context._cacheKey;
|
|
66
|
+
const resolved = context._cacheResolved;
|
|
67
|
+
if (!key || !resolved) return;
|
|
68
|
+
await engine.set(key, payload.result, resolved);
|
|
69
|
+
engine.resolvePending(key, payload.result);
|
|
70
|
+
hookCtx.log.onWrite?.(key, op, resolved.tags);
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
function registerError(engine) {
|
|
74
|
+
return async (rawPayload) => {
|
|
75
|
+
const payload = rawPayload;
|
|
76
|
+
const key = ctx(payload.context)._cacheKey;
|
|
77
|
+
if (!key) return;
|
|
78
|
+
engine.rejectPending(key, payload.error);
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
async function deriveKey(engine, op, context, scopeTags, hookCtx) {
|
|
82
|
+
const model = context["model"] ?? "unknown";
|
|
83
|
+
const scopeKey = scopeKeyFromTags(scopeTags);
|
|
84
|
+
const version = await engine.getVersion(model, scopeKey);
|
|
85
|
+
const params = extractShapeFields(context, op, hookCtx.shapeKeysByOp);
|
|
86
|
+
return buildCacheKey({
|
|
87
|
+
prefix: engine.keyPrefix,
|
|
88
|
+
operation: op,
|
|
89
|
+
model,
|
|
90
|
+
version,
|
|
91
|
+
params: params ?? {},
|
|
92
|
+
scopeTags
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
//#endregion
|
|
96
|
+
export { registerReadHooks };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { AGGREGATE_OPS } from "./context.mjs";
|
|
2
|
+
import { scheduleBackground } from "../runtime.mjs";
|
|
3
|
+
//#region src/cache/plugin/swr.ts
|
|
4
|
+
function scheduleSwrRefresh(op, repo, context) {
|
|
5
|
+
if (!AGGREGATE_OPS.has(op)) return;
|
|
6
|
+
const aggReq = context["aggRequest"];
|
|
7
|
+
if (!aggReq) return;
|
|
8
|
+
const refreshReq = {
|
|
9
|
+
...aggReq,
|
|
10
|
+
cache: {
|
|
11
|
+
...aggReq.cache ?? {},
|
|
12
|
+
bypass: true
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
scheduleBackground(() => {
|
|
16
|
+
repo[op]?.(refreshReq).catch(() => {});
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
//#endregion
|
|
20
|
+
export { scheduleSwrRefresh };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
//#region src/cache/runtime.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Cross-runtime scheduling primitives.
|
|
4
|
+
*
|
|
5
|
+
* The cache layer targets every JS runtime kits + arc + Express/Nest
|
|
6
|
+
* hosts run on — Node, Bun, Deno Deploy, Cloudflare Workers, edge
|
|
7
|
+
* functions. Most Web-style APIs (`Map`, `Promise`, `setTimeout`,
|
|
8
|
+
* `BigInt`, `queueMicrotask`) are available across all of them, but
|
|
9
|
+
* `setImmediate` is Node-specific — Workers / Deno / browsers throw a
|
|
10
|
+
* `ReferenceError` if you call it.
|
|
11
|
+
*
|
|
12
|
+
* `scheduleBackground` resolves the right primitive at module load and
|
|
13
|
+
* exposes a single API the rest of the cache layer uses.
|
|
14
|
+
*
|
|
15
|
+
* **Semantics:**
|
|
16
|
+
*
|
|
17
|
+
* | Runtime | Mechanism | When the callback fires |
|
|
18
|
+
* | ----------- | -------------------- | ----------------------------------- |
|
|
19
|
+
* | Node / Bun | `setImmediate(fn)` | After current I/O phase |
|
|
20
|
+
* | Workers | `setTimeout(fn, 0)` | After current task (min 0ms) |
|
|
21
|
+
* | Deno Deploy | `setTimeout(fn, 0)` | After current task |
|
|
22
|
+
* | Browser | `setTimeout(fn, 0)` | After current task (clamped 4ms) |
|
|
23
|
+
*
|
|
24
|
+
* Every runtime guarantees the callback runs AFTER the current sync
|
|
25
|
+
* block + any pending microtasks of the current task — which is the
|
|
26
|
+
* actual contract callers rely on (don't run the bg work synchronously
|
|
27
|
+
* in the response path).
|
|
28
|
+
*
|
|
29
|
+
* **Why not `queueMicrotask`?** Microtasks flush BEFORE the current
|
|
30
|
+
* I/O phase completes. For SWR, that means the bg refresh's first
|
|
31
|
+
* `await` could delay the user's HTTP response write. `setImmediate`
|
|
32
|
+
* (or `setTimeout(0)` on edge runtimes) defers past the I/O phase.
|
|
33
|
+
*/
|
|
34
|
+
/**
|
|
35
|
+
* Schedule a fire-and-forget callback to run after the current task.
|
|
36
|
+
*
|
|
37
|
+
* Used by SWR background refresh + (potentially) any future hook that
|
|
38
|
+
* wants "run after response" semantics. Caller is responsible for
|
|
39
|
+
* error handling — this helper does NOT add a default `.catch`.
|
|
40
|
+
*/
|
|
41
|
+
declare const scheduleBackground: (fn: () => void) => void;
|
|
42
|
+
//#endregion
|
|
43
|
+
export { scheduleBackground };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schedule a fire-and-forget callback to run after the current task.
|
|
3
|
+
*
|
|
4
|
+
* Used by SWR background refresh + (potentially) any future hook that
|
|
5
|
+
* wants "run after response" semantics. Caller is responsible for
|
|
6
|
+
* error handling — this helper does NOT add a default `.catch`.
|
|
7
|
+
*/
|
|
8
|
+
const scheduleBackground = typeof globalThis.setImmediate === "function" ? (fn) => {
|
|
9
|
+
globalThis.setImmediate(fn);
|
|
10
|
+
} : (fn) => {
|
|
11
|
+
setTimeout(fn, 0);
|
|
12
|
+
};
|
|
13
|
+
//#endregion
|
|
14
|
+
export { scheduleBackground };
|