@classytic/repo-core 0.3.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 +243 -0
- 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/index.d.mts +2 -1
- package/dist/errors/index.mjs +2 -1
- package/dist/errors/schema.d.mts +101 -0
- package/dist/errors/schema.mjs +78 -0
- package/dist/filter/match.mjs +38 -2
- package/dist/pagination/canonical.d.mts +8 -8
- package/dist/pagination/canonical.mjs +3 -9
- package/dist/pagination/cursor.mjs +4 -1
- package/dist/pagination/index.d.mts +2 -2
- package/dist/pagination/types.d.mts +17 -27
- 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/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 +41 -1
- package/dist/schema/field-rules.mjs +92 -1
- package/dist/schema/index.d.mts +2 -2
- package/dist/schema/index.mjs +2 -2
- package/dist/schema/types.d.mts +21 -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 +19 -1
- package/dist/cache/stable-stringify.d.mts +0 -15
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { stableStringify } from "./stable-stringify.mjs";
|
|
2
|
+
//#region src/cache/keys.ts
|
|
3
|
+
/**
|
|
4
|
+
* Cache key derivation. Builds stable keys from operation context so
|
|
5
|
+
* any kit + arc + Express/Nest hosts compute the same key for the
|
|
6
|
+
* same logical request — letting one Redis serve a mixed-kit fleet.
|
|
7
|
+
*
|
|
8
|
+
* **Key shape:**
|
|
9
|
+
* `<prefix>:<op>:<model>:v<version>:<paramsHash>:<scopeHash>`
|
|
10
|
+
*
|
|
11
|
+
* - `prefix` — tenant of the cache namespace (`'rc'` default)
|
|
12
|
+
* - `op` — repository operation name (`getById`, `aggregate`, ...)
|
|
13
|
+
* - `model` — the entity model name
|
|
14
|
+
* - `version` — collection version (bumped on writes; orphans all
|
|
15
|
+
* keys for the model in O(1)). Per-scope when the
|
|
16
|
+
* plugin extracts a scopeKey.
|
|
17
|
+
* - `paramsHash` — fnv1a64 of stable-stringified call params
|
|
18
|
+
* (filter, id, sort, kit-specific options like
|
|
19
|
+
* `lean`). The plugin's per-op allowlist decides
|
|
20
|
+
* which fields participate.
|
|
21
|
+
* - `scopeHash` — short hash of the auto-extracted scope tags
|
|
22
|
+
* (`org:<id>` / `user:<id>`). Keeps the key short
|
|
23
|
+
* while preserving cross-tenant isolation.
|
|
24
|
+
*/
|
|
25
|
+
/** Build the canonical cache key. */
|
|
26
|
+
function buildCacheKey(input) {
|
|
27
|
+
const paramsHash = fnv1a64(stableStringify(input.params));
|
|
28
|
+
const scopeHash = input.scopeTags.length > 0 ? fnv1a64(input.scopeTags.join("|")) : "0";
|
|
29
|
+
return `${input.prefix}:${input.operation}:${input.model}:v${input.version}:${paramsHash}:${scopeHash}`;
|
|
30
|
+
}
|
|
31
|
+
/** Tag-index key — maps a tag to the set of cache keys carrying it. */
|
|
32
|
+
function tagIndexKey(prefix, tag) {
|
|
33
|
+
return `${prefix}:tag:${tag}`;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Collection-version key — bumped on writes to orphan all reads.
|
|
37
|
+
*
|
|
38
|
+
* Per-scope sharding when `scopeKey` is supplied — e.g. `'org:abc'`
|
|
39
|
+
* keys to `<prefix>:ver:<model>:org:abc`, so writes inside `org:abc`
|
|
40
|
+
* don't invalidate other tenants' cached reads. Without a scopeKey
|
|
41
|
+
* the version is global (legacy semantic — invalidates all reads on
|
|
42
|
+
* any write).
|
|
43
|
+
*/
|
|
44
|
+
function versionKey(prefix, model, scopeKey) {
|
|
45
|
+
return scopeKey ? `${prefix}:ver:${model}:${scopeKey}` : `${prefix}:ver:${model}`;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Build a deterministic scope-key string from extracted scope tags.
|
|
49
|
+
* Used to suffix `versionKey` so per-scope invalidation works.
|
|
50
|
+
*
|
|
51
|
+
* Returns `undefined` when no scope is present — caller falls back to
|
|
52
|
+
* global version semantics.
|
|
53
|
+
*/
|
|
54
|
+
function scopeKeyFromTags(scopeTags) {
|
|
55
|
+
if (scopeTags.length === 0) return void 0;
|
|
56
|
+
return [...scopeTags].sort().join("|");
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Merge two tag lists, preserving first-seen order and deduping. Used
|
|
60
|
+
* by the plugin to combine caller-supplied tags with auto-derived
|
|
61
|
+
* scope tags into a single index-able set.
|
|
62
|
+
*/
|
|
63
|
+
function mergeTags(a, b) {
|
|
64
|
+
if (a.length === 0) return b;
|
|
65
|
+
if (b.length === 0) return a;
|
|
66
|
+
const seen = /* @__PURE__ */ new Set();
|
|
67
|
+
const out = [];
|
|
68
|
+
for (const t of a) if (!seen.has(t)) {
|
|
69
|
+
seen.add(t);
|
|
70
|
+
out.push(t);
|
|
71
|
+
}
|
|
72
|
+
for (const t of b) if (!seen.has(t)) {
|
|
73
|
+
seen.add(t);
|
|
74
|
+
out.push(t);
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Extract scope tags from a hook context. Reads the canonical fields
|
|
80
|
+
* multi-tenant + auth plugins inject (`organizationId`, `userId`).
|
|
81
|
+
* Returns an empty array when no scope is present — public reads share
|
|
82
|
+
* one cache slot.
|
|
83
|
+
*
|
|
84
|
+
* Looks at THREE locations in priority order:
|
|
85
|
+
* 1. `context.filter.<field>` — multi-tenant injects here
|
|
86
|
+
* 2. `context.options.<field>` — kit options bag (arc audit attribution)
|
|
87
|
+
* 3. `context.<field>` — top-level fallback
|
|
88
|
+
*/
|
|
89
|
+
function extractScopeTags(context) {
|
|
90
|
+
if (!context) return [];
|
|
91
|
+
const tags = [];
|
|
92
|
+
const orgId = pickScopeField(context, "organizationId");
|
|
93
|
+
if (orgId) tags.push(`org:${orgId}`);
|
|
94
|
+
const userId = pickScopeField(context, "userId");
|
|
95
|
+
if (userId) tags.push(`user:${userId}`);
|
|
96
|
+
return tags;
|
|
97
|
+
}
|
|
98
|
+
function pickScopeField(context, field) {
|
|
99
|
+
const filter = context["filter"];
|
|
100
|
+
if (filter && typeof filter[field] === "string") return filter[field];
|
|
101
|
+
const options = context["options"];
|
|
102
|
+
if (options && typeof options[field] === "string") return options[field];
|
|
103
|
+
if (typeof context[field] === "string") return context[field];
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* FNV-1a 64-bit — non-cryptographic hash for cache-key shortening.
|
|
107
|
+
* Emits stable base-36 strings (≤13 chars) for compact keys.
|
|
108
|
+
*
|
|
109
|
+
* **Why 64-bit, not 32-bit:** djb2 32-bit (~4B value space) hits ~50%
|
|
110
|
+
* birthday-paradox collision probability around √(2^32) ≈ 65k distinct
|
|
111
|
+
* keys — easily exceeded by a multi-tenant fleet. FNV-1a 64-bit pushes
|
|
112
|
+
* that threshold to ~4B keys, which no realistic cache approaches.
|
|
113
|
+
* Both are non-cryptographic; FNV-1a has better avalanche on short
|
|
114
|
+
* ASCII strings (cache keys).
|
|
115
|
+
*
|
|
116
|
+
* BigInt is required — JS numbers lose precision past 2^53. The cost
|
|
117
|
+
* is negligible at cache-key sizes (key strings are typically <1KB).
|
|
118
|
+
*/
|
|
119
|
+
const FNV_OFFSET_64 = 14695981039346656037n;
|
|
120
|
+
const FNV_PRIME_64 = 1099511628211n;
|
|
121
|
+
const FNV_MASK_64 = 18446744073709551615n;
|
|
122
|
+
function fnv1a64(str) {
|
|
123
|
+
let hash = FNV_OFFSET_64;
|
|
124
|
+
for (let i = 0; i < str.length; i++) {
|
|
125
|
+
hash ^= BigInt(str.charCodeAt(i));
|
|
126
|
+
hash = hash * FNV_PRIME_64 & FNV_MASK_64;
|
|
127
|
+
}
|
|
128
|
+
return hash.toString(36);
|
|
129
|
+
}
|
|
130
|
+
//#endregion
|
|
131
|
+
export { buildCacheKey, extractScopeTags, mergeTags, scopeKeyFromTags, tagIndexKey, versionKey };
|
|
@@ -3,15 +3,20 @@
|
|
|
3
3
|
function createMemoryCacheAdapter() {
|
|
4
4
|
const store = /* @__PURE__ */ new Map();
|
|
5
5
|
const now = () => Date.now();
|
|
6
|
+
function readUnexpired(key) {
|
|
7
|
+
const entry = store.get(key);
|
|
8
|
+
if (!entry) return void 0;
|
|
9
|
+
if (entry.expiresAt !== 0 && entry.expiresAt < now()) {
|
|
10
|
+
store.delete(key);
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
return entry;
|
|
14
|
+
}
|
|
6
15
|
return {
|
|
7
16
|
get(key) {
|
|
8
|
-
const
|
|
9
|
-
if (
|
|
10
|
-
|
|
11
|
-
store.delete(key);
|
|
12
|
-
return;
|
|
13
|
-
}
|
|
14
|
-
return entry.value;
|
|
17
|
+
const value = readUnexpired(key)?.value;
|
|
18
|
+
if (value instanceof Set) return Array.from(value);
|
|
19
|
+
return value;
|
|
15
20
|
},
|
|
16
21
|
set(key, value, ttlSeconds = 60) {
|
|
17
22
|
const expiresAt = ttlSeconds === 0 ? 0 : now() + ttlSeconds * 1e3;
|
|
@@ -30,6 +35,35 @@ function createMemoryCacheAdapter() {
|
|
|
30
35
|
}
|
|
31
36
|
const prefix = pattern.endsWith("*") ? pattern.slice(0, -1) : pattern;
|
|
32
37
|
for (const key of store.keys()) if (key.startsWith(prefix)) store.delete(key);
|
|
38
|
+
},
|
|
39
|
+
addToSet(key, members, ttlSeconds = 60) {
|
|
40
|
+
const existing = readUnexpired(key);
|
|
41
|
+
let set;
|
|
42
|
+
if (existing && existing.value instanceof Set) set = existing.value;
|
|
43
|
+
else {
|
|
44
|
+
set = /* @__PURE__ */ new Set();
|
|
45
|
+
const expiresAt = existing?.expiresAt ?? (ttlSeconds === 0 ? 0 : now() + ttlSeconds * 1e3);
|
|
46
|
+
store.set(key, {
|
|
47
|
+
value: set,
|
|
48
|
+
expiresAt
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
let added = 0;
|
|
52
|
+
for (const m of members) if (!set.has(m)) {
|
|
53
|
+
set.add(m);
|
|
54
|
+
added++;
|
|
55
|
+
}
|
|
56
|
+
return added;
|
|
57
|
+
},
|
|
58
|
+
increment(key, by = 1, ttlSeconds = 60) {
|
|
59
|
+
const existing = readUnexpired(key);
|
|
60
|
+
const next = (existing && typeof existing.value === "number" && Number.isFinite(existing.value) ? existing.value : 0) + by;
|
|
61
|
+
const expiresAt = existing ? existing.expiresAt : ttlSeconds === 0 ? 0 : now() + ttlSeconds * 1e3;
|
|
62
|
+
store.set(key, {
|
|
63
|
+
value: next,
|
|
64
|
+
expiresAt
|
|
65
|
+
});
|
|
66
|
+
return next;
|
|
33
67
|
}
|
|
34
68
|
};
|
|
35
69
|
}
|
|
@@ -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 };
|