@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 +44 -0
- package/dist/cache/index.d.mts +2 -1
- package/dist/cache/index.mjs +2 -1
- package/dist/cache/memo.d.mts +30 -0
- package/dist/cache/memo.mjs +47 -0
- package/package.json +1 -9
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
|
package/dist/cache/index.d.mts
CHANGED
|
@@ -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 };
|
package/dist/cache/index.mjs
CHANGED
|
@@ -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.
|
|
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
|
}
|