dsh-model-pool 0.0.0-stage → 0.1.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.
@@ -0,0 +1,404 @@
1
+ /**
2
+ * Group every registered provider's models into logical pool models.
3
+ * Pure: the caller supplies the registry snapshot; nothing here performs I/O.
4
+ * @module dsh-model-pool/lib/grouping
5
+ */
6
+
7
+ import { rankEfforts, rankOf, unionEfforts } from "./effort.js";
8
+
9
+ /** Fallback priority base for providers missing from `providerOrder`. */
10
+ const UNLISTED_PRIORITY = 1000;
11
+
12
+ /** Placeholder protecting a `.` that sits between two digits (a version dot). */
13
+ const VERSION_DOT = "\u0001";
14
+
15
+ /**
16
+ * Normalize a concrete model id into its pool id.
17
+ * Strips a vendor prefix before the `/` (the last path segment wins, so every
18
+ * vendor prefix is removed), lower-cases, collapses `-`/`_` runs to `-`, turns a
19
+ * non-version `.` into `-`, drops a trailing `-latest` and collapses repeated `-`.
20
+ * A `.` between digits is kept, so `deepseek-v4.1-flash` normalizes to itself.
21
+ * @param raw - concrete model id, e.g. `openai/GPT-5_Latest`.
22
+ * @returns the normalized pool id, or `""` for non-string input.
23
+ */
24
+ export function normalizeModelId(raw) {
25
+ if (typeof raw !== "string") return "";
26
+ const slash = raw.lastIndexOf("/");
27
+ let id = (slash >= 0 ? raw.slice(slash + 1) : raw).trim().toLowerCase();
28
+ id = id.replace(/[-_]+/g, "-").replace(/(\d)\.(?=\d)/g, `$1${VERSION_DOT}`).replace(/\./g, "-");
29
+ id = id.replace(/-latest$/, "");
30
+ id = id.replace(/-+/g, "-").replace(/^-+|-+$/g, "").split(VERSION_DOT).join(".");
31
+ return id;
32
+ }
33
+
34
+ /**
35
+ * Display name for a pool model.
36
+ * @param model - concrete model entry.
37
+ * @param fallback - id used when the entry has no name.
38
+ * @returns the display name.
39
+ */
40
+ function displayName(model, fallback) {
41
+ const name = model?.name;
42
+ return typeof name === "string" && name.trim() !== "" ? name : fallback;
43
+ }
44
+
45
+ /**
46
+ * Priority of one provider.
47
+ * @param providerId - provider id.
48
+ * @param providerOrder - configured provider ids, highest priority first.
49
+ * @param registrationIndex - index of the provider in registry order.
50
+ * @returns the priority, `0`-based and ascending.
51
+ */
52
+ function priorityOf(providerId, providerOrder, registrationIndex) {
53
+ const listed = (Array.isArray(providerOrder) ? providerOrder : []).indexOf(providerId);
54
+ return listed >= 0 ? listed : UNLISTED_PRIORITY + registrationIndex;
55
+ }
56
+
57
+ /**
58
+ * Whether a provider passes the include/exclude filters.
59
+ * @param providerId - provider id.
60
+ * @param include - provider allowlist; empty means every provider.
61
+ * @param exclude - provider denylist.
62
+ * @returns true when the provider may join the pool.
63
+ */
64
+ function providerAllowed(providerId, include, exclude) {
65
+ const allowed = Array.isArray(include) ? include : [];
66
+ const denied = Array.isArray(exclude) ? exclude : [];
67
+ if (allowed.length > 0 && !allowed.includes(providerId)) return false;
68
+ return !denied.includes(providerId);
69
+ }
70
+
71
+ /**
72
+ * Whether a model is denied by `excludeModels`.
73
+ * An entry matches when it equals the concrete id or is a case-insensitive substring
74
+ * of the normalized pool id.
75
+ * @param modelId - concrete model id.
76
+ * @param poolId - normalized pool id.
77
+ * @param excludeModels - configured deny entries.
78
+ * @returns true when the model must be skipped.
79
+ */
80
+ function modelExcluded(modelId, poolId, excludeModels) {
81
+ if (!Array.isArray(excludeModels)) return false;
82
+ return excludeModels.some((entry) => {
83
+ if (typeof entry !== "string" || entry === "") return false;
84
+ if (entry === modelId) return true;
85
+ return poolId.includes(entry.toLowerCase());
86
+ });
87
+ }
88
+
89
+ /**
90
+ * Copy one candidate into a `PoolRoute`.
91
+ * @param provider - provider entry.
92
+ * @param model - concrete model entry.
93
+ * @param priority - provider priority.
94
+ * @param registrationIndex - provider registration index used as a tie-break.
95
+ * @returns the pool route.
96
+ */
97
+ function toRoute(provider, model, priority, registrationIndex) {
98
+ const route = {
99
+ provider: provider.id,
100
+ model: model.id,
101
+ name: displayName(model, model.id),
102
+ priority,
103
+ weight: Number.isFinite(model.weight) && model.weight >= 1 ? model.weight : 1,
104
+ };
105
+ // The provider's display name rides along so a surface can name the upstream
106
+ // without keeping its own copy of the registry.
107
+ if (typeof provider.name === "string" && provider.name !== "") route.providerName = provider.name;
108
+ if (typeof model.description === "string") route.description = model.description;
109
+ if (model.reasoning !== undefined && model.reasoning !== null) {
110
+ const efforts = rankEfforts(model.reasoning.efforts);
111
+ if (efforts.length > 0) {
112
+ route.reasoning = { efforts };
113
+ const defaultEffort = model.reasoning.defaultEffort;
114
+ if (typeof defaultEffort === "string" && efforts.some((effort) => effort.id === defaultEffort)) {
115
+ route.reasoning.defaultEffort = defaultEffort;
116
+ }
117
+ }
118
+ }
119
+ if (Number.isFinite(model.contextWindow) && model.contextWindow > 0) {
120
+ route.contextWindow = model.contextWindow;
121
+ }
122
+ route.registrationIndex = registrationIndex;
123
+ return route;
124
+ }
125
+
126
+ /**
127
+ * Public copy of a route: drops the internal registration index and clones the
128
+ * reasoning block so pools never share mutable state.
129
+ * @param route - internal route.
130
+ * @returns the route as exposed on a `PoolModel`.
131
+ */
132
+ function routeForPool(route) {
133
+ const copy = {
134
+ provider: route.provider,
135
+ model: route.model,
136
+ name: route.name,
137
+ priority: route.priority,
138
+ weight: route.weight,
139
+ };
140
+ if (typeof route.providerName === "string") copy.providerName = route.providerName;
141
+ if (typeof route.description === "string") copy.description = route.description;
142
+ if (route.reasoning !== undefined) {
143
+ copy.reasoning = {
144
+ efforts: route.reasoning.efforts.map((effort) => ({ ...effort })),
145
+ };
146
+ if (typeof route.reasoning.defaultEffort === "string") {
147
+ copy.reasoning.defaultEffort = route.reasoning.defaultEffort;
148
+ }
149
+ }
150
+ if (route.contextWindow !== undefined) copy.contextWindow = route.contextWindow;
151
+ return copy;
152
+ }
153
+
154
+ /**
155
+ * Aggregate the candidate routes of one pool model.
156
+ * @param id - pool id.
157
+ * @param routes - candidate routes, already priority-sorted.
158
+ * @param name - display name override, used by manual groups.
159
+ * @returns the `PoolModel`.
160
+ */
161
+ function toPoolModel(id, routes, name = undefined) {
162
+ const ordered = [...routes].sort(
163
+ (left, right) => left.priority - right.priority || left.registrationIndex - right.registrationIndex,
164
+ );
165
+ const head = ordered[0];
166
+ const providerCount = new Set(ordered.map((route) => route.provider)).size;
167
+ const pool = {
168
+ id,
169
+ name: typeof name === "string" && name.trim() !== "" ? name : displayName(head, id),
170
+ description: `${providerCount} provider${providerCount === 1 ? "" : "s"}`,
171
+ routes: ordered.map((route) => routeForPool(route)),
172
+ };
173
+ // `unionEfforts` folds provider spellings of one tier (`off` / `disabled`) so
174
+ // the pool offers each tier once. Building the union inline would duplicate
175
+ // the label while the two entries still routed to different wire values.
176
+ const union = unionEfforts(ordered.map((route) => route.reasoning?.efforts ?? []));
177
+ if (union.length > 0) {
178
+ pool.reasoning = { efforts: union };
179
+ const shared = sharedDefaultEffort(ordered, union);
180
+ if (shared !== undefined) pool.reasoning.defaultEffort = shared;
181
+ }
182
+ const windows = ordered
183
+ .map((route) => route.contextWindow)
184
+ .filter((window) => Number.isFinite(window));
185
+ if (windows.length > 0) pool.contextWindow = Math.min(...windows);
186
+ return pool;
187
+ }
188
+
189
+ /**
190
+ * Pick the effort id most candidates share; ties go to the highest canonical rank.
191
+ * @param routes - candidate routes.
192
+ * @param union - the ranked effort union of the pool.
193
+ * @returns the shared default effort id, or undefined when no candidate declares one.
194
+ */
195
+ function sharedDefaultEffort(routes, union) {
196
+ const counts = new Map();
197
+ for (const route of routes) {
198
+ const defaultEffort = route.reasoning?.defaultEffort;
199
+ if (typeof defaultEffort !== "string") continue;
200
+ counts.set(defaultEffort, (counts.get(defaultEffort) ?? 0) + 1);
201
+ }
202
+ if (counts.size === 0) return undefined;
203
+ let best;
204
+ let bestCount = -1;
205
+ for (const [effortId, count] of counts) {
206
+ const rank = rankOf(effortId);
207
+ const bestRank = best === undefined ? -1 : rankOf(best);
208
+ if (count > bestCount || (count === bestCount && rank > bestRank)) {
209
+ best = effortId;
210
+ bestCount = count;
211
+ }
212
+ }
213
+ if (!union.some((effort) => effort.id === best)) return undefined;
214
+ return best;
215
+ }
216
+
217
+ /**
218
+ * Collect the candidate routes of every provider, keyed by pool id.
219
+ * @param providers - registry providers, in registration order.
220
+ * @param options - grouping config.
221
+ * @returns `{ pools, routeIndex }`, where `routeIndex` lists every route in
222
+ * registration order for manual groups.
223
+ */
224
+ function collectPools(providers, options) {
225
+ const pools = new Map();
226
+ const routeIndex = [];
227
+ (Array.isArray(providers) ? providers : []).forEach((provider, registrationIndex) => {
228
+ if (provider === null || typeof provider !== "object" || typeof provider.id !== "string") return;
229
+ if (!providerAllowed(provider.id, options.include, options.exclude)) return;
230
+ if (provider.id === options.providerId) return;
231
+ const priority = priorityOf(provider.id, options.providerOrder, registrationIndex);
232
+ for (const model of Array.isArray(provider.models) ? provider.models : []) {
233
+ if (model === null || typeof model !== "object" || typeof model.id !== "string") continue;
234
+ const poolId = normalizeModelId(model.id);
235
+ if (poolId === "") continue;
236
+ if (modelExcluded(model.id, poolId, options.excludeModels)) continue;
237
+ const route = toRoute(provider, model, priority, registrationIndex);
238
+ if (!pools.has(poolId)) pools.set(poolId, []);
239
+ pools.get(poolId).push(route);
240
+ routeIndex.push({ route, poolId });
241
+ }
242
+ });
243
+ return { pools, routeIndex };
244
+ }
245
+
246
+ /**
247
+ * Compile one group selector into a predicate over a concrete route.
248
+ *
249
+ * Accepted forms, matched against the concrete model id and the normalized pool
250
+ * id (the last one is what makes a provider-prefixed id usable):
251
+ *
252
+ * - `provider/model` — exact concrete route, e.g. `workbuddy/deepseek-v4.1-flash`
253
+ * - `model-id` — exact concrete id, e.g. `deepseek-v4.1-flash-free`
254
+ * - `normalized-id` — the normalized pool id, e.g. `deepseek-v4.1-flash`
255
+ * - any of the above with `*`/`?` glob wildcards, e.g. `deepseek-v4.1-flash*`
256
+ *
257
+ * Glob is a deliberate convenience: one pattern can claim every tier suffix of
258
+ * the same model (`-free`, `-latest`, …) without listing each id.
259
+ *
260
+ * @param entry - one configured selector.
261
+ * @returns a predicate, or undefined when the entry is not a usable string.
262
+ */
263
+ function selectorFor(entry) {
264
+ if (typeof entry !== "string") return undefined;
265
+ const raw = entry.trim();
266
+ if (raw === "") return undefined;
267
+ const pattern = raw.toLowerCase();
268
+ const glob = pattern.includes("*") || pattern.includes("?");
269
+ const regex = glob ? globToRegExp(pattern) : undefined;
270
+ return ({ route, poolId }) => {
271
+ const concrete = `${route.provider}/${route.model}`.toLowerCase();
272
+ const model = route.model.toLowerCase();
273
+ const normalized = poolId;
274
+ if (regex !== undefined) return regex.test(concrete) || regex.test(model) || regex.test(normalized);
275
+ return pattern === concrete || pattern === model || pattern === normalized;
276
+ };
277
+ }
278
+
279
+ /**
280
+ * Translate a `*`/`?` glob into an anchored regular expression.
281
+ * Every other character is escaped, so a model id containing `.` or `+` matches
282
+ * literally.
283
+ * @param pattern - lower-cased glob.
284
+ * @returns the compiled expression.
285
+ */
286
+ function globToRegExp(pattern) {
287
+ let source = "";
288
+ for (const char of pattern) {
289
+ if (char === "*") source += ".*";
290
+ else if (char === "?") source += ".";
291
+ else source += char.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
292
+ }
293
+ return new RegExp(`^${source}$`);
294
+ }
295
+
296
+ /**
297
+ * Build the manual groups declared by `modelGroups`.
298
+ *
299
+ * A group **claims** the routes it selects: with `merge` (the default) those
300
+ * routes leave their natural single-id pool, so one logical model served under
301
+ * several different provider ids becomes one row instead of several. A natural
302
+ * pool left with no routes disappears. With `merge: false` the group is instead
303
+ * an additive curated pool and the natural pools keep their routes.
304
+ *
305
+ * Claims are first-come: a route is taken by the first group whose selectors
306
+ * match it, so listing the same id in two groups is not an error — the earlier
307
+ * group wins and the later one simply gets nothing from it.
308
+ *
309
+ * @param modelGroups - configured groups.
310
+ * @param routeIndex - every route collected from the registry.
311
+ * @returns `{ groups, claimed }` where `claimed` holds the merged-away routes.
312
+ */
313
+ function buildManualGroups(modelGroups, routeIndex) {
314
+ const claimed = new Set();
315
+ const groups = [];
316
+ for (const group of Array.isArray(modelGroups) ? modelGroups : []) {
317
+ if (group === null || typeof group !== "object") continue;
318
+ if (typeof group.name !== "string" || group.name.trim() === "") continue;
319
+ const selectors = (Array.isArray(group.models) ? group.models : [])
320
+ .map(selectorFor)
321
+ .filter((predicate) => predicate !== undefined);
322
+ if (selectors.length === 0) continue;
323
+ const chosen = routeIndex.filter(
324
+ (entry) => !claimed.has(entry.route) && selectors.some((matches) => matches(entry)),
325
+ );
326
+ if (chosen.length === 0) continue;
327
+ const merge = group.merge !== false;
328
+ if (merge) for (const { route } of chosen) claimed.add(route);
329
+ const id = typeof group.id === "string" && group.id.trim() !== "" ? group.id.trim() : group.name.trim();
330
+ groups.push({
331
+ model: toPoolModel(id, chosen.map((entry) => entry.route), group.name.trim()),
332
+ merge,
333
+ });
334
+ }
335
+ return { groups, claimed };
336
+ }
337
+
338
+ /**
339
+ * Build the pool snapshot from a registry read.
340
+ * @param input - `{ providers, modelGroups, providerOrder, include, exclude,
341
+ * excludeModels, providerId, failures }`.
342
+ * @param optionsOrFailures - optional provider catalog failures, or a second options bag.
343
+ * @param failures - optional provider catalog failures.
344
+ * @returns `{ models, failures }` per the contract.
345
+ */
346
+ export function buildPoolSnapshot(input = {}, optionsOrFailures = undefined, failures = undefined) {
347
+ const source = input ?? {};
348
+ const { providers, modelGroups, providerOrder, include, exclude, excludeModels, providerId } = source;
349
+ const { pools, routeIndex } = collectPools(providers, {
350
+ providerId,
351
+ providerOrder,
352
+ include,
353
+ exclude,
354
+ excludeModels,
355
+ });
356
+
357
+ // Manual groups run first: a merged group removes its routes from the natural
358
+ // single-id pools, which is what makes several provider ids collapse into one
359
+ // row. Additive groups leave the natural pools untouched.
360
+ const manual = buildManualGroups(modelGroups, routeIndex);
361
+ const natural = [...pools]
362
+ .map(([id, routes]) => [id, manual.claimed.size === 0 ? routes : routes.filter((route) => !manual.claimed.has(route))])
363
+ .filter(([, routes]) => routes.length > 0);
364
+
365
+ const byId = new Map(natural.map(([id, routes]) => [id, toPoolModel(id, routes)]));
366
+ for (const { model, merge } of manual.groups) {
367
+ // A merged group owns its id outright. An additive group must not silently
368
+ // replace a natural pool with the same id, so its id is suffixed instead.
369
+ const id = merge || !byId.has(model.id) ? model.id : uniqueGroupId(model.id, byId);
370
+ byId.set(id, id === model.id ? model : { ...model, id });
371
+ }
372
+
373
+ const models = [...byId.values()];
374
+ models.sort((left, right) => left.name.localeCompare(right.name, "en") || left.id.localeCompare(right.id, "en"));
375
+ return { models, failures: failureList(source, optionsOrFailures, failures) };
376
+ }
377
+
378
+ /**
379
+ * Pick an id for an additive group whose name collides with a natural pool.
380
+ * @param preferred - the configured group id.
381
+ * @param byId - pools already built.
382
+ * @returns the first free `preferred`, `preferred-2`, `preferred-3`, …
383
+ */
384
+ function uniqueGroupId(preferred, byId) {
385
+ if (!byId.has(preferred)) return preferred;
386
+ for (let suffix = 2; ; suffix += 1) {
387
+ const candidate = `${preferred}-${suffix}`;
388
+ if (!byId.has(candidate)) return candidate;
389
+ }
390
+ }
391
+
392
+ /**
393
+ * Resolve the provider catalog failures to report.
394
+ * @param source - the input bag.
395
+ * @param optionsOrFailures - array of failures, or an options bag carrying `failures`.
396
+ * @param failures - explicit failures argument.
397
+ * @returns the failure list.
398
+ */
399
+ function failureList(source, optionsOrFailures, failures) {
400
+ if (Array.isArray(failures)) return failures;
401
+ if (Array.isArray(optionsOrFailures)) return optionsOrFailures;
402
+ const candidate = failures ?? optionsOrFailures?.failures ?? source.failures;
403
+ return Array.isArray(candidate) ? candidate : [];
404
+ }
package/lib/health.js ADDED
@@ -0,0 +1,175 @@
1
+ /**
2
+ * In-memory availability and latency registry for pool routes.
3
+ * Pure bookkeeping: no I/O, no timers; callers supply `now`.
4
+ * @module dsh-model-pool/lib/health
5
+ */
6
+
7
+ /** Weight of a fresh latency sample in the EWMA. */
8
+ const EWMA_ALPHA = 0.3;
9
+
10
+ /** Cooldown used when a caller omits one. */
11
+ const DEFAULT_COOLDOWN_MS = 60_000;
12
+
13
+ const SEPARATOR = "\u0000";
14
+
15
+ /**
16
+ * Registry key for one concrete route.
17
+ * @param provider - provider id.
18
+ * @param model - concrete model id.
19
+ * @returns the `provider\0model` key.
20
+ */
21
+ function routeKey(provider, model) {
22
+ return `${provider}${SEPARATOR}${model}`;
23
+ }
24
+
25
+ /**
26
+ * Fresh health record for a route.
27
+ * @param provider - provider id.
28
+ * @param model - concrete model id.
29
+ * @returns a record in the `unknown` state.
30
+ */
31
+ function blankEntry(provider, model) {
32
+ return { provider, model, state: "unknown", failures: 0, successes: 0, uses: 0 };
33
+ }
34
+
35
+ /**
36
+ * Per-route health: consecutive failures, successes, EWMA time-to-first-chunk and
37
+ * cooldown state. One instance lives on the plugin; every method is synchronous.
38
+ */
39
+ export class HealthRegistry {
40
+ constructor() {
41
+ /** @type {Map<string, object>} */
42
+ this.records = new Map();
43
+ }
44
+
45
+ /**
46
+ * Record a completed attempt and fold its latency into the EWMA.
47
+ * @param provider - provider id.
48
+ * @param model - concrete model id.
49
+ * @param latencyMs - time to first chunk, in milliseconds.
50
+ * @param now - clock reading for the success timestamp.
51
+ * @returns the updated record.
52
+ */
53
+ recordSuccess(provider, model, latencyMs, now = Date.now()) {
54
+ const entry = this.#entry(provider, model);
55
+ entry.state = "ok";
56
+ entry.failures = 0;
57
+ entry.successes += 1;
58
+ entry.uses += 1;
59
+ entry.lastOkAt = now;
60
+ delete entry.reason;
61
+ delete entry.cooldownUntil;
62
+ if (Number.isFinite(latencyMs)) {
63
+ entry.latencyMs =
64
+ entry.latencyMs === undefined
65
+ ? latencyMs
66
+ : (1 - EWMA_ALPHA) * entry.latencyMs + EWMA_ALPHA * latencyMs;
67
+ }
68
+ return entry;
69
+ }
70
+
71
+ /**
72
+ * Record a failed attempt and open its cooldown window.
73
+ * @param provider - provider id.
74
+ * @param model - concrete model id.
75
+ * @param reason - failure code or message.
76
+ * @param now - clock reading for the failure timestamp.
77
+ * @param cooldownMs - cooldown length used for the stored `cooldownUntil`.
78
+ * @returns the updated record.
79
+ */
80
+ recordFailure(provider, model, reason, now = Date.now(), cooldownMs = DEFAULT_COOLDOWN_MS) {
81
+ const entry = this.#entry(provider, model);
82
+ entry.state = "unavailable";
83
+ entry.failures += 1;
84
+ entry.uses += 1;
85
+ entry.lastErrorAt = now;
86
+ entry.cooldownUntil = now + cooldownMs;
87
+ if (reason !== undefined) entry.reason = reason;
88
+ return entry;
89
+ }
90
+
91
+ /**
92
+ * Current state of a route at `now`.
93
+ * @param provider - provider id.
94
+ * @param model - concrete model id.
95
+ * @param now - clock reading.
96
+ * @param cooldownMs - cooldown length applied to the last error.
97
+ * @returns `unknown` before first use, `ok` while healthy, `unavailable` inside the
98
+ * cooldown window and `degraded` after it.
99
+ */
100
+ statusOf(provider, model, now = Date.now(), cooldownMs = DEFAULT_COOLDOWN_MS) {
101
+ const entry = this.records.get(routeKey(provider, model));
102
+ if (entry === undefined) return "unknown";
103
+ if (entry.failures === 0) {
104
+ entry.state = entry.successes > 0 ? "ok" : "unknown";
105
+ delete entry.cooldownUntil;
106
+ return entry.state;
107
+ }
108
+ const cooldownUntil = (entry.lastErrorAt ?? now) + cooldownMs;
109
+ entry.cooldownUntil = cooldownUntil;
110
+ entry.state = now < cooldownUntil ? "unavailable" : "degraded";
111
+ return entry.state;
112
+ }
113
+
114
+ /**
115
+ * Whether a route may be routed to at `now`.
116
+ * @param provider - provider id.
117
+ * @param model - concrete model id.
118
+ * @param now - clock reading.
119
+ * @param cooldownMs - cooldown length applied to the last error.
120
+ * @returns false only inside the cooldown window.
121
+ */
122
+ isAvailable(provider, model, now = Date.now(), cooldownMs = DEFAULT_COOLDOWN_MS) {
123
+ return this.statusOf(provider, model, now, cooldownMs) !== "unavailable";
124
+ }
125
+
126
+ /**
127
+ * Read one record without creating it.
128
+ * @param provider - provider id.
129
+ * @param model - concrete model id.
130
+ * @returns a copy of the record, or undefined when the route was never used.
131
+ */
132
+ entryOf(provider, model) {
133
+ const entry = this.records.get(routeKey(provider, model));
134
+ return entry === undefined ? undefined : { ...entry };
135
+ }
136
+
137
+ /**
138
+ * Every record, in first-seen order.
139
+ * @returns copies of all records.
140
+ */
141
+ entries() {
142
+ return [...this.records.values()].map((entry) => ({ ...entry }));
143
+ }
144
+
145
+ /**
146
+ * JSON-friendly view for the HTTP state route.
147
+ * @returns a `provider/model` keyed map of record copies.
148
+ */
149
+ snapshot() {
150
+ const out = {};
151
+ for (const entry of this.records.values()) out[`${entry.provider}/${entry.model}`] = { ...entry };
152
+ return out;
153
+ }
154
+
155
+ /** Drop every record. */
156
+ reset() {
157
+ this.records.clear();
158
+ }
159
+
160
+ /**
161
+ * Get or create the record for a route.
162
+ * @param provider - provider id.
163
+ * @param model - concrete model id.
164
+ * @returns the live record.
165
+ */
166
+ #entry(provider, model) {
167
+ const key = routeKey(provider, model);
168
+ let entry = this.records.get(key);
169
+ if (entry === undefined) {
170
+ entry = blankEntry(provider, model);
171
+ this.records.set(key, entry);
172
+ }
173
+ return entry;
174
+ }
175
+ }