@classytic/repo-core 0.26.0 → 0.27.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,50 @@ 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.27.0] - 2026-09-09
8
+
9
+ ### Added
10
+
11
+ - **`createTtlMemo(load, { ttlMs, keyOf?, maxEntries? })`** on `./cache` — the
12
+ one way to memoise a keyed async load for a while. It is `CacheEngine.prefetch`
13
+ over the memory adapter, so concurrent misses for one key share ONE load, a
14
+ thrown load caches nothing, and a `null` result is an answer.
15
+
16
+ Four packages had each hand-rolled `Map<key, { at, value }>` with a TTL, and
17
+ every one wrote its entry AFTER the await. Under parallel event dispatch N
18
+ callers reach the await together, all miss, and the cache only ever helps the
19
+ caller after them — a 30 s TTL that measured nine reads of one row per event.
20
+ `tests/unit/cache/ttl-memo.test.ts` pins single flight, expiry, invalidation,
21
+ `ttlMs: 0` = off, and refuses an object key without `keyOf` (an
22
+ identity-compared key never hits and reads as a caching bug).
23
+
24
+ ## [0.26.1] - 2026-09-08
25
+
26
+ ### Fixed
27
+
28
+ - **`vitest` is no longer declared as a peer dependency.** It was
29
+ `peerDependencies: { vitest: "^3.0.0 || ^4.0.0" }` with
30
+ `peerDependenciesMeta.optional: true` — and that combination does not do what
31
+ it looks like. `optional: true` only silences the *missing* case; npm still
32
+ hard-fails `ERESOLVE` on a version **mismatch**. So every consumer on a newer
33
+ vitest could not install repo-core at all, even though nothing they imported
34
+ touched it.
35
+
36
+ Measured: `@classytic/arc` could not move to vitest 5 — `npm install` failed
37
+ with `Conflicting peer dependency: vitest@5.0.0`, sourced to this package.
38
+ Widening the range to `|| ^5.0.0` would only have moved the wall to vitest 6.
39
+
40
+ `vitest` is the CONSUMER's test runner. Anyone importing
41
+ `@classytic/repo-core/testing` is by definition already inside a vitest suite
42
+ that provides it, so the import resolves from their install; anyone who does
43
+ not import it should never have been constrained. It stays in
44
+ `devDependencies` for this package's own suite, and `tsdown`'s `neverBundle`
45
+ now lists `vitest` so the bundler keeps the import external instead of
46
+ inlining a dev-only dependency into shipped output.
47
+
48
+ Any vitest exposing the standard `describe` / `it` / `expect` / `beforeEach` /
49
+ `afterEach` API works with the `/testing` conformance suites.
50
+
7
51
  ## [0.26.0] - 2026-09-02
8
52
 
9
53
  ### Added
@@ -1,9 +1,10 @@
1
1
  import { CacheOptions, CacheReadResult } from "./options.mjs";
2
2
  import { CacheAdapter } from "./types.mjs";
3
3
  import { CacheAdapterResolver, CacheAdapterSource, CacheEngine, CacheEngineOptions, SingleFlightClaim } from "./engine.mjs";
4
+ import { TtlMemo, TtlMemoOptions, createTtlMemo } from "./memo.mjs";
4
5
  import { MemoryCacheAdapterOptions, createMemoryCacheAdapter } from "./memory-adapter.mjs";
5
6
  import { DEFAULT_SHAPE_KEYS_BY_OP } from "./plugin/context.mjs";
6
7
  import { DEFAULT_CACHEABLE_OPS, DEFAULT_INVALIDATING_OPS, LogCallbacks, RepositoryCacheHandle, RepositoryCachePluginOptions, cachePlugin } from "./plugin/index.mjs";
7
8
  import { scheduleBackground } from "./runtime.mjs";
8
9
  import { CacheTimeoutError, TimeoutAdapterOptions, withTimeout } from "./timeout-adapter.mjs";
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 };
10
+ 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, type TtlMemo, type TtlMemoOptions, cachePlugin, createMemoryCacheAdapter, createTtlMemo, scheduleBackground, withTimeout };
@@ -1,7 +1,8 @@
1
1
  import { CacheEngine } from "./engine.mjs";
2
2
  import { createMemoryCacheAdapter } from "./memory-adapter.mjs";
3
+ import { createTtlMemo } from "./memo.mjs";
3
4
  import { DEFAULT_SHAPE_KEYS_BY_OP } from "./plugin/context.mjs";
4
5
  import { scheduleBackground } from "./runtime.mjs";
5
6
  import { DEFAULT_CACHEABLE_OPS, DEFAULT_INVALIDATING_OPS, cachePlugin } from "./plugin/index.mjs";
6
7
  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 };
8
+ export { CacheEngine, CacheTimeoutError, DEFAULT_CACHEABLE_OPS, DEFAULT_INVALIDATING_OPS, DEFAULT_SHAPE_KEYS_BY_OP, cachePlugin, createMemoryCacheAdapter, createTtlMemo, scheduleBackground, withTimeout };
@@ -0,0 +1,30 @@
1
+ //#region src/cache/memo.d.ts
2
+ /**
3
+ * `createTtlMemo` — memoise a keyed async load for `ttlMs`, single-flight.
4
+ *
5
+ * Use this instead of a `Map<key, { at, value }>`. Every hand-rolled one wrote
6
+ * its entry AFTER the await, so N concurrent callers all missed and the cache
7
+ * only ever helped the caller after them. This is `CacheEngine.prefetch` over
8
+ * the memory adapter: concurrent misses share one load, a thrown load caches
9
+ * nothing, and a `null` result is an answer.
10
+ */
11
+ interface TtlMemoOptions<K> {
12
+ /** Freshness window. `0` disables memoisation — every call loads. */
13
+ readonly ttlMs: number;
14
+ /**
15
+ * Key derivation. REQUIRED for non-primitive keys: an object key would be
16
+ * identity-compared, never hit, and read as a caching bug. Fold anything that
17
+ * changes the answer into it (a revision counter, a tenant).
18
+ */
19
+ readonly keyOf?: (key: K) => string;
20
+ /** LRU ceiling. Default 10,000. */
21
+ readonly maxEntries?: number;
22
+ }
23
+ interface TtlMemo<K, V> {
24
+ get(key: K): Promise<V>;
25
+ /** Drop one key, or everything. */
26
+ invalidate(key?: K): Promise<void>;
27
+ }
28
+ declare function createTtlMemo<K, V>(load: (key: K) => Promise<V>, options: TtlMemoOptions<K>): TtlMemo<K, V>;
29
+ //#endregion
30
+ export { TtlMemo, TtlMemoOptions, createTtlMemo };
@@ -0,0 +1,47 @@
1
+ import { CacheEngine } from "./engine.mjs";
2
+ import { createMemoryCacheAdapter } from "./memory-adapter.mjs";
3
+ //#region src/cache/memo.ts
4
+ /**
5
+ * `createTtlMemo` — memoise a keyed async load for `ttlMs`, single-flight.
6
+ *
7
+ * Use this instead of a `Map<key, { at, value }>`. Every hand-rolled one wrote
8
+ * its entry AFTER the await, so N concurrent callers all missed and the cache
9
+ * only ever helped the caller after them. This is `CacheEngine.prefetch` over
10
+ * the memory adapter: concurrent misses share one load, a thrown load caches
11
+ * nothing, and a `null` result is an answer.
12
+ */
13
+ const PREFIX = "memo";
14
+ function createTtlMemo(load, options) {
15
+ const { ttlMs, keyOf, maxEntries } = options;
16
+ const adapter = createMemoryCacheAdapter({ maxEntries: maxEntries ?? 1e4 });
17
+ const engine = new CacheEngine(adapter, { prefix: PREFIX });
18
+ const resolved = {
19
+ staleTime: Math.max(0, ttlMs) / 1e3,
20
+ gcTime: 0,
21
+ tags: [],
22
+ bypass: false,
23
+ swr: false,
24
+ enabled: ttlMs > 0
25
+ };
26
+ const toKey = (key) => {
27
+ if (keyOf) return `${PREFIX}:${keyOf(key)}`;
28
+ const t = typeof key;
29
+ if (t === "string" || t === "number" || t === "boolean") return `${PREFIX}:${String(key)}`;
30
+ throw new TypeError(`createTtlMemo: a ${key === null ? "null" : t} key needs keyOf(...) — an identity-compared key never hits`);
31
+ };
32
+ return {
33
+ get(key) {
34
+ const k = toKey(key);
35
+ return engine.prefetch(k, resolved, () => load(key));
36
+ },
37
+ async invalidate(key) {
38
+ if (key === void 0) {
39
+ await engine.clear();
40
+ return;
41
+ }
42
+ await adapter.delete(toKey(key));
43
+ }
44
+ };
45
+ }
46
+ //#endregion
47
+ export { createTtlMemo };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classytic/repo-core",
3
- "version": "0.26.0",
3
+ "version": "0.27.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,
@@ -176,13 +176,5 @@
176
176
  "tsdown": "^0.22.14",
177
177
  "typescript": "^7.0.2",
178
178
  "vitest": "^4.1.4"
179
- },
180
- "peerDependencies": {
181
- "vitest": "^3.0.0 || ^4.0.0"
182
- },
183
- "peerDependenciesMeta": {
184
- "vitest": {
185
- "optional": true
186
- }
187
179
  }
188
180
  }