@classytic/repo-core 0.23.0 → 0.25.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 CHANGED
@@ -4,6 +4,28 @@ All notable changes to `@classytic/repo-core` are documented here.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.25.0] - 2026-08-24
8
+
9
+ ### Added
10
+
11
+ - **`CacheAdapterResolver = () => CacheAdapter | undefined`** — a function consulted per call that returns the store for the current scope, or `undefined` for "no cache right now". `undefined` is inert — identical to `enabled: false` — and never falls back to a shared store, because that fallback is the cross-scope leak the scoping exists to prevent.
12
+ - **`CacheAdapterSource = CacheAdapter | CacheAdapterResolver`** — `CacheEngine` and `cachePlugin` now accept either a fixed process-lifetime adapter (unchanged behaviour) or a resolver. A bare adapter is sugar for `() => adapter`; normalised at construction so every call site is on one path.
13
+ - **`CacheAdapterResolver` and `CacheAdapterSource` exported from `@classytic/repo-core/cache`** and re-exported from the plugin index.
14
+
15
+ ## [0.24.0] - 2026-08-17
16
+
17
+ Version bump only — republished 0.23.1's contents under a minor so consumers pinning `>=0.24.0` get the tenant guard by a floor rather than a patch. No source changes over 0.23.1.
18
+
19
+ ## [0.23.1] - 2026-08-17
20
+
21
+ ### Added — `assertNoLegacyTenantKeys(config, pkg, extra?)` (`/tenant`)
22
+
23
+ Refuses a config still carrying a pre-consolidation tenant key (`multiTenant`, plus any package-specific retirees passed via `extra`) instead of ignoring it.
24
+
25
+ The rename onto `TenantConfig` under `tenant` is trivial; the failure mode when a CALLER misses it is not. Every kernel resolver reads `resolveTenantConfig(config.tenant ?? false)`, so an absent `tenant` resolves to `strategy: 'none'` — no tenant field, no tenant filter, every read spanning ALL tenants, no error, and figures that look plausible. A host that asked for tenancy silently gets none.
26
+
27
+ The guard lives beside the resolver every one of those packages already calls, rather than being hand-rolled nine times — nine chances to forget, and the one that forgets ships the leak. Additive and non-breaking: nothing calls it until a package renames.
28
+
7
29
  ## [0.23.0] - 2026-08-13
8
30
 
9
31
  ### Added — transactional-core contracts (Phase 1a + 1d) + fencing (Phase 2, first slice)
@@ -22,8 +22,36 @@ type SingleFlightClaim<T = unknown> = {
22
22
  readonly status: 'wait';
23
23
  readonly promise: Promise<T>;
24
24
  };
25
+ /**
26
+ * Resolve the adapter to use for THIS call, or `undefined` for "no cache
27
+ * right now".
28
+ *
29
+ * Exists so a cache can be scoped to something narrower than the process —
30
+ * a request, a job run, a unit of work — without this package learning what
31
+ * any of those are. The resolver is the whole seam: repo-core asks "is there
32
+ * a store for the current scope?", and whoever owns the lifecycle answers.
33
+ *
34
+ * **`undefined` must mean INERT, never "make one".** A resolver that returns
35
+ * nothing outside its scope is the correct, safe answer for cron jobs,
36
+ * scripts and tests — the alternative (falling back to a process-wide store)
37
+ * is exactly the cross-request leak the scoping exists to prevent, and it
38
+ * would be invisible.
39
+ */
40
+ type CacheAdapterResolver = () => CacheAdapter | undefined;
41
+ /**
42
+ * Either a fixed adapter (process-lifetime, the original contract) or a
43
+ * resolver consulted per call. A bare adapter is sugar for `() => adapter`.
44
+ */
45
+ type CacheAdapterSource = CacheAdapter | CacheAdapterResolver;
25
46
  declare class CacheEngine {
26
- private readonly adapter;
47
+ /**
48
+ * Consulted PER CALL, never cached in a field.
49
+ *
50
+ * Memoising the first resolution would defeat the entire purpose: the
51
+ * second request would be served the first request's store. The whole
52
+ * point of the indirection is that the answer changes.
53
+ */
54
+ private readonly resolveAdapter;
27
55
  private readonly prefix;
28
56
  private readonly jitter;
29
57
  /**
@@ -34,7 +62,7 @@ declare class CacheEngine {
34
62
  * unbounded burst).
35
63
  */
36
64
  private readonly pending;
37
- constructor(adapter: CacheAdapter, options?: CacheEngineOptions);
65
+ constructor(adapter: CacheAdapterSource, options?: CacheEngineOptions);
38
66
  /**
39
67
  * Read a cache entry under SWR + TTL semantics. Returns a
40
68
  * structured `CacheReadResult` describing freshness state — the
@@ -123,4 +151,4 @@ declare class CacheEngine {
123
151
  prefetch<TData>(key: string, opts: ResolvedCacheOptions, fetcher: () => Promise<TData>): Promise<TData>;
124
152
  }
125
153
  //#endregion
126
- export { CacheEngine, CacheEngineOptions, SingleFlightClaim };
154
+ export { CacheAdapterResolver, CacheAdapterSource, CacheEngine, CacheEngineOptions, SingleFlightClaim };
@@ -24,7 +24,14 @@ import { bumpModelVersion, getModelVersion } from "./version-store.mjs";
24
24
  * call it directly when they need fine-grained control.
25
25
  */
26
26
  var CacheEngine = class {
27
- adapter;
27
+ /**
28
+ * Consulted PER CALL, never cached in a field.
29
+ *
30
+ * Memoising the first resolution would defeat the entire purpose: the
31
+ * second request would be served the first request's store. The whole
32
+ * point of the indirection is that the answer changes.
33
+ */
34
+ resolveAdapter;
28
35
  prefix;
29
36
  jitter;
30
37
  /**
@@ -36,7 +43,7 @@ var CacheEngine = class {
36
43
  */
37
44
  pending = /* @__PURE__ */ new Map();
38
45
  constructor(adapter, options = {}) {
39
- this.adapter = adapter;
46
+ this.resolveAdapter = typeof adapter === "function" ? adapter : () => adapter;
40
47
  this.prefix = options.prefix ?? "rc";
41
48
  this.jitter = resolveJitter(options.jitter);
42
49
  }
@@ -62,7 +69,12 @@ var CacheEngine = class {
62
69
  status: "bypass",
63
70
  data: void 0
64
71
  };
65
- const raw = await this.adapter.get(key);
72
+ const adapter = this.resolveAdapter();
73
+ if (!adapter) return {
74
+ status: "disabled",
75
+ data: void 0
76
+ };
77
+ const raw = await adapter.get(key);
66
78
  const inspection = inspectEnvelope(raw);
67
79
  if (inspection.state === "missing" || inspection.state === "expired") return {
68
80
  status: "miss",
@@ -98,12 +110,14 @@ var CacheEngine = class {
98
110
  */
99
111
  async set(key, value, opts) {
100
112
  if (!opts.enabled) return;
113
+ const adapter = this.resolveAdapter();
114
+ if (!adapter) return;
101
115
  const tags = opts.tags;
102
116
  const envelope = buildEnvelope(value, opts.staleTime, opts.gcTime, tags);
103
117
  const totalSeconds = opts.staleTime + opts.gcTime;
104
118
  const ttl = this.jitter(totalSeconds);
105
- await this.adapter.set(key, envelope, ttl);
106
- if (tags.length > 0) await appendKeyToTags(this.adapter, this.prefix, key, tags, ttl);
119
+ await adapter.set(key, envelope, ttl);
120
+ if (tags.length > 0) await appendKeyToTags(adapter, this.prefix, key, tags, ttl);
107
121
  }
108
122
  /**
109
123
  * Look up an in-flight fetch for `key`. Returns the promise the
@@ -157,7 +171,9 @@ var CacheEngine = class {
157
171
  * the index. Returns the count of entries removed.
158
172
  */
159
173
  async invalidateByTags(tags) {
160
- return invalidateByTags(this.adapter, this.prefix, tags);
174
+ const adapter = this.resolveAdapter();
175
+ if (!adapter) return 0;
176
+ return invalidateByTags(adapter, this.prefix, tags);
161
177
  }
162
178
  /**
163
179
  * Read a model's current version (optionally per-scope). Used by
@@ -165,7 +181,9 @@ var CacheEngine = class {
165
181
  * version bump orphans the model's cache space.
166
182
  */
167
183
  async getVersion(model, scopeKey) {
168
- return getModelVersion(this.adapter, this.prefix, model, scopeKey);
184
+ const adapter = this.resolveAdapter();
185
+ if (!adapter) return 0;
186
+ return getModelVersion(adapter, this.prefix, model, scopeKey);
169
187
  }
170
188
  /**
171
189
  * Bump the model's version (per-scope when `scopeKey` is supplied)
@@ -174,11 +192,15 @@ var CacheEngine = class {
174
192
  * invalidation.
175
193
  */
176
194
  async bumpVersion(model, scopeKey) {
177
- return bumpModelVersion(this.adapter, this.prefix, model, scopeKey);
195
+ const adapter = this.resolveAdapter();
196
+ if (!adapter) return 0;
197
+ return bumpModelVersion(adapter, this.prefix, model, scopeKey);
178
198
  }
179
199
  /** Wipe the entire cache namespace (when the adapter supports `clear`). */
180
200
  async clear() {
181
- if (this.adapter.clear) await this.adapter.clear(`${this.prefix}:*`);
201
+ const adapter = this.resolveAdapter();
202
+ if (!adapter) return;
203
+ if (adapter.clear) await adapter.clear(`${this.prefix}:*`);
182
204
  }
183
205
  /** Expose the prefix so plugins building keys downstream stay aligned. */
184
206
  get keyPrefix() {
@@ -1,9 +1,9 @@
1
1
  import { CacheOptions, CacheReadResult } from "./options.mjs";
2
2
  import { CacheAdapter } from "./types.mjs";
3
- import { CacheEngine, CacheEngineOptions, SingleFlightClaim } from "./engine.mjs";
3
+ import { CacheAdapterResolver, CacheAdapterSource, CacheEngine, CacheEngineOptions, SingleFlightClaim } from "./engine.mjs";
4
4
  import { MemoryCacheAdapterOptions, createMemoryCacheAdapter } from "./memory-adapter.mjs";
5
5
  import { DEFAULT_SHAPE_KEYS_BY_OP } from "./plugin/context.mjs";
6
6
  import { DEFAULT_CACHEABLE_OPS, DEFAULT_INVALIDATING_OPS, LogCallbacks, RepositoryCacheHandle, RepositoryCachePluginOptions, cachePlugin } from "./plugin/index.mjs";
7
7
  import { scheduleBackground } from "./runtime.mjs";
8
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 MemoryCacheAdapterOptions, type RepositoryCacheHandle, type RepositoryCachePluginOptions, type SingleFlightClaim, type TimeoutAdapterOptions, cachePlugin, createMemoryCacheAdapter, scheduleBackground, withTimeout };
9
+ export { type CacheAdapter, type CacheAdapterResolver, type CacheAdapterSource, CacheEngine, type CacheEngineOptions, type CacheOptions, type CacheReadResult, CacheTimeoutError, DEFAULT_CACHEABLE_OPS, DEFAULT_INVALIDATING_OPS, DEFAULT_SHAPE_KEYS_BY_OP, type LogCallbacks, type MemoryCacheAdapterOptions, type RepositoryCacheHandle, type RepositoryCachePluginOptions, type SingleFlightClaim, type TimeoutAdapterOptions, cachePlugin, createMemoryCacheAdapter, scheduleBackground, withTimeout };
@@ -1,7 +1,6 @@
1
1
  import { Plugin } from "../../repository/plugin-types.mjs";
2
2
  import { CacheOptions, CacheReadResult } from "../options.mjs";
3
- import { CacheAdapter } from "../types.mjs";
4
- import { CacheEngine } from "../engine.mjs";
3
+ import { CacheAdapterSource, CacheEngine } from "../engine.mjs";
5
4
  import { DEFAULT_SHAPE_KEYS_BY_OP } from "./context.mjs";
6
5
  //#region src/cache/plugin/index.d.ts
7
6
  /** Default read ops the plugin caches. Kits may override per resource. */
@@ -24,8 +23,27 @@ interface LogCallbacks {
24
23
  onInvalidate?: (model: string, version: number, tagCount: number) => void;
25
24
  }
26
25
  interface RepositoryCachePluginOptions {
27
- /** Concrete adapter — Redis, in-memory, custom KV. */
28
- readonly adapter: CacheAdapter;
26
+ /**
27
+ * Concrete adapter — Redis, in-memory, custom KV — OR a resolver consulted
28
+ * per call that returns the store for the current scope.
29
+ *
30
+ * The resolver form is what makes a cache narrower than the process
31
+ * possible (per-request, per-job, per-unit-of-work) without this package
32
+ * knowing what those are. Returning `undefined` means "no cache right now"
33
+ * and the call behaves exactly as `enabled: false` — it does not fall back
34
+ * to a shared store, because that fallback IS the cross-scope leak the
35
+ * scoping exists to prevent.
36
+ *
37
+ * @example Process-lifetime (unchanged)
38
+ * ```ts
39
+ * cachePlugin({ adapter: createMemoryCacheAdapter() })
40
+ * ```
41
+ * @example Request-scoped — dies with the request, no TTL needed
42
+ * ```ts
43
+ * cachePlugin({ adapter: () => requestScopedCache() })
44
+ * ```
45
+ */
46
+ readonly adapter: CacheAdapterSource;
29
47
  /** Read ops the plugin caches. Default: every op in `DEFAULT_CACHEABLE_OPS`. */
30
48
  readonly enabled?: readonly string[];
31
49
  /** Mutating ops that trigger invalidation. Default: every op in `DEFAULT_INVALIDATING_OPS`. */
@@ -85,10 +85,27 @@ interface CleanupStepExecuteContext extends CleanupStepContext {
85
85
  * One preview line — maps 1:1 onto a host plan item. A business record class
86
86
  * (`'sales facts'`, `'journal entries'`), never a collection name.
87
87
  */
88
+ /**
89
+ * What a step's `estimated` COUNTS. Absent ⇒ `'remove'`.
90
+ *
91
+ * `destructive: false` is NOT a substitute: it is true of both a protective
92
+ * guard (counts records it defends) and a projection rebuild (counts records it
93
+ * recomputes), and those mean opposite things in a "records to remove" headline.
94
+ * A guard reporting 173 protected journal entries once pushed that headline to
95
+ * 540 on a plan that removed 367 — a plausible, internally consistent, wrong
96
+ * number shown at the exact moment an operator authorises destruction.
97
+ */
98
+ type CleanupStepDisposition = 'remove' | 'protect' | 'rebuild';
88
99
  interface CleanupStepEstimate {
89
100
  readonly resource: string;
90
101
  /** Estimated records this step would affect. */
91
102
  readonly estimated: number;
103
+ /**
104
+ * Whether `estimated` counts records REMOVED, PROTECTED, or REBUILT.
105
+ * Defaults to `'remove'`, so every existing purge step is unchanged and only
106
+ * a step that means something else has to say so.
107
+ */
108
+ readonly disposition?: CleanupStepDisposition | undefined;
92
109
  /** What this step RETAINS (e.g. `'measures kept, PII redacted'`). */
93
110
  readonly retained?: string | undefined;
94
111
  /**
@@ -147,6 +164,22 @@ interface CleanupStep {
147
164
  * recipe is destructive iff ANY of its steps is.
148
165
  */
149
166
  readonly destructive: boolean;
167
+ /**
168
+ * What this step IS, declared once — not merely what one estimate counted.
169
+ *
170
+ * Needed at the STEP because a caller has to know a step is protective
171
+ * WITHOUT running it. A host resolving an operator's exclusion list must
172
+ * refuse to switch a guard off, and asking `estimate()` to find out would mean
173
+ * running the counting queries for a line that is being taken out — and
174
+ * surfacing that line's blockers, so excluding a domain could still be refused
175
+ * because of it.
176
+ *
177
+ * `destructive: false` is not the same question: it is true of a guard AND of
178
+ * a projection rebuild, and a rebuild is perfectly reasonable to exclude.
179
+ *
180
+ * An estimate may restate it; absent everywhere ⇒ `'remove'`.
181
+ */
182
+ readonly disposition?: CleanupStepDisposition | undefined;
150
183
  /**
151
184
  * Projection / scaffolding rebuilds this step performs AFTER its cleanup —
152
185
  * surfaced in the preview's `rebuildActions` (e.g. `'rebuild sales rollup'`).
@@ -164,4 +197,4 @@ interface CleanupStep {
164
197
  verify?(ctx: CleanupStepContext): Promise<readonly CleanupStepCheck[]>;
165
198
  }
166
199
  //#endregion
167
- export { CleanupStep, CleanupStepCheck, CleanupStepContext, CleanupStepEstimate, CleanupStepExecuteContext, CleanupStepLogger, CleanupStepOutcome, CleanupStepProgress };
200
+ export { CleanupStep, CleanupStepCheck, CleanupStepContext, CleanupStepDisposition, CleanupStepEstimate, CleanupStepExecuteContext, CleanupStepLogger, CleanupStepOutcome, CleanupStepProgress };
@@ -1,3 +1,3 @@
1
1
  import { ResolvedTenantConfig, TenantConfig, TenantFieldType, TenantStrategy } from "./types.mjs";
2
- import { DEFAULT_TENANT_CONFIG, resolveTenantConfig, resolveTenantField } from "./resolve.mjs";
3
- export { DEFAULT_TENANT_CONFIG, type ResolvedTenantConfig, type TenantConfig, type TenantFieldType, type TenantStrategy, resolveTenantConfig, resolveTenantField };
2
+ import { DEFAULT_TENANT_CONFIG, assertNoLegacyTenantKeys, resolveTenantConfig, resolveTenantField } from "./resolve.mjs";
3
+ export { DEFAULT_TENANT_CONFIG, type ResolvedTenantConfig, type TenantConfig, type TenantFieldType, type TenantStrategy, assertNoLegacyTenantKeys, resolveTenantConfig, resolveTenantField };
@@ -1,2 +1,2 @@
1
- import { DEFAULT_TENANT_CONFIG, resolveTenantConfig, resolveTenantField } from "./resolve.mjs";
2
- export { DEFAULT_TENANT_CONFIG, resolveTenantConfig, resolveTenantField };
1
+ import { DEFAULT_TENANT_CONFIG, assertNoLegacyTenantKeys, resolveTenantConfig, resolveTenantField } from "./resolve.mjs";
2
+ export { DEFAULT_TENANT_CONFIG, assertNoLegacyTenantKeys, resolveTenantConfig, resolveTenantField };
@@ -28,5 +28,36 @@ declare function resolveTenantConfig(config?: TenantConfig | boolean): ResolvedT
28
28
  * scoping" and all return `false` — callers get one thing to branch on.
29
29
  */
30
30
  declare function resolveTenantField(config?: TenantConfig | boolean): string | false;
31
+ /**
32
+ * Refuse a config that still carries a pre-consolidation tenant key.
33
+ *
34
+ * ## Why this belongs in repo-core and not in each kernel
35
+ *
36
+ * Kernels are migrating from a per-package tenant shape (`multiTenant`, plus a
37
+ * sibling `tenantFieldType` in some) onto {@link TenantConfig} under the key
38
+ * `tenant`. `@classytic/ledger` has landed; catalog, order, cart, crm, flow,
39
+ * party, review, transfer and yard have adopted the TYPE but still read
40
+ * `multiTenant`. Each of those is a future rename.
41
+ *
42
+ * The rename itself is trivial. What is not trivial is the failure mode when a
43
+ * CALLER misses it, because every one of these resolvers reads
44
+ * `resolveTenantConfig(config.tenant ?? false)`: an absent `tenant` resolves to
45
+ * `strategy: 'none'` — no tenant field, no tenant filter, every read spanning
46
+ * ALL tenants, no error, and figures that look plausible. A host that asked for
47
+ * tenancy gets none, silently.
48
+ *
49
+ * Nine packages each remembering to hand-roll that check is nine chances to
50
+ * forget, and the one that forgets is the one that ships the leak. So the guard
51
+ * lives beside the resolver every one of them already calls.
52
+ *
53
+ * Additive and non-breaking: nothing calls it until a package renames.
54
+ *
55
+ * @param config the raw, unresolved shape as the host supplied it
56
+ * @param pkg package name for the message (e.g. `'defineOrder'`)
57
+ * @param extra additional legacy keys this package is retiring, as
58
+ * `[oldKey, newPath]` — pass `['tenantFieldType', 'tenant.fieldType']` when
59
+ * the package carried a sibling field-type option.
60
+ */
61
+ declare function assertNoLegacyTenantKeys(config: unknown, pkg: string, extra?: ReadonlyArray<readonly [string, string]>): void;
31
62
  //#endregion
32
- export { DEFAULT_TENANT_CONFIG, resolveTenantConfig, resolveTenantField };
63
+ export { DEFAULT_TENANT_CONFIG, assertNoLegacyTenantKeys, resolveTenantConfig, resolveTenantField };
@@ -71,5 +71,44 @@ function resolveTenantField(config) {
71
71
  if (!resolved.enabled || resolved.strategy === "none") return false;
72
72
  return resolved.tenantField;
73
73
  }
74
+ /**
75
+ * Refuse a config that still carries a pre-consolidation tenant key.
76
+ *
77
+ * ## Why this belongs in repo-core and not in each kernel
78
+ *
79
+ * Kernels are migrating from a per-package tenant shape (`multiTenant`, plus a
80
+ * sibling `tenantFieldType` in some) onto {@link TenantConfig} under the key
81
+ * `tenant`. `@classytic/ledger` has landed; catalog, order, cart, crm, flow,
82
+ * party, review, transfer and yard have adopted the TYPE but still read
83
+ * `multiTenant`. Each of those is a future rename.
84
+ *
85
+ * The rename itself is trivial. What is not trivial is the failure mode when a
86
+ * CALLER misses it, because every one of these resolvers reads
87
+ * `resolveTenantConfig(config.tenant ?? false)`: an absent `tenant` resolves to
88
+ * `strategy: 'none'` — no tenant field, no tenant filter, every read spanning
89
+ * ALL tenants, no error, and figures that look plausible. A host that asked for
90
+ * tenancy gets none, silently.
91
+ *
92
+ * Nine packages each remembering to hand-roll that check is nine chances to
93
+ * forget, and the one that forgets is the one that ships the leak. So the guard
94
+ * lives beside the resolver every one of them already calls.
95
+ *
96
+ * Additive and non-breaking: nothing calls it until a package renames.
97
+ *
98
+ * @param config the raw, unresolved shape as the host supplied it
99
+ * @param pkg package name for the message (e.g. `'defineOrder'`)
100
+ * @param extra additional legacy keys this package is retiring, as
101
+ * `[oldKey, newPath]` — pass `['tenantFieldType', 'tenant.fieldType']` when
102
+ * the package carried a sibling field-type option.
103
+ */
104
+ function assertNoLegacyTenantKeys(config, pkg, extra = []) {
105
+ if (config === null || typeof config !== "object") return;
106
+ const record = config;
107
+ const retired = [["multiTenant", "tenant"], ...extra];
108
+ for (const [key, became] of retired) {
109
+ if (record[key] === void 0) continue;
110
+ throw new Error(`${pkg}: \`${key}\` was renamed to \`${became}\`. It is REFUSED rather than ignored because ignoring it disables tenancy SILENTLY — no tenant field, no tenant filter, and every read spanning all tenants while returning plausible numbers. Move the value to \`tenant\` (\`tenant: false\` for a single-tenant deployment).`);
111
+ }
112
+ }
74
113
  //#endregion
75
- export { DEFAULT_TENANT_CONFIG, resolveTenantConfig, resolveTenantField };
114
+ export { DEFAULT_TENANT_CONFIG, assertNoLegacyTenantKeys, resolveTenantConfig, resolveTenantField };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classytic/repo-core",
3
- "version": "0.23.0",
3
+ "version": "0.25.0",
4
4
  "description": "Driver-agnostic repository primitives: hooks, Filter IR, operations, pagination, cache contract. Foundation for mongokit, sqlitekit, pgkit, and prismakit. Lean by design — no plugins ship here; each kit owns its own.",
5
5
  "type": "module",
6
6
  "sideEffects": false,