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