@classytic/repo-core 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +363 -0
- package/README.md +28 -7
- package/dist/adapter/index.d.mts +3 -0
- package/dist/adapter/index.mjs +2 -0
- package/dist/adapter/types.d.mts +222 -0
- package/dist/adapter/widen.d.mts +22 -0
- package/dist/adapter/widen.mjs +26 -0
- package/dist/aggregate/index.d.mts +3 -0
- package/dist/aggregate/index.mjs +3 -0
- package/dist/aggregate/keyset.d.mts +57 -0
- package/dist/aggregate/keyset.mjs +45 -0
- package/dist/aggregate/normalize.d.mts +24 -0
- package/dist/aggregate/normalize.mjs +28 -0
- package/dist/better-auth/index.d.mts +110 -0
- package/dist/better-auth/index.mjs +71 -0
- package/dist/cache/engine.d.mts +127 -0
- package/dist/cache/engine.mjs +235 -0
- package/dist/cache/envelope.mjs +32 -0
- package/dist/cache/index.d.mts +7 -2
- package/dist/cache/index.mjs +6 -2
- package/dist/cache/keys.mjs +131 -0
- package/dist/cache/memory-adapter.mjs +41 -7
- package/dist/cache/options.d.mts +112 -0
- package/dist/cache/options.mjs +25 -0
- package/dist/cache/plugin/context.d.mts +18 -0
- package/dist/cache/plugin/context.mjs +121 -0
- package/dist/cache/plugin/index.d.mts +86 -0
- package/dist/cache/plugin/index.mjs +78 -0
- package/dist/cache/plugin/invalidation-hooks.mjs +35 -0
- package/dist/cache/plugin/read-hooks.mjs +96 -0
- package/dist/cache/plugin/swr.mjs +20 -0
- package/dist/cache/runtime.d.mts +43 -0
- package/dist/cache/runtime.mjs +14 -0
- package/dist/cache/tag-index.mjs +84 -0
- package/dist/cache/timeout-adapter.d.mts +30 -0
- package/dist/cache/timeout-adapter.mjs +58 -0
- package/dist/cache/types.d.mts +45 -0
- package/dist/cache/version-store.mjs +57 -0
- package/dist/errors/contract.d.mts +37 -0
- package/dist/errors/contract.mjs +75 -0
- package/dist/errors/index.d.mts +4 -2
- package/dist/errors/index.mjs +4 -1
- package/dist/errors/schema.d.mts +101 -0
- package/dist/errors/schema.mjs +78 -0
- package/dist/errors/types.d.mts +113 -8
- package/dist/errors/types.mjs +29 -0
- package/dist/filter/match.mjs +38 -2
- package/dist/pagination/canonical.d.mts +35 -0
- package/dist/pagination/canonical.mjs +26 -0
- package/dist/pagination/cursor.mjs +4 -1
- package/dist/pagination/index.d.mts +3 -2
- package/dist/pagination/index.mjs +2 -1
- package/dist/pagination/types.d.mts +57 -3
- package/dist/plugins/index.d.mts +2 -0
- package/dist/plugins/index.mjs +2 -0
- package/dist/plugins/tenant-helpers.d.mts +63 -0
- package/dist/plugins/tenant-helpers.mjs +84 -0
- package/dist/query-parser/index.d.mts +2 -1
- package/dist/query-parser/index.mjs +2 -1
- package/dist/query-parser/parse-url.mjs +13 -11
- package/dist/query-parser/reserved.d.mts +43 -0
- package/dist/query-parser/reserved.mjs +56 -0
- package/dist/repository/agg-output.d.mts +63 -0
- package/dist/repository/agg-output.mjs +89 -0
- package/dist/repository/base.mjs +21 -0
- package/dist/repository/index.d.mts +4 -2
- package/dist/repository/index.mjs +3 -1
- package/dist/repository/options.d.mts +62 -0
- package/dist/repository/options.mjs +57 -0
- package/dist/repository/types.d.mts +935 -48
- package/dist/schema/field-rules.d.mts +60 -9
- package/dist/schema/field-rules.mjs +121 -10
- package/dist/schema/generator.d.mts +72 -0
- package/dist/schema/generator.mjs +16 -0
- package/dist/schema/index.d.mts +3 -2
- package/dist/schema/index.mjs +3 -2
- package/dist/schema/types.d.mts +77 -3
- package/dist/tenant/index.d.mts +3 -0
- package/dist/tenant/index.mjs +2 -0
- package/dist/tenant/resolve.d.mts +27 -0
- package/dist/tenant/resolve.mjs +69 -0
- package/dist/tenant/types.d.mts +142 -0
- package/dist/testing/conformance.mjs +666 -17
- package/dist/testing/index.d.mts +2 -2
- package/dist/testing/types.d.mts +99 -2
- package/package.json +27 -1
- package/dist/cache/stable-stringify.d.mts +0 -15
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { CacheReadResult, ResolvedCacheOptions } from "./options.mjs";
|
|
2
|
+
import { CacheAdapter } from "./types.mjs";
|
|
3
|
+
|
|
4
|
+
//#region src/cache/engine.d.ts
|
|
5
|
+
interface CacheEngineOptions {
|
|
6
|
+
/** Cache key namespace prefix. Default: `'rc'`. */
|
|
7
|
+
prefix?: string;
|
|
8
|
+
/**
|
|
9
|
+
* TTL jitter — randomizes the actual stored TTL so cache stampedes
|
|
10
|
+
* don't synchronize across many entries written together. Pass a
|
|
11
|
+
* number in `(0, 1]` for symmetric fractional jitter (`0.1` =
|
|
12
|
+
* uniform ±10%) or a function for custom logic. Default: `0` (off).
|
|
13
|
+
*/
|
|
14
|
+
jitter?: number | ((ttl: number) => number);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* In-flight claim outcome. `'claimed'` → caller owns the fetch;
|
|
18
|
+
* `'wait'` → caller awaits an already-in-flight fetch.
|
|
19
|
+
*/
|
|
20
|
+
type SingleFlightClaim<T = unknown> = {
|
|
21
|
+
readonly status: 'claimed';
|
|
22
|
+
} | {
|
|
23
|
+
readonly status: 'wait';
|
|
24
|
+
readonly promise: Promise<T>;
|
|
25
|
+
};
|
|
26
|
+
declare class CacheEngine {
|
|
27
|
+
private readonly adapter;
|
|
28
|
+
private readonly prefix;
|
|
29
|
+
private readonly jitter;
|
|
30
|
+
/**
|
|
31
|
+
* In-flight fetches keyed by cache-key. Process-local (lives in this
|
|
32
|
+
* engine instance) — server restart clears it; cross-pod fanout is
|
|
33
|
+
* fine because each pod runs its own single-flight, and downstream
|
|
34
|
+
* load is bounded to N-pods worst case (a huge improvement over
|
|
35
|
+
* unbounded burst).
|
|
36
|
+
*/
|
|
37
|
+
private readonly pending;
|
|
38
|
+
constructor(adapter: CacheAdapter, options?: CacheEngineOptions);
|
|
39
|
+
/**
|
|
40
|
+
* Read a cache entry under SWR + TTL semantics. Returns a
|
|
41
|
+
* structured `CacheReadResult` describing freshness state — the
|
|
42
|
+
* caller decides whether to serve, revalidate, or fetch fresh.
|
|
43
|
+
*
|
|
44
|
+
* **State table:**
|
|
45
|
+
* - `enabled: false` → `{ status: 'disabled' }` — caller fetches
|
|
46
|
+
* - `bypass: true` → `{ status: 'bypass' }` — caller fetches
|
|
47
|
+
* - missing / expired → `{ status: 'miss' }` — caller fetches
|
|
48
|
+
* - fresh → `{ status: 'fresh', data }`
|
|
49
|
+
* - stale + swr=true → `{ status: 'stale', data }` — caller serves + bg-refreshes
|
|
50
|
+
* - stale + swr=false → `{ status: 'miss' }` — caller fetches
|
|
51
|
+
*/
|
|
52
|
+
get<TData>(key: string, opts: ResolvedCacheOptions): Promise<CacheReadResult<TData>>;
|
|
53
|
+
/**
|
|
54
|
+
* Write `value` under `key` with the resolved options. Skips silently
|
|
55
|
+
* when `enabled: false` (no cache pollution from disabled calls).
|
|
56
|
+
*
|
|
57
|
+
* Side effect: appends `key` to the tag side-index for every tag in
|
|
58
|
+
* `opts.tags` so future `invalidateByTags` calls find it.
|
|
59
|
+
*/
|
|
60
|
+
set<TData>(key: string, value: TData, opts: ResolvedCacheOptions): Promise<void>;
|
|
61
|
+
/**
|
|
62
|
+
* Look up an in-flight fetch for `key`. Returns the promise the
|
|
63
|
+
* first miss-claimer registered, or `undefined` when no fetch is
|
|
64
|
+
* pending.
|
|
65
|
+
*/
|
|
66
|
+
getPending<T = unknown>(key: string): Promise<T> | undefined;
|
|
67
|
+
/**
|
|
68
|
+
* Atomically claim `key` for a fetch. Returns `'claimed'` when this
|
|
69
|
+
* caller owns the fetch (it must call `resolvePending` or
|
|
70
|
+
* `rejectPending` when done) or `{ status: 'wait', promise }` when
|
|
71
|
+
* another caller already claimed — the returned promise resolves
|
|
72
|
+
* with the first claimer's result.
|
|
73
|
+
*/
|
|
74
|
+
claimPending<T = unknown>(key: string): SingleFlightClaim<T>;
|
|
75
|
+
/** Resolve an in-flight claim with the fresh result + clear it. */
|
|
76
|
+
resolvePending<T>(key: string, value: T): void;
|
|
77
|
+
/**
|
|
78
|
+
* Reject an in-flight claim — waiters fail-fast (they DON'T retry
|
|
79
|
+
* inline; they get the same error as the claimer). Caller's choice
|
|
80
|
+
* whether to retry on a higher level.
|
|
81
|
+
*/
|
|
82
|
+
rejectPending(key: string, error: unknown): void;
|
|
83
|
+
/** Internal — number of in-flight fetches; observability hook. */
|
|
84
|
+
get pendingCount(): number;
|
|
85
|
+
/**
|
|
86
|
+
* Invalidate every entry tagged with ANY of the provided tags. Reads
|
|
87
|
+
* each tag's index, deletes the listed cache entries, and clears
|
|
88
|
+
* the index. Returns the count of entries removed.
|
|
89
|
+
*/
|
|
90
|
+
invalidateByTags(tags: readonly string[]): Promise<number>;
|
|
91
|
+
/**
|
|
92
|
+
* Read a model's current version (optionally per-scope). Used by
|
|
93
|
+
* the plugin to embed `v<version>` into every cache key so a single
|
|
94
|
+
* version bump orphans the model's cache space.
|
|
95
|
+
*/
|
|
96
|
+
getVersion(model: string, scopeKey?: string): Promise<number>;
|
|
97
|
+
/**
|
|
98
|
+
* Bump the model's version (per-scope when `scopeKey` is supplied)
|
|
99
|
+
* to invalidate every cached read for it. Per-scope bumps don't
|
|
100
|
+
* affect other tenants' caches — TanStack-style targeted
|
|
101
|
+
* invalidation.
|
|
102
|
+
*/
|
|
103
|
+
bumpVersion(model: string, scopeKey?: string): Promise<number>;
|
|
104
|
+
/** Wipe the entire cache namespace (when the adapter supports `clear`). */
|
|
105
|
+
clear(): Promise<void>;
|
|
106
|
+
/** Expose the prefix so plugins building keys downstream stay aligned. */
|
|
107
|
+
get keyPrefix(): string;
|
|
108
|
+
/**
|
|
109
|
+
* Warm the cache for `key` if it's not already populated. On hit
|
|
110
|
+
* (fresh OR stale) returns the cached value; on miss runs `fetcher`,
|
|
111
|
+
* stores the result, and returns it. Single-flight guarantees apply
|
|
112
|
+
* — concurrent `prefetch` calls for the same key share one fetcher
|
|
113
|
+
* invocation.
|
|
114
|
+
*
|
|
115
|
+
* **Use case:** preload dashboards before the user request lands
|
|
116
|
+
* (route-level `prefetch` after auth, scheduled-job warmup, server-
|
|
117
|
+
* push hints from a CDN edge).
|
|
118
|
+
*
|
|
119
|
+
* **Difference from `engine.get` + manual write:** this one method
|
|
120
|
+
* handles the miss-fetch-store sequence atomically, with single-
|
|
121
|
+
* flight dedup. Mirrors TanStack Query's
|
|
122
|
+
* `queryClient.prefetchQuery({ queryKey, queryFn })`.
|
|
123
|
+
*/
|
|
124
|
+
prefetch<TData>(key: string, opts: ResolvedCacheOptions, fetcher: () => Promise<TData>): Promise<TData>;
|
|
125
|
+
}
|
|
126
|
+
//#endregion
|
|
127
|
+
export { CacheEngine, CacheEngineOptions, SingleFlightClaim };
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { buildEnvelope, inspectEnvelope } from "./envelope.mjs";
|
|
2
|
+
import { appendKeyToTags, invalidateByTags } from "./tag-index.mjs";
|
|
3
|
+
import { bumpModelVersion, getModelVersion } from "./version-store.mjs";
|
|
4
|
+
//#region src/cache/engine.ts
|
|
5
|
+
/**
|
|
6
|
+
* `CacheEngine` — the SWR + TTL + tag-invalidation behavior on top of
|
|
7
|
+
* a `CacheAdapter`. ONE implementation of the cache-flow primitives,
|
|
8
|
+
* shared across every kit + arc + Express/Nest hosts.
|
|
9
|
+
*
|
|
10
|
+
* Replaces three independent implementations:
|
|
11
|
+
* - mongokit's `withAggCache` (TTL/SWR/tag flow for aggregate)
|
|
12
|
+
* - mongokit's CRUD `cachePlugin` (TTL + version-bump for getById/getAll)
|
|
13
|
+
* - arc's `QueryCache` (TTL + SWR + version-bump + tag-version)
|
|
14
|
+
*
|
|
15
|
+
* **Production hardening (TanStack-inspired):**
|
|
16
|
+
* - **Single-flight on miss** — concurrent misses for the same key
|
|
17
|
+
* wait on the first fetch's promise (no cache stampede).
|
|
18
|
+
* - **Per-scope version-bump** — writes only invalidate the writing
|
|
19
|
+
* scope's cache, not other tenants' (targeted invalidation).
|
|
20
|
+
* - **Strictly-monotonic version** — same-millisecond writes never
|
|
21
|
+
* collide.
|
|
22
|
+
*
|
|
23
|
+
* Hosts compose this via `cachePlugin` (declarative, hook-driven) or
|
|
24
|
+
* call it directly when they need fine-grained control.
|
|
25
|
+
*/
|
|
26
|
+
var CacheEngine = class {
|
|
27
|
+
adapter;
|
|
28
|
+
prefix;
|
|
29
|
+
jitter;
|
|
30
|
+
/**
|
|
31
|
+
* In-flight fetches keyed by cache-key. Process-local (lives in this
|
|
32
|
+
* engine instance) — server restart clears it; cross-pod fanout is
|
|
33
|
+
* fine because each pod runs its own single-flight, and downstream
|
|
34
|
+
* load is bounded to N-pods worst case (a huge improvement over
|
|
35
|
+
* unbounded burst).
|
|
36
|
+
*/
|
|
37
|
+
pending = /* @__PURE__ */ new Map();
|
|
38
|
+
constructor(adapter, options = {}) {
|
|
39
|
+
this.adapter = adapter;
|
|
40
|
+
this.prefix = options.prefix ?? "rc";
|
|
41
|
+
this.jitter = resolveJitter(options.jitter);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Read a cache entry under SWR + TTL semantics. Returns a
|
|
45
|
+
* structured `CacheReadResult` describing freshness state — the
|
|
46
|
+
* caller decides whether to serve, revalidate, or fetch fresh.
|
|
47
|
+
*
|
|
48
|
+
* **State table:**
|
|
49
|
+
* - `enabled: false` → `{ status: 'disabled' }` — caller fetches
|
|
50
|
+
* - `bypass: true` → `{ status: 'bypass' }` — caller fetches
|
|
51
|
+
* - missing / expired → `{ status: 'miss' }` — caller fetches
|
|
52
|
+
* - fresh → `{ status: 'fresh', data }`
|
|
53
|
+
* - stale + swr=true → `{ status: 'stale', data }` — caller serves + bg-refreshes
|
|
54
|
+
* - stale + swr=false → `{ status: 'miss' }` — caller fetches
|
|
55
|
+
*/
|
|
56
|
+
async get(key, opts) {
|
|
57
|
+
if (!opts.enabled) return {
|
|
58
|
+
status: "disabled",
|
|
59
|
+
data: void 0
|
|
60
|
+
};
|
|
61
|
+
if (opts.bypass) return {
|
|
62
|
+
status: "bypass",
|
|
63
|
+
data: void 0
|
|
64
|
+
};
|
|
65
|
+
const inspection = inspectEnvelope(await this.adapter.get(key));
|
|
66
|
+
if (inspection.state === "missing" || inspection.state === "expired") return {
|
|
67
|
+
status: "miss",
|
|
68
|
+
data: void 0
|
|
69
|
+
};
|
|
70
|
+
const env = inspection.envelope;
|
|
71
|
+
if (!env) return {
|
|
72
|
+
status: "miss",
|
|
73
|
+
data: void 0
|
|
74
|
+
};
|
|
75
|
+
const ageSeconds = Math.floor((Date.now() - env.createdAt) / 1e3);
|
|
76
|
+
if (inspection.state === "fresh") return {
|
|
77
|
+
status: "fresh",
|
|
78
|
+
data: env.data,
|
|
79
|
+
age: ageSeconds
|
|
80
|
+
};
|
|
81
|
+
if (opts.swr) return {
|
|
82
|
+
status: "stale",
|
|
83
|
+
data: env.data,
|
|
84
|
+
age: ageSeconds
|
|
85
|
+
};
|
|
86
|
+
return {
|
|
87
|
+
status: "miss",
|
|
88
|
+
data: void 0
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Write `value` under `key` with the resolved options. Skips silently
|
|
93
|
+
* when `enabled: false` (no cache pollution from disabled calls).
|
|
94
|
+
*
|
|
95
|
+
* Side effect: appends `key` to the tag side-index for every tag in
|
|
96
|
+
* `opts.tags` so future `invalidateByTags` calls find it.
|
|
97
|
+
*/
|
|
98
|
+
async set(key, value, opts) {
|
|
99
|
+
if (!opts.enabled) return;
|
|
100
|
+
const tags = opts.tags;
|
|
101
|
+
const envelope = buildEnvelope(value, opts.staleTime, opts.gcTime, tags);
|
|
102
|
+
const totalSeconds = opts.staleTime + opts.gcTime;
|
|
103
|
+
const ttl = this.jitter(totalSeconds);
|
|
104
|
+
await this.adapter.set(key, envelope, ttl);
|
|
105
|
+
if (tags.length > 0) await appendKeyToTags(this.adapter, this.prefix, key, tags, ttl);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Look up an in-flight fetch for `key`. Returns the promise the
|
|
109
|
+
* first miss-claimer registered, or `undefined` when no fetch is
|
|
110
|
+
* pending.
|
|
111
|
+
*/
|
|
112
|
+
getPending(key) {
|
|
113
|
+
return this.pending.get(key)?.promise;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Atomically claim `key` for a fetch. Returns `'claimed'` when this
|
|
117
|
+
* caller owns the fetch (it must call `resolvePending` or
|
|
118
|
+
* `rejectPending` when done) or `{ status: 'wait', promise }` when
|
|
119
|
+
* another caller already claimed — the returned promise resolves
|
|
120
|
+
* with the first claimer's result.
|
|
121
|
+
*/
|
|
122
|
+
claimPending(key) {
|
|
123
|
+
const existing = this.pending.get(key);
|
|
124
|
+
if (existing) return {
|
|
125
|
+
status: "wait",
|
|
126
|
+
promise: existing.promise
|
|
127
|
+
};
|
|
128
|
+
this.pending.set(key, Promise.withResolvers());
|
|
129
|
+
return { status: "claimed" };
|
|
130
|
+
}
|
|
131
|
+
/** Resolve an in-flight claim with the fresh result + clear it. */
|
|
132
|
+
resolvePending(key, value) {
|
|
133
|
+
const deferred = this.pending.get(key);
|
|
134
|
+
if (!deferred) return;
|
|
135
|
+
this.pending.delete(key);
|
|
136
|
+
deferred.resolve(value);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Reject an in-flight claim — waiters fail-fast (they DON'T retry
|
|
140
|
+
* inline; they get the same error as the claimer). Caller's choice
|
|
141
|
+
* whether to retry on a higher level.
|
|
142
|
+
*/
|
|
143
|
+
rejectPending(key, error) {
|
|
144
|
+
const deferred = this.pending.get(key);
|
|
145
|
+
if (!deferred) return;
|
|
146
|
+
this.pending.delete(key);
|
|
147
|
+
deferred.reject(error);
|
|
148
|
+
}
|
|
149
|
+
/** Internal — number of in-flight fetches; observability hook. */
|
|
150
|
+
get pendingCount() {
|
|
151
|
+
return this.pending.size;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Invalidate every entry tagged with ANY of the provided tags. Reads
|
|
155
|
+
* each tag's index, deletes the listed cache entries, and clears
|
|
156
|
+
* the index. Returns the count of entries removed.
|
|
157
|
+
*/
|
|
158
|
+
async invalidateByTags(tags) {
|
|
159
|
+
return invalidateByTags(this.adapter, this.prefix, tags);
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Read a model's current version (optionally per-scope). Used by
|
|
163
|
+
* the plugin to embed `v<version>` into every cache key so a single
|
|
164
|
+
* version bump orphans the model's cache space.
|
|
165
|
+
*/
|
|
166
|
+
async getVersion(model, scopeKey) {
|
|
167
|
+
return getModelVersion(this.adapter, this.prefix, model, scopeKey);
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Bump the model's version (per-scope when `scopeKey` is supplied)
|
|
171
|
+
* to invalidate every cached read for it. Per-scope bumps don't
|
|
172
|
+
* affect other tenants' caches — TanStack-style targeted
|
|
173
|
+
* invalidation.
|
|
174
|
+
*/
|
|
175
|
+
async bumpVersion(model, scopeKey) {
|
|
176
|
+
return bumpModelVersion(this.adapter, this.prefix, model, scopeKey);
|
|
177
|
+
}
|
|
178
|
+
/** Wipe the entire cache namespace (when the adapter supports `clear`). */
|
|
179
|
+
async clear() {
|
|
180
|
+
if (this.adapter.clear) await this.adapter.clear(`${this.prefix}:*`);
|
|
181
|
+
}
|
|
182
|
+
/** Expose the prefix so plugins building keys downstream stay aligned. */
|
|
183
|
+
get keyPrefix() {
|
|
184
|
+
return this.prefix;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Warm the cache for `key` if it's not already populated. On hit
|
|
188
|
+
* (fresh OR stale) returns the cached value; on miss runs `fetcher`,
|
|
189
|
+
* stores the result, and returns it. Single-flight guarantees apply
|
|
190
|
+
* — concurrent `prefetch` calls for the same key share one fetcher
|
|
191
|
+
* invocation.
|
|
192
|
+
*
|
|
193
|
+
* **Use case:** preload dashboards before the user request lands
|
|
194
|
+
* (route-level `prefetch` after auth, scheduled-job warmup, server-
|
|
195
|
+
* push hints from a CDN edge).
|
|
196
|
+
*
|
|
197
|
+
* **Difference from `engine.get` + manual write:** this one method
|
|
198
|
+
* handles the miss-fetch-store sequence atomically, with single-
|
|
199
|
+
* flight dedup. Mirrors TanStack Query's
|
|
200
|
+
* `queryClient.prefetchQuery({ queryKey, queryFn })`.
|
|
201
|
+
*/
|
|
202
|
+
async prefetch(key, opts, fetcher) {
|
|
203
|
+
const result = await this.get(key, opts);
|
|
204
|
+
if (result.status === "fresh" || result.status === "stale") return result.data;
|
|
205
|
+
if (result.status === "miss") {
|
|
206
|
+
const claim = this.claimPending(key);
|
|
207
|
+
if (claim.status === "wait") return await claim.promise;
|
|
208
|
+
try {
|
|
209
|
+
const value = await fetcher();
|
|
210
|
+
await this.set(key, value, opts);
|
|
211
|
+
this.resolvePending(key, value);
|
|
212
|
+
return value;
|
|
213
|
+
} catch (err) {
|
|
214
|
+
this.rejectPending(key, err);
|
|
215
|
+
throw err;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const value = await fetcher();
|
|
219
|
+
if (opts.enabled) await this.set(key, value, opts);
|
|
220
|
+
return value;
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
function resolveJitter(jitter) {
|
|
224
|
+
if (!jitter) return (ttl) => ttl;
|
|
225
|
+
if (typeof jitter === "function") return (ttl) => Math.max(1, Math.round(jitter(ttl)));
|
|
226
|
+
const fraction = Math.min(1, Math.max(0, jitter));
|
|
227
|
+
if (fraction === 0) return (ttl) => ttl;
|
|
228
|
+
return (ttl) => {
|
|
229
|
+
const delta = ttl * fraction;
|
|
230
|
+
const jittered = ttl - delta + Math.random() * 2 * delta;
|
|
231
|
+
return Math.max(1, Math.round(jittered));
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
//#endregion
|
|
235
|
+
export { CacheEngine };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
//#region src/cache/envelope.ts
|
|
2
|
+
/** Build an envelope from raw data + freshness windows (seconds). */
|
|
3
|
+
function buildEnvelope(data, staleTimeSeconds, gcTimeSeconds, tags, now = Date.now()) {
|
|
4
|
+
const staleAfter = now + Math.max(0, staleTimeSeconds) * 1e3;
|
|
5
|
+
return {
|
|
6
|
+
version: 1,
|
|
7
|
+
data,
|
|
8
|
+
createdAt: now,
|
|
9
|
+
staleAfter,
|
|
10
|
+
expiresAt: staleAfter + Math.max(0, gcTimeSeconds) * 1e3,
|
|
11
|
+
tags
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Inspect an envelope against the current wall-clock time. Returns
|
|
16
|
+
* structured freshness state — caller chooses how to act on `'stale'`
|
|
17
|
+
* (serve + revalidate vs treat as miss) based on its SWR config.
|
|
18
|
+
*/
|
|
19
|
+
function inspectEnvelope(envelope, now = Date.now()) {
|
|
20
|
+
if (!envelope || envelope.version !== 1) return { state: "missing" };
|
|
21
|
+
if (now >= envelope.expiresAt) return { state: "expired" };
|
|
22
|
+
if (now < envelope.staleAfter) return {
|
|
23
|
+
state: "fresh",
|
|
24
|
+
envelope
|
|
25
|
+
};
|
|
26
|
+
return {
|
|
27
|
+
state: "stale",
|
|
28
|
+
envelope
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
//#endregion
|
|
32
|
+
export { buildEnvelope, inspectEnvelope };
|
package/dist/cache/index.d.mts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
+
import { CacheOptions, CacheReadResult } from "./options.mjs";
|
|
1
2
|
import { CacheAdapter } from "./types.mjs";
|
|
3
|
+
import { CacheEngine, CacheEngineOptions, SingleFlightClaim } from "./engine.mjs";
|
|
2
4
|
import { createMemoryCacheAdapter } from "./memory-adapter.mjs";
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
+
import { DEFAULT_SHAPE_KEYS_BY_OP } from "./plugin/context.mjs";
|
|
6
|
+
import { DEFAULT_CACHEABLE_OPS, DEFAULT_INVALIDATING_OPS, LogCallbacks, RepositoryCacheHandle, RepositoryCachePluginOptions, cachePlugin } from "./plugin/index.mjs";
|
|
7
|
+
import { scheduleBackground } from "./runtime.mjs";
|
|
8
|
+
import { CacheTimeoutError, TimeoutAdapterOptions, withTimeout } from "./timeout-adapter.mjs";
|
|
9
|
+
export { type CacheAdapter, CacheEngine, type CacheEngineOptions, type CacheOptions, type CacheReadResult, CacheTimeoutError, DEFAULT_CACHEABLE_OPS, DEFAULT_INVALIDATING_OPS, DEFAULT_SHAPE_KEYS_BY_OP, type LogCallbacks, type RepositoryCacheHandle, type RepositoryCachePluginOptions, type SingleFlightClaim, type TimeoutAdapterOptions, cachePlugin, createMemoryCacheAdapter, scheduleBackground, withTimeout };
|
package/dist/cache/index.mjs
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { CacheEngine } from "./engine.mjs";
|
|
1
2
|
import { createMemoryCacheAdapter } from "./memory-adapter.mjs";
|
|
2
|
-
import {
|
|
3
|
-
|
|
3
|
+
import { DEFAULT_SHAPE_KEYS_BY_OP } from "./plugin/context.mjs";
|
|
4
|
+
import { scheduleBackground } from "./runtime.mjs";
|
|
5
|
+
import { DEFAULT_CACHEABLE_OPS, DEFAULT_INVALIDATING_OPS, cachePlugin } from "./plugin/index.mjs";
|
|
6
|
+
import { CacheTimeoutError, withTimeout } from "./timeout-adapter.mjs";
|
|
7
|
+
export { CacheEngine, CacheTimeoutError, DEFAULT_CACHEABLE_OPS, DEFAULT_INVALIDATING_OPS, DEFAULT_SHAPE_KEYS_BY_OP, cachePlugin, createMemoryCacheAdapter, scheduleBackground, withTimeout };
|
|
@@ -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
|
}
|