@classytic/repo-core 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +243 -0
- package/dist/adapter/index.d.mts +3 -0
- package/dist/adapter/index.mjs +2 -0
- package/dist/adapter/types.d.mts +222 -0
- package/dist/adapter/widen.d.mts +22 -0
- package/dist/adapter/widen.mjs +26 -0
- package/dist/aggregate/index.d.mts +3 -0
- package/dist/aggregate/index.mjs +3 -0
- package/dist/aggregate/keyset.d.mts +57 -0
- package/dist/aggregate/keyset.mjs +45 -0
- package/dist/aggregate/normalize.d.mts +24 -0
- package/dist/aggregate/normalize.mjs +28 -0
- package/dist/better-auth/index.d.mts +110 -0
- package/dist/better-auth/index.mjs +71 -0
- package/dist/cache/engine.d.mts +127 -0
- package/dist/cache/engine.mjs +235 -0
- package/dist/cache/envelope.mjs +32 -0
- package/dist/cache/index.d.mts +7 -2
- package/dist/cache/index.mjs +6 -2
- package/dist/cache/keys.mjs +131 -0
- package/dist/cache/memory-adapter.mjs +41 -7
- package/dist/cache/options.d.mts +112 -0
- package/dist/cache/options.mjs +25 -0
- package/dist/cache/plugin/context.d.mts +18 -0
- package/dist/cache/plugin/context.mjs +121 -0
- package/dist/cache/plugin/index.d.mts +86 -0
- package/dist/cache/plugin/index.mjs +78 -0
- package/dist/cache/plugin/invalidation-hooks.mjs +35 -0
- package/dist/cache/plugin/read-hooks.mjs +96 -0
- package/dist/cache/plugin/swr.mjs +20 -0
- package/dist/cache/runtime.d.mts +43 -0
- package/dist/cache/runtime.mjs +14 -0
- package/dist/cache/tag-index.mjs +84 -0
- package/dist/cache/timeout-adapter.d.mts +30 -0
- package/dist/cache/timeout-adapter.mjs +58 -0
- package/dist/cache/types.d.mts +45 -0
- package/dist/cache/version-store.mjs +57 -0
- package/dist/errors/index.d.mts +2 -1
- package/dist/errors/index.mjs +2 -1
- package/dist/errors/schema.d.mts +101 -0
- package/dist/errors/schema.mjs +78 -0
- package/dist/filter/match.mjs +38 -2
- package/dist/pagination/canonical.d.mts +8 -8
- package/dist/pagination/canonical.mjs +3 -9
- package/dist/pagination/cursor.mjs +4 -1
- package/dist/pagination/index.d.mts +2 -2
- package/dist/pagination/types.d.mts +17 -27
- package/dist/plugins/index.d.mts +2 -0
- package/dist/plugins/index.mjs +2 -0
- package/dist/plugins/tenant-helpers.d.mts +63 -0
- package/dist/plugins/tenant-helpers.mjs +84 -0
- package/dist/query-parser/index.d.mts +2 -1
- package/dist/query-parser/index.mjs +2 -1
- package/dist/query-parser/parse-url.mjs +13 -11
- package/dist/query-parser/reserved.d.mts +43 -0
- package/dist/query-parser/reserved.mjs +56 -0
- package/dist/repository/agg-output.d.mts +63 -0
- package/dist/repository/agg-output.mjs +89 -0
- package/dist/repository/index.d.mts +4 -2
- package/dist/repository/index.mjs +3 -1
- package/dist/repository/options.d.mts +62 -0
- package/dist/repository/options.mjs +57 -0
- package/dist/repository/types.d.mts +935 -48
- package/dist/schema/field-rules.d.mts +41 -1
- package/dist/schema/field-rules.mjs +92 -1
- package/dist/schema/index.d.mts +2 -2
- package/dist/schema/index.mjs +2 -2
- package/dist/schema/types.d.mts +21 -0
- package/dist/testing/conformance.mjs +666 -17
- package/dist/testing/index.d.mts +2 -2
- package/dist/testing/types.d.mts +99 -2
- package/package.json +19 -1
- package/dist/cache/stable-stringify.d.mts +0 -15
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { AggRequest } from "../repository/types.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/aggregate/normalize.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Normalize `AggRequest['groupBy']` into a readonly string array.
|
|
6
|
+
* Returns `[]` for scalar aggregation (no groupBy). Downstream
|
|
7
|
+
* compilers treat `[]` uniformly — Mongo emits `$group: { _id: null }`,
|
|
8
|
+
* SQL emits a single SELECT without a GROUP BY clause.
|
|
9
|
+
*/
|
|
10
|
+
declare function normalizeGroupBy(groupBy: AggRequest['groupBy']): readonly string[];
|
|
11
|
+
/**
|
|
12
|
+
* Fail loud on an empty measures bag — there's nothing to compute and
|
|
13
|
+
* the caller's code path is almost certainly a wiring bug (conditional
|
|
14
|
+
* collapsed, key renamed, etc.). Silently returning `{ rows: [] }`
|
|
15
|
+
* would mask it.
|
|
16
|
+
*
|
|
17
|
+
* The `kitName` prefix (`'mongokit'`, `'sqlitekit'`, ...) on the error
|
|
18
|
+
* message keeps stack-trace context legible — when a developer sees
|
|
19
|
+
* the throw they know which kit's compiler raised it without having
|
|
20
|
+
* to walk the trace.
|
|
21
|
+
*/
|
|
22
|
+
declare function validateMeasures(measures: AggRequest['measures'], kitName: string): void;
|
|
23
|
+
//#endregion
|
|
24
|
+
export { normalizeGroupBy, validateMeasures };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
//#region src/aggregate/normalize.ts
|
|
2
|
+
/**
|
|
3
|
+
* Normalize `AggRequest['groupBy']` into a readonly string array.
|
|
4
|
+
* Returns `[]` for scalar aggregation (no groupBy). Downstream
|
|
5
|
+
* compilers treat `[]` uniformly — Mongo emits `$group: { _id: null }`,
|
|
6
|
+
* SQL emits a single SELECT without a GROUP BY clause.
|
|
7
|
+
*/
|
|
8
|
+
function normalizeGroupBy(groupBy) {
|
|
9
|
+
if (!groupBy) return [];
|
|
10
|
+
if (typeof groupBy === "string") return [groupBy];
|
|
11
|
+
return groupBy;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Fail loud on an empty measures bag — there's nothing to compute and
|
|
15
|
+
* the caller's code path is almost certainly a wiring bug (conditional
|
|
16
|
+
* collapsed, key renamed, etc.). Silently returning `{ rows: [] }`
|
|
17
|
+
* would mask it.
|
|
18
|
+
*
|
|
19
|
+
* The `kitName` prefix (`'mongokit'`, `'sqlitekit'`, ...) on the error
|
|
20
|
+
* message keeps stack-trace context legible — when a developer sees
|
|
21
|
+
* the throw they know which kit's compiler raised it without having
|
|
22
|
+
* to walk the trace.
|
|
23
|
+
*/
|
|
24
|
+
function validateMeasures(measures, kitName) {
|
|
25
|
+
if (!measures || Object.keys(measures).length === 0) throw new Error(`${kitName}/aggregate: AggRequest requires at least one measure — empty measures bag is a wiring bug`);
|
|
26
|
+
}
|
|
27
|
+
//#endregion
|
|
28
|
+
export { normalizeGroupBy, validateMeasures };
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
//#region src/better-auth/index.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Better Auth — kit-agnostic registry of which collections each plugin owns.
|
|
4
|
+
*
|
|
5
|
+
* This module ships **zero DB code**. It's the source of truth that every
|
|
6
|
+
* kit's `better-auth` overlay subpath consumes (`@classytic/mongokit/better-auth`,
|
|
7
|
+
* `@classytic/sqlitekit/better-auth`, future `@classytic/prismakit/better-auth`)
|
|
8
|
+
* so the per-kit overlays don't each maintain their own copy of the
|
|
9
|
+
* plugin → collection list.
|
|
10
|
+
*
|
|
11
|
+
* Why repo-core: kits depend on repo-core; arc/hosts consume kits. Putting the
|
|
12
|
+
* registry one level below the kit layer keeps it accessible to every kit
|
|
13
|
+
* without forcing kits to peer-dep each other or to peer-dep arc.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```ts
|
|
17
|
+
* import { resolveBetterAuthCollections } from '@classytic/repo-core/better-auth';
|
|
18
|
+
*
|
|
19
|
+
* const names = resolveBetterAuthCollections({
|
|
20
|
+
* plugins: ['organization'],
|
|
21
|
+
* extraCollections: ['passkey'],
|
|
22
|
+
* });
|
|
23
|
+
* // → ['user', 'session', 'account', 'verification', 'organization', 'member', 'invitation', 'passkey']
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* Plugin keys that map to Better Auth collection sets.
|
|
28
|
+
*
|
|
29
|
+
* Only plugins that ship inside the **core `better-auth` package** are listed
|
|
30
|
+
* here. Plugins distributed as separate `@better-auth/*` packages
|
|
31
|
+
* (api-key, passkey, sso, oauth-provider, etc.) evolve independently and
|
|
32
|
+
* should be handled via `extraCollections` — see `resolveBetterAuthCollections`.
|
|
33
|
+
*
|
|
34
|
+
* Plugins that only add *fields* to existing tables (admin, username,
|
|
35
|
+
* phoneNumber, magicLink, emailOtp, anonymous, bearer, multiSession, siwe,
|
|
36
|
+
* lastLoginMethod, genericOAuth, etc.) don't need an entry — kit overlays
|
|
37
|
+
* register schemas with `strict: false` (Mongoose) or pass-through column
|
|
38
|
+
* mappings (SQL), so extra fields round-trip automatically.
|
|
39
|
+
*
|
|
40
|
+
* - `core` — always included; covers `user`, `session`, `account`, `verification`.
|
|
41
|
+
* - `organization` — adds `organization`, `member`, `invitation`.
|
|
42
|
+
* - `organization-teams` — adds `team`, `teamMember` (only when `teams.enabled`).
|
|
43
|
+
* - `twoFactor` — adds `twoFactor`.
|
|
44
|
+
* - `jwt` — adds `jwks`.
|
|
45
|
+
* - `oidcProvider` — adds `oauthApplication`, `oauthAccessToken`, `oauthConsent`.
|
|
46
|
+
* - `oauthProvider` — alias of `oidcProvider` (same schema).
|
|
47
|
+
* - `mcp` — MCP plugin reuses the oidcProvider schema (BA docs are explicit).
|
|
48
|
+
* - `deviceAuthorization` — adds `deviceCode` (RFC 8628 device authorization).
|
|
49
|
+
*/
|
|
50
|
+
type BetterAuthPluginKey = 'core' | 'organization' | 'organization-teams' | 'twoFactor' | 'jwt' | 'oidcProvider' | 'oauthProvider' | 'mcp' | 'deviceAuthorization';
|
|
51
|
+
/**
|
|
52
|
+
* Canonical collection lists per plugin. `core` is always implied by
|
|
53
|
+
* `resolveBetterAuthCollections` — callers don't need to pass it.
|
|
54
|
+
*
|
|
55
|
+
* Naming follows BA's mongo adapter (`usePlural: false`) convention. Hosts
|
|
56
|
+
* that opted into `usePlural: true` should pass that flag to the kit overlay,
|
|
57
|
+
* which appends `s` to each name (`user` → `users`).
|
|
58
|
+
*/
|
|
59
|
+
declare const BA_COLLECTIONS_BY_PLUGIN: Record<BetterAuthPluginKey, readonly string[]>;
|
|
60
|
+
/**
|
|
61
|
+
* Naive English pluralization that matches Better Auth's `usePlural` behavior:
|
|
62
|
+
* BA's mongo adapter just appends `s` (it doesn't handle irregular nouns —
|
|
63
|
+
* none of its collection names are irregular). Kit overlays mirror this.
|
|
64
|
+
*/
|
|
65
|
+
declare function pluralizeBetterAuthCollection(name: string): string;
|
|
66
|
+
interface ResolveBetterAuthCollectionsOptions {
|
|
67
|
+
/**
|
|
68
|
+
* Which plugin collection sets to include. `core` is always implied —
|
|
69
|
+
* you don't need to pass it.
|
|
70
|
+
*
|
|
71
|
+
* @default []
|
|
72
|
+
*/
|
|
73
|
+
plugins?: BetterAuthPluginKey[];
|
|
74
|
+
/**
|
|
75
|
+
* Additional collection names beyond the built-in plugin set.
|
|
76
|
+
*
|
|
77
|
+
* Use for plugins that ship as separate `@better-auth/*` packages — their
|
|
78
|
+
* collection names live in their own packages and are intentionally not
|
|
79
|
+
* hardcoded here so they can evolve independently.
|
|
80
|
+
*
|
|
81
|
+
* Known names for official separate-package plugins:
|
|
82
|
+
* - `@better-auth/passkey` → `'passkey'`
|
|
83
|
+
* - `@better-auth/sso` → `'ssoProvider'`
|
|
84
|
+
* - `@better-auth/oauth-provider` → already covered by `plugins: ['oauthProvider']`
|
|
85
|
+
*
|
|
86
|
+
* @default []
|
|
87
|
+
*/
|
|
88
|
+
extraCollections?: string[];
|
|
89
|
+
/**
|
|
90
|
+
* When BA's adapter was configured with `usePlural: true`, every name is
|
|
91
|
+
* pluralized (`user` → `users`). Must match what you passed to BA's adapter.
|
|
92
|
+
*
|
|
93
|
+
* @default false
|
|
94
|
+
*/
|
|
95
|
+
usePlural?: boolean;
|
|
96
|
+
/**
|
|
97
|
+
* Per-collection name override. Applies AFTER pluralization. Use when
|
|
98
|
+
* you've passed `user: { modelName: 'profile' }` (or similar) to
|
|
99
|
+
* `betterAuth()` — pass the same map here so downstream consumers
|
|
100
|
+
* (kit overlays, populate resolution) line up.
|
|
101
|
+
*/
|
|
102
|
+
modelOverrides?: Partial<Record<string, string>>;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Resolve a plugin set + extras to a deduplicated, ordered list of
|
|
106
|
+
* collection names. `core` is always included.
|
|
107
|
+
*/
|
|
108
|
+
declare function resolveBetterAuthCollections(options?: ResolveBetterAuthCollectionsOptions): string[];
|
|
109
|
+
//#endregion
|
|
110
|
+
export { BA_COLLECTIONS_BY_PLUGIN, BetterAuthPluginKey, ResolveBetterAuthCollectionsOptions, pluralizeBetterAuthCollection, resolveBetterAuthCollections };
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
//#region src/better-auth/index.ts
|
|
2
|
+
/**
|
|
3
|
+
* Canonical collection lists per plugin. `core` is always implied by
|
|
4
|
+
* `resolveBetterAuthCollections` — callers don't need to pass it.
|
|
5
|
+
*
|
|
6
|
+
* Naming follows BA's mongo adapter (`usePlural: false`) convention. Hosts
|
|
7
|
+
* that opted into `usePlural: true` should pass that flag to the kit overlay,
|
|
8
|
+
* which appends `s` to each name (`user` → `users`).
|
|
9
|
+
*/
|
|
10
|
+
const BA_COLLECTIONS_BY_PLUGIN = {
|
|
11
|
+
core: [
|
|
12
|
+
"user",
|
|
13
|
+
"session",
|
|
14
|
+
"account",
|
|
15
|
+
"verification"
|
|
16
|
+
],
|
|
17
|
+
organization: [
|
|
18
|
+
"organization",
|
|
19
|
+
"member",
|
|
20
|
+
"invitation"
|
|
21
|
+
],
|
|
22
|
+
"organization-teams": ["team", "teamMember"],
|
|
23
|
+
twoFactor: ["twoFactor"],
|
|
24
|
+
jwt: ["jwks"],
|
|
25
|
+
oidcProvider: [
|
|
26
|
+
"oauthApplication",
|
|
27
|
+
"oauthAccessToken",
|
|
28
|
+
"oauthConsent"
|
|
29
|
+
],
|
|
30
|
+
oauthProvider: [
|
|
31
|
+
"oauthApplication",
|
|
32
|
+
"oauthAccessToken",
|
|
33
|
+
"oauthConsent"
|
|
34
|
+
],
|
|
35
|
+
mcp: [
|
|
36
|
+
"oauthApplication",
|
|
37
|
+
"oauthAccessToken",
|
|
38
|
+
"oauthConsent"
|
|
39
|
+
],
|
|
40
|
+
deviceAuthorization: ["deviceCode"]
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Naive English pluralization that matches Better Auth's `usePlural` behavior:
|
|
44
|
+
* BA's mongo adapter just appends `s` (it doesn't handle irregular nouns —
|
|
45
|
+
* none of its collection names are irregular). Kit overlays mirror this.
|
|
46
|
+
*/
|
|
47
|
+
function pluralizeBetterAuthCollection(name) {
|
|
48
|
+
return name.endsWith("s") ? name : `${name}s`;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Resolve a plugin set + extras to a deduplicated, ordered list of
|
|
52
|
+
* collection names. `core` is always included.
|
|
53
|
+
*/
|
|
54
|
+
function resolveBetterAuthCollections(options = {}) {
|
|
55
|
+
const { plugins = [], extraCollections = [], usePlural = false, modelOverrides = {} } = options;
|
|
56
|
+
const pluginSet = new Set(["core", ...plugins]);
|
|
57
|
+
const collected = [];
|
|
58
|
+
for (const key of pluginSet) for (const name of BA_COLLECTIONS_BY_PLUGIN[key]) collected.push(name);
|
|
59
|
+
for (const name of extraCollections) collected.push(name);
|
|
60
|
+
const seen = /* @__PURE__ */ new Set();
|
|
61
|
+
const unique = [];
|
|
62
|
+
for (const canonical of collected) {
|
|
63
|
+
if (seen.has(canonical)) continue;
|
|
64
|
+
seen.add(canonical);
|
|
65
|
+
const finalName = modelOverrides[canonical] ?? (usePlural ? pluralizeBetterAuthCollection(canonical) : canonical);
|
|
66
|
+
unique.push(finalName);
|
|
67
|
+
}
|
|
68
|
+
return unique;
|
|
69
|
+
}
|
|
70
|
+
//#endregion
|
|
71
|
+
export { BA_COLLECTIONS_BY_PLUGIN, pluralizeBetterAuthCollection, resolveBetterAuthCollections };
|
|
@@ -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 };
|