@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.
Files changed (73) hide show
  1. package/CHANGELOG.md +243 -0
  2. package/dist/adapter/index.d.mts +3 -0
  3. package/dist/adapter/index.mjs +2 -0
  4. package/dist/adapter/types.d.mts +222 -0
  5. package/dist/adapter/widen.d.mts +22 -0
  6. package/dist/adapter/widen.mjs +26 -0
  7. package/dist/aggregate/index.d.mts +3 -0
  8. package/dist/aggregate/index.mjs +3 -0
  9. package/dist/aggregate/keyset.d.mts +57 -0
  10. package/dist/aggregate/keyset.mjs +45 -0
  11. package/dist/aggregate/normalize.d.mts +24 -0
  12. package/dist/aggregate/normalize.mjs +28 -0
  13. package/dist/better-auth/index.d.mts +110 -0
  14. package/dist/better-auth/index.mjs +71 -0
  15. package/dist/cache/engine.d.mts +127 -0
  16. package/dist/cache/engine.mjs +235 -0
  17. package/dist/cache/envelope.mjs +32 -0
  18. package/dist/cache/index.d.mts +7 -2
  19. package/dist/cache/index.mjs +6 -2
  20. package/dist/cache/keys.mjs +131 -0
  21. package/dist/cache/memory-adapter.mjs +41 -7
  22. package/dist/cache/options.d.mts +112 -0
  23. package/dist/cache/options.mjs +25 -0
  24. package/dist/cache/plugin/context.d.mts +18 -0
  25. package/dist/cache/plugin/context.mjs +121 -0
  26. package/dist/cache/plugin/index.d.mts +86 -0
  27. package/dist/cache/plugin/index.mjs +78 -0
  28. package/dist/cache/plugin/invalidation-hooks.mjs +35 -0
  29. package/dist/cache/plugin/read-hooks.mjs +96 -0
  30. package/dist/cache/plugin/swr.mjs +20 -0
  31. package/dist/cache/runtime.d.mts +43 -0
  32. package/dist/cache/runtime.mjs +14 -0
  33. package/dist/cache/tag-index.mjs +84 -0
  34. package/dist/cache/timeout-adapter.d.mts +30 -0
  35. package/dist/cache/timeout-adapter.mjs +58 -0
  36. package/dist/cache/types.d.mts +45 -0
  37. package/dist/cache/version-store.mjs +57 -0
  38. package/dist/errors/index.d.mts +2 -1
  39. package/dist/errors/index.mjs +2 -1
  40. package/dist/errors/schema.d.mts +101 -0
  41. package/dist/errors/schema.mjs +78 -0
  42. package/dist/filter/match.mjs +38 -2
  43. package/dist/pagination/canonical.d.mts +8 -8
  44. package/dist/pagination/canonical.mjs +3 -9
  45. package/dist/pagination/cursor.mjs +4 -1
  46. package/dist/pagination/index.d.mts +2 -2
  47. package/dist/pagination/types.d.mts +17 -27
  48. package/dist/plugins/index.d.mts +2 -0
  49. package/dist/plugins/index.mjs +2 -0
  50. package/dist/plugins/tenant-helpers.d.mts +63 -0
  51. package/dist/plugins/tenant-helpers.mjs +84 -0
  52. package/dist/query-parser/index.d.mts +2 -1
  53. package/dist/query-parser/index.mjs +2 -1
  54. package/dist/query-parser/parse-url.mjs +13 -11
  55. package/dist/query-parser/reserved.d.mts +43 -0
  56. package/dist/query-parser/reserved.mjs +56 -0
  57. package/dist/repository/agg-output.d.mts +63 -0
  58. package/dist/repository/agg-output.mjs +89 -0
  59. package/dist/repository/index.d.mts +4 -2
  60. package/dist/repository/index.mjs +3 -1
  61. package/dist/repository/options.d.mts +62 -0
  62. package/dist/repository/options.mjs +57 -0
  63. package/dist/repository/types.d.mts +935 -48
  64. package/dist/schema/field-rules.d.mts +41 -1
  65. package/dist/schema/field-rules.mjs +92 -1
  66. package/dist/schema/index.d.mts +2 -2
  67. package/dist/schema/index.mjs +2 -2
  68. package/dist/schema/types.d.mts +21 -0
  69. package/dist/testing/conformance.mjs +666 -17
  70. package/dist/testing/index.d.mts +2 -2
  71. package/dist/testing/types.d.mts +99 -2
  72. package/package.json +19 -1
  73. 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 entry = store.get(key);
9
- if (!entry) return void 0;
10
- if (entry.expiresAt !== 0 && entry.expiresAt < now()) {
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 };