@classytic/repo-core 0.24.0 → 0.26.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 +956 -908
- package/dist/cache/engine.d.mts +31 -3
- package/dist/cache/engine.mjs +31 -9
- package/dist/cache/index.d.mts +2 -2
- package/dist/cache/plugin/index.d.mts +22 -4
- package/dist/loader/batch-loader.d.mts +94 -0
- package/dist/loader/batch-loader.mjs +153 -0
- package/dist/loader/index.d.mts +2 -0
- package/dist/loader/index.mjs +2 -0
- package/package.json +5 -1
package/dist/cache/engine.d.mts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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 };
|
package/dist/cache/engine.mjs
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
|
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
|
|
106
|
-
if (tags.length > 0) await appendKeyToTags(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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() {
|
package/dist/cache/index.d.mts
CHANGED
|
@@ -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 {
|
|
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
|
-
/**
|
|
28
|
-
|
|
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`. */
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
//#region src/loader/batch-loader.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Collapse many per-item reads issued in one operation into ONE round trip.
|
|
4
|
+
*
|
|
5
|
+
* The read-side counterpart of `bulkWrite`: a loop that awaits a read per item
|
|
6
|
+
* costs N round trips, and against a remote cluster the round trip IS the cost.
|
|
7
|
+
* Hand the loader the keys and it issues a single batched call per tick.
|
|
8
|
+
*
|
|
9
|
+
* ## Scope: per OPERATION, never process-lived
|
|
10
|
+
*
|
|
11
|
+
* A loader caches, so its lifetime is a correctness property. Create one per
|
|
12
|
+
* request / transaction / placement and let it die with that work. A loader
|
|
13
|
+
* held on a repository instance serves a stale value forever, which is the
|
|
14
|
+
* failure mode a plain memo already has — this primitive does not fix it, it
|
|
15
|
+
* inherits it, so scope is the caller's job.
|
|
16
|
+
*
|
|
17
|
+
* ## The contract `batch` MUST honour
|
|
18
|
+
*
|
|
19
|
+
* `batch(keys)` returns results POSITIONALLY: `result[i]` belongs to `keys[i]`.
|
|
20
|
+
* A length mismatch is refused rather than tolerated — silently zipping a short
|
|
21
|
+
* array assigns one key's value to a different key, and every downstream check
|
|
22
|
+
* still passes because the values are individually well-formed.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* const products = createBatchLoader<string, Product>({
|
|
26
|
+
* batch: async (ids) => {
|
|
27
|
+
* const rows = await repo.getAll({ filters: { _id: { $in: [...ids] } } });
|
|
28
|
+
* const byId = new Map(rows.map((r) => [String(r._id), r]));
|
|
29
|
+
* return ids.map((id) => byId.get(id)); // positional, same length
|
|
30
|
+
* },
|
|
31
|
+
* });
|
|
32
|
+
* const [a, b] = await Promise.all([products.load('1'), products.load('2')]); // ONE query
|
|
33
|
+
*
|
|
34
|
+
* ## The trap: an awaited loop batches NOTHING
|
|
35
|
+
*
|
|
36
|
+
* The batch window is one microtask, so any `await` between two `load` calls
|
|
37
|
+
* closes it:
|
|
38
|
+
*
|
|
39
|
+
* for (const id of ids) await loader.load(id); // N batches, not 1
|
|
40
|
+
*
|
|
41
|
+
* That returns correct data and no error — only `stats.batches` shows it. Use
|
|
42
|
+
* `loadMany`, or issue the loads before awaiting them, and assert
|
|
43
|
+
* `stats.batches` when the round-trip count is the point.
|
|
44
|
+
*/
|
|
45
|
+
/** Default cap on one batched call — keeps a generated `$in` bounded. */
|
|
46
|
+
declare const DEFAULT_MAX_BATCH_SIZE = 500;
|
|
47
|
+
interface BatchLoaderOptions<K, V> {
|
|
48
|
+
/**
|
|
49
|
+
* Load every key in one call. MUST return one entry per key, in the same
|
|
50
|
+
* order. Use `undefined` for "no such key" — throwing rejects the whole batch.
|
|
51
|
+
*/
|
|
52
|
+
batch: (keys: readonly K[]) => Promise<ReadonlyArray<V | undefined>>;
|
|
53
|
+
/**
|
|
54
|
+
* Cache identity for a key. Required when `K` is an object — the default
|
|
55
|
+
* (`String(key)`) collapses every object to `[object Object]`, which would
|
|
56
|
+
* merge unrelated keys into one entry.
|
|
57
|
+
*/
|
|
58
|
+
keyOf?: (key: K) => string;
|
|
59
|
+
/** Split larger requests into several calls. Defaults to {@link DEFAULT_MAX_BATCH_SIZE}. */
|
|
60
|
+
maxBatchSize?: number;
|
|
61
|
+
/**
|
|
62
|
+
* Refuse a batch that resolved NOTHING for keys it was given.
|
|
63
|
+
*
|
|
64
|
+
* The length check catches a `.filter()`/`.slice()` mistake. It cannot catch
|
|
65
|
+
* the commoner one: a `Map` keyed on `ObjectId` while the keys are strings,
|
|
66
|
+
* or keyed on `_id` while the caller passes `skuRef`. That returns the right
|
|
67
|
+
* LENGTH and all `undefined` — and `undefined` is this loader's documented
|
|
68
|
+
* value for "no such key", so a total lookup failure is indistinguishable
|
|
69
|
+
* from "these rows do not exist".
|
|
70
|
+
*
|
|
71
|
+
* Set `true` when every key is expected to resolve (a `$in` over ids you just
|
|
72
|
+
* read). Leave it off for a genuine existence check.
|
|
73
|
+
*/
|
|
74
|
+
requireAllResolved?: boolean;
|
|
75
|
+
}
|
|
76
|
+
interface BatchLoader<K, V> {
|
|
77
|
+
/** Queue one key; resolves when its batch settles. */
|
|
78
|
+
load(key: K): Promise<V | undefined>;
|
|
79
|
+
/** Queue many keys as one batch, positionally. */
|
|
80
|
+
loadMany(keys: readonly K[]): Promise<Array<V | undefined>>;
|
|
81
|
+
/** Seed a value the caller already holds, so it is never fetched. */
|
|
82
|
+
prime(key: K, value: V | undefined): void;
|
|
83
|
+
/** Drop one key, or the whole cache — use after a write invalidates a read. */
|
|
84
|
+
clear(key?: K): void;
|
|
85
|
+
/** Observability: how many batched calls this loader has made. */
|
|
86
|
+
readonly stats: {
|
|
87
|
+
batches: number;
|
|
88
|
+
keys: number;
|
|
89
|
+
cacheHits: number;
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
declare function createBatchLoader<K, V>(options: BatchLoaderOptions<K, V>): BatchLoader<K, V>;
|
|
93
|
+
//#endregion
|
|
94
|
+
export { BatchLoader, BatchLoaderOptions, DEFAULT_MAX_BATCH_SIZE, createBatchLoader };
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
//#region src/loader/batch-loader.ts
|
|
2
|
+
/**
|
|
3
|
+
* Collapse many per-item reads issued in one operation into ONE round trip.
|
|
4
|
+
*
|
|
5
|
+
* The read-side counterpart of `bulkWrite`: a loop that awaits a read per item
|
|
6
|
+
* costs N round trips, and against a remote cluster the round trip IS the cost.
|
|
7
|
+
* Hand the loader the keys and it issues a single batched call per tick.
|
|
8
|
+
*
|
|
9
|
+
* ## Scope: per OPERATION, never process-lived
|
|
10
|
+
*
|
|
11
|
+
* A loader caches, so its lifetime is a correctness property. Create one per
|
|
12
|
+
* request / transaction / placement and let it die with that work. A loader
|
|
13
|
+
* held on a repository instance serves a stale value forever, which is the
|
|
14
|
+
* failure mode a plain memo already has — this primitive does not fix it, it
|
|
15
|
+
* inherits it, so scope is the caller's job.
|
|
16
|
+
*
|
|
17
|
+
* ## The contract `batch` MUST honour
|
|
18
|
+
*
|
|
19
|
+
* `batch(keys)` returns results POSITIONALLY: `result[i]` belongs to `keys[i]`.
|
|
20
|
+
* A length mismatch is refused rather than tolerated — silently zipping a short
|
|
21
|
+
* array assigns one key's value to a different key, and every downstream check
|
|
22
|
+
* still passes because the values are individually well-formed.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* const products = createBatchLoader<string, Product>({
|
|
26
|
+
* batch: async (ids) => {
|
|
27
|
+
* const rows = await repo.getAll({ filters: { _id: { $in: [...ids] } } });
|
|
28
|
+
* const byId = new Map(rows.map((r) => [String(r._id), r]));
|
|
29
|
+
* return ids.map((id) => byId.get(id)); // positional, same length
|
|
30
|
+
* },
|
|
31
|
+
* });
|
|
32
|
+
* const [a, b] = await Promise.all([products.load('1'), products.load('2')]); // ONE query
|
|
33
|
+
*
|
|
34
|
+
* ## The trap: an awaited loop batches NOTHING
|
|
35
|
+
*
|
|
36
|
+
* The batch window is one microtask, so any `await` between two `load` calls
|
|
37
|
+
* closes it:
|
|
38
|
+
*
|
|
39
|
+
* for (const id of ids) await loader.load(id); // N batches, not 1
|
|
40
|
+
*
|
|
41
|
+
* That returns correct data and no error — only `stats.batches` shows it. Use
|
|
42
|
+
* `loadMany`, or issue the loads before awaiting them, and assert
|
|
43
|
+
* `stats.batches` when the round-trip count is the point.
|
|
44
|
+
*/
|
|
45
|
+
/** Default cap on one batched call — keeps a generated `$in` bounded. */
|
|
46
|
+
const DEFAULT_MAX_BATCH_SIZE = 500;
|
|
47
|
+
function createBatchLoader(options) {
|
|
48
|
+
const { batch } = options;
|
|
49
|
+
/**
|
|
50
|
+
* REFUSES an object key rather than stringifying it. `String({})` is
|
|
51
|
+
* `[object Object]` for every object, so the default would merge unrelated
|
|
52
|
+
* keys into one entry and hand the first one's value to all of them — and
|
|
53
|
+
* report the collisions as cache HITS, so the telemetry would say the loader
|
|
54
|
+
* was working well while line 2 received line 1's row.
|
|
55
|
+
*/
|
|
56
|
+
const keyOf = options.keyOf ?? ((k) => {
|
|
57
|
+
if (k !== null && typeof k === "object") throw new TypeError("batch loader: an object key needs an explicit `keyOf` — String(key) collides them all");
|
|
58
|
+
return String(k);
|
|
59
|
+
});
|
|
60
|
+
const requested = options.maxBatchSize ?? 500;
|
|
61
|
+
if (!Number.isInteger(requested) || requested < 1) throw new TypeError(`batch loader: maxBatchSize must be a positive integer, received ${String(requested)}`);
|
|
62
|
+
const maxBatchSize = requested;
|
|
63
|
+
/** Settled + in-flight results, keyed by cache identity. */
|
|
64
|
+
const cache = /* @__PURE__ */ new Map();
|
|
65
|
+
/** Keys queued for the next tick, with everyone waiting on each. */
|
|
66
|
+
let queue = [];
|
|
67
|
+
let scheduled = false;
|
|
68
|
+
const stats = {
|
|
69
|
+
batches: 0,
|
|
70
|
+
keys: 0,
|
|
71
|
+
cacheHits: 0
|
|
72
|
+
};
|
|
73
|
+
async function runBatch(entries) {
|
|
74
|
+
const keys = entries.map((e) => e.key);
|
|
75
|
+
stats.batches += 1;
|
|
76
|
+
stats.keys += keys.length;
|
|
77
|
+
try {
|
|
78
|
+
const results = await batch(keys);
|
|
79
|
+
if (results.length !== keys.length) throw new Error(`batch loader: batch() returned ${results.length} results for ${keys.length} keys — results must be positional and the same length`);
|
|
80
|
+
if (options.requireAllResolved && keys.length > 0 && results.every((r) => r === void 0)) throw new Error(`batch loader: batch() resolved NOTHING for ${keys.length} key(s) — likely a key-type mismatch between the keys and the lookup (ObjectId vs string, _id vs skuRef)`);
|
|
81
|
+
entries.forEach((entry, i) => {
|
|
82
|
+
for (const w of entry.waiters) w.resolve(results[i]);
|
|
83
|
+
});
|
|
84
|
+
} catch (err) {
|
|
85
|
+
for (const entry of entries) {
|
|
86
|
+
cache.delete(entry.id);
|
|
87
|
+
for (const w of entry.waiters) w.reject(err);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function schedule() {
|
|
92
|
+
if (scheduled) return;
|
|
93
|
+
scheduled = true;
|
|
94
|
+
queueMicrotask(() => {
|
|
95
|
+
scheduled = false;
|
|
96
|
+
const entries = queue;
|
|
97
|
+
queue = [];
|
|
98
|
+
if (entries.length === 0) return;
|
|
99
|
+
for (let i = 0; i < entries.length; i += maxBatchSize) runBatch(entries.slice(i, i + maxBatchSize));
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* No queue scan here. `load` writes the promise into `cache` synchronously,
|
|
104
|
+
* so a repeated key in the same tick is served from there and never reaches
|
|
105
|
+
* this function — a `queue.find` would be an unreachable O(n) per load, i.e.
|
|
106
|
+
* quadratic on exactly the large batch this primitive exists for.
|
|
107
|
+
*/
|
|
108
|
+
function enqueue(key, id) {
|
|
109
|
+
const promise = new Promise((resolve, reject) => {
|
|
110
|
+
queue.push({
|
|
111
|
+
key,
|
|
112
|
+
id,
|
|
113
|
+
waiters: [{
|
|
114
|
+
resolve,
|
|
115
|
+
reject
|
|
116
|
+
}]
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
schedule();
|
|
120
|
+
return promise;
|
|
121
|
+
}
|
|
122
|
+
function load(key) {
|
|
123
|
+
const id = keyOf(key);
|
|
124
|
+
const hit = cache.get(id);
|
|
125
|
+
if (hit) {
|
|
126
|
+
stats.cacheHits += 1;
|
|
127
|
+
return hit;
|
|
128
|
+
}
|
|
129
|
+
const promise = enqueue(key, id);
|
|
130
|
+
cache.set(id, promise);
|
|
131
|
+
return promise;
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
load,
|
|
135
|
+
loadMany: (keys) => Promise.all(keys.map(load)),
|
|
136
|
+
prime(key, value) {
|
|
137
|
+
cache.set(keyOf(key), Promise.resolve(value));
|
|
138
|
+
},
|
|
139
|
+
/**
|
|
140
|
+
* Cache only — an in-flight fetch is left to settle for the caller already
|
|
141
|
+
* waiting on it. Dropping its queue entry would abandon that waiter
|
|
142
|
+
* forever. A load issued after this gets its own entry, because `enqueue`
|
|
143
|
+
* no longer joins by key (see the note there).
|
|
144
|
+
*/
|
|
145
|
+
clear(key) {
|
|
146
|
+
if (key === void 0) cache.clear();
|
|
147
|
+
else cache.delete(keyOf(key));
|
|
148
|
+
},
|
|
149
|
+
stats
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
//#endregion
|
|
153
|
+
export { DEFAULT_MAX_BATCH_SIZE, createBatchLoader };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@classytic/repo-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.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,
|
|
@@ -98,6 +98,10 @@
|
|
|
98
98
|
"types": "./dist/lock/index.d.mts",
|
|
99
99
|
"default": "./dist/lock/index.mjs"
|
|
100
100
|
},
|
|
101
|
+
"./loader": {
|
|
102
|
+
"types": "./dist/loader/index.d.mts",
|
|
103
|
+
"default": "./dist/loader/index.mjs"
|
|
104
|
+
},
|
|
101
105
|
"./usage": {
|
|
102
106
|
"types": "./dist/usage/index.d.mts",
|
|
103
107
|
"default": "./dist/usage/index.mjs"
|