@classytic/repo-core 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/CHANGELOG.md +243 -0
  2. package/dist/adapter/index.d.mts +3 -0
  3. package/dist/adapter/index.mjs +2 -0
  4. package/dist/adapter/types.d.mts +222 -0
  5. package/dist/adapter/widen.d.mts +22 -0
  6. package/dist/adapter/widen.mjs +26 -0
  7. package/dist/aggregate/index.d.mts +3 -0
  8. package/dist/aggregate/index.mjs +3 -0
  9. package/dist/aggregate/keyset.d.mts +57 -0
  10. package/dist/aggregate/keyset.mjs +45 -0
  11. package/dist/aggregate/normalize.d.mts +24 -0
  12. package/dist/aggregate/normalize.mjs +28 -0
  13. package/dist/better-auth/index.d.mts +110 -0
  14. package/dist/better-auth/index.mjs +71 -0
  15. package/dist/cache/engine.d.mts +127 -0
  16. package/dist/cache/engine.mjs +235 -0
  17. package/dist/cache/envelope.mjs +32 -0
  18. package/dist/cache/index.d.mts +7 -2
  19. package/dist/cache/index.mjs +6 -2
  20. package/dist/cache/keys.mjs +131 -0
  21. package/dist/cache/memory-adapter.mjs +41 -7
  22. package/dist/cache/options.d.mts +112 -0
  23. package/dist/cache/options.mjs +25 -0
  24. package/dist/cache/plugin/context.d.mts +18 -0
  25. package/dist/cache/plugin/context.mjs +121 -0
  26. package/dist/cache/plugin/index.d.mts +86 -0
  27. package/dist/cache/plugin/index.mjs +78 -0
  28. package/dist/cache/plugin/invalidation-hooks.mjs +35 -0
  29. package/dist/cache/plugin/read-hooks.mjs +96 -0
  30. package/dist/cache/plugin/swr.mjs +20 -0
  31. package/dist/cache/runtime.d.mts +43 -0
  32. package/dist/cache/runtime.mjs +14 -0
  33. package/dist/cache/tag-index.mjs +84 -0
  34. package/dist/cache/timeout-adapter.d.mts +30 -0
  35. package/dist/cache/timeout-adapter.mjs +58 -0
  36. package/dist/cache/types.d.mts +45 -0
  37. package/dist/cache/version-store.mjs +57 -0
  38. package/dist/errors/index.d.mts +2 -1
  39. package/dist/errors/index.mjs +2 -1
  40. package/dist/errors/schema.d.mts +101 -0
  41. package/dist/errors/schema.mjs +78 -0
  42. package/dist/filter/match.mjs +38 -2
  43. package/dist/pagination/canonical.d.mts +8 -8
  44. package/dist/pagination/canonical.mjs +3 -9
  45. package/dist/pagination/cursor.mjs +4 -1
  46. package/dist/pagination/index.d.mts +2 -2
  47. package/dist/pagination/types.d.mts +17 -27
  48. package/dist/plugins/index.d.mts +2 -0
  49. package/dist/plugins/index.mjs +2 -0
  50. package/dist/plugins/tenant-helpers.d.mts +63 -0
  51. package/dist/plugins/tenant-helpers.mjs +84 -0
  52. package/dist/query-parser/index.d.mts +2 -1
  53. package/dist/query-parser/index.mjs +2 -1
  54. package/dist/query-parser/parse-url.mjs +13 -11
  55. package/dist/query-parser/reserved.d.mts +43 -0
  56. package/dist/query-parser/reserved.mjs +56 -0
  57. package/dist/repository/agg-output.d.mts +63 -0
  58. package/dist/repository/agg-output.mjs +89 -0
  59. package/dist/repository/index.d.mts +4 -2
  60. package/dist/repository/index.mjs +3 -1
  61. package/dist/repository/options.d.mts +62 -0
  62. package/dist/repository/options.mjs +57 -0
  63. package/dist/repository/types.d.mts +935 -48
  64. package/dist/schema/field-rules.d.mts +41 -1
  65. package/dist/schema/field-rules.mjs +92 -1
  66. package/dist/schema/index.d.mts +2 -2
  67. package/dist/schema/index.mjs +2 -2
  68. package/dist/schema/types.d.mts +21 -0
  69. package/dist/testing/conformance.mjs +666 -17
  70. package/dist/testing/index.d.mts +2 -2
  71. package/dist/testing/types.d.mts +99 -2
  72. package/package.json +19 -1
  73. package/dist/cache/stable-stringify.d.mts +0 -15
@@ -0,0 +1,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 };
@@ -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 { stableStringify } from "./stable-stringify.mjs";
4
- export { type CacheAdapter, createMemoryCacheAdapter, stableStringify };
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 };
@@ -1,3 +1,7 @@
1
+ import { CacheEngine } from "./engine.mjs";
1
2
  import { createMemoryCacheAdapter } from "./memory-adapter.mjs";
2
- import { stableStringify } from "./stable-stringify.mjs";
3
- export { createMemoryCacheAdapter, stableStringify };
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 };