@codefast/di 0.9.0 → 0.10.1

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.
Files changed (73) hide show
  1. package/CHANGELOG.md +165 -0
  2. package/README.md +6 -5
  3. package/dist/ambient/active-container.d.ts +14 -4
  4. package/dist/ambient/active-container.js +22 -1
  5. package/dist/container/binding-builders.d.ts +35 -13
  6. package/dist/container/binding-builders.js +174 -97
  7. package/dist/container/container.d.ts +10 -10
  8. package/dist/container/container.js +53 -36
  9. package/dist/core/binding-scope.d.ts +2 -2
  10. package/dist/core/binding.d.ts +42 -49
  11. package/dist/core/binding.js +12 -38
  12. package/dist/core/constraint-requirement.d.ts +1 -1
  13. package/dist/core/module.d.ts +3 -3
  14. package/dist/core/registry.d.ts +52 -9
  15. package/dist/core/registry.js +376 -162
  16. package/dist/core/state-epoch.d.ts +16 -0
  17. package/dist/core/state-epoch.js +21 -0
  18. package/dist/core/token.d.ts +1 -1
  19. package/dist/core/types.d.ts +12 -9
  20. package/dist/decorators/inject.d.ts +3 -3
  21. package/dist/decorators/inject.js +5 -5
  22. package/dist/decorators/injectable.d.ts +2 -2
  23. package/dist/decorators/injectable.js +2 -2
  24. package/dist/decorators/lifecycle-decorators.js +2 -2
  25. package/dist/errors/diagnostics.d.ts +2 -0
  26. package/dist/errors/errors.d.ts +29 -3
  27. package/dist/errors/errors.js +34 -2
  28. package/dist/index.d.ts +35 -35
  29. package/dist/index.js +19 -19
  30. package/dist/injection/descriptor.d.ts +9 -7
  31. package/dist/injection/descriptor.js +3 -1
  32. package/dist/injection/resolve-options.d.ts +9 -3
  33. package/dist/injection/resolve-options.js +17 -1
  34. package/dist/introspection/dependency-graph.d.ts +3 -3
  35. package/dist/introspection/dependency-graph.js +15 -10
  36. package/dist/introspection/graph-adapters/cytoscape.d.ts +1 -1
  37. package/dist/introspection/graph-adapters/dot.d.ts +1 -1
  38. package/dist/introspection/graph-adapters/mermaid.d.ts +1 -1
  39. package/dist/introspection/graph-adapters/reactflow.d.ts +1 -1
  40. package/dist/introspection/inspector.d.ts +7 -5
  41. package/dist/introspection/inspector.js +13 -27
  42. package/dist/lifecycle/lifecycle-manager.d.ts +4 -4
  43. package/dist/lifecycle/lifecycle-manager.js +13 -11
  44. package/dist/lifecycle/scope-manager.d.ts +2 -2
  45. package/dist/lifecycle/scope-manager.js +4 -4
  46. package/dist/metadata/metadata-reader-token.d.ts +2 -2
  47. package/dist/metadata/metadata-reader-token.js +1 -1
  48. package/dist/metadata/metadata-types.d.ts +3 -3
  49. package/dist/metadata/symbol-metadata-reader.d.ts +3 -3
  50. package/dist/metadata/symbol-metadata-reader.js +1 -1
  51. package/dist/metadata/verifying-metadata-reader.d.ts +1 -1
  52. package/dist/metadata/verifying-metadata-reader.js +2 -2
  53. package/dist/resolution/cache/activation-need.d.ts +6 -4
  54. package/dist/resolution/cache/activation-need.js +13 -6
  55. package/dist/resolution/cache/binding-lookup-cache.d.ts +39 -6
  56. package/dist/resolution/cache/binding-lookup-cache.js +97 -13
  57. package/dist/resolution/cache/class-introspector.d.ts +6 -6
  58. package/dist/resolution/cache/class-introspector.js +82 -69
  59. package/dist/resolution/context.d.ts +11 -11
  60. package/dist/resolution/context.js +1 -1
  61. package/dist/resolution/path/resolution-path.d.ts +1 -1
  62. package/dist/resolution/path/resolution-path.js +1 -1
  63. package/dist/resolution/plan/instantiation-plan.d.ts +16 -4
  64. package/dist/resolution/plan/instantiation-plan.js +160 -69
  65. package/dist/resolution/plan/plan-codegen.d.ts +100 -0
  66. package/dist/resolution/plan/plan-codegen.js +185 -0
  67. package/dist/resolution/resolver.d.ts +26 -16
  68. package/dist/resolution/resolver.js +401 -147
  69. package/dist/resolution/select/binding-select.d.ts +6 -5
  70. package/dist/resolution/select/binding-select.js +17 -11
  71. package/dist/resolution/select/constraints.d.ts +3 -3
  72. package/dist/resolution/select/constraints.js +4 -4
  73. package/package.json +11 -3
@@ -1,5 +1,16 @@
1
- import { bindingSlotEquals, bindingSlotToString } from "#/core/binding";
2
- import { getOrInsert } from "#/core/map-upsert";
1
+ import { bindingSlotEquals, bindingSlotToString, writableMembership, writablePredicate } from "#core/binding";
2
+ import { getOrInsert } from "#core/map-upsert";
3
+ import { advanceStateEpoch } from "#core/state-epoch";
4
+ const NO_BINDINGS = Object.freeze([]);
5
+ /** One construction site, so every record shares a hidden class. */
6
+ function createTokenRecord(bindings) {
7
+ return {
8
+ bindings,
9
+ defaultOccupant: undefined,
10
+ simple: undefined,
11
+ multi: undefined,
12
+ };
13
+ }
3
14
  /**
4
15
  * One container's binding store, indexed by token, binding id, and slot for fast lookup.
5
16
  *
@@ -8,18 +19,17 @@ import { getOrInsert } from "#/core/map-upsert";
8
19
  export class BindingRegistry {
9
20
  // Monotonic mutation counter — lets resolvers version-stamp lookup caches across a container chain.
10
21
  #version = 0;
11
- // Map from token key -> array of bindings (order matters for last-wins)
12
- #bindings = new Map();
13
- // Fast lookup by binding ID
14
- #byId = new Map();
15
- // Fast path for one default slot binding with no predicate
16
- #fastDefault = new Map();
17
- // Fast lookup for a slot carrying exactly one criterion (a lone name folds here too) — keyed by
18
- // the interned criterion itself, so the pair is one hash. Unallocated until such a slot lands.
19
- #simpleTagged;
20
- // Slots with two or more criteria, bucketed by their FIRST criterion. A matching slot's every
21
- // criterion is in the request, so walking the request's buckets finds each candidate exactly once.
22
- #multiTagged;
22
+ // The common token lives here and nowhere else: exactly one default-slot binding, so the hot read
23
+ // of every resolve is a bare `Map.get` and a plain bind is one map write.
24
+ #lone = new Map();
25
+ // Every other token — several bindings, a tagged slot, a predicate — has a record here, and a
26
+ // token is in exactly one of the two maps. Allocated by the first token that needs a record.
27
+ #records;
28
+ // Built on the first id-keyed read and maintained from then on: a bind-and-resolve container
29
+ // never asks by id, so it never pays for the second map.
30
+ #byId;
31
+ // Set when the first tagged slot lands and never cleared, like the tagged maps it stands for.
32
+ #taggedIndexBuilt = false;
23
33
  // Set on the first constant registered and never cleared. Teardown only needs the negative answer
24
34
  // to be exact, and that is what lets a container holding no constant skip its sweep entirely.
25
35
  #heldConstantBinding = false;
@@ -27,134 +37,186 @@ export class BindingRegistry {
27
37
  get version() {
28
38
  return this.#version;
29
39
  }
40
+ // Every mutation moves the process-wide epoch too, which is what lets a descendant's cache skip
41
+ // re-summing the chain while nothing anywhere has changed.
42
+ #bump() {
43
+ this.#version += 1;
44
+ advanceStateEpoch();
45
+ }
30
46
  /** Whether a constant has ever been registered here, and so whether teardown has anything to sweep. */
31
47
  get hasHeldConstantBinding() {
32
48
  return this.#heldConstantBinding;
33
49
  }
50
+ /** Whether the deferred tagged-slot index has had to be built. */
51
+ get isTaggedIndexBuilt() {
52
+ return this.#taggedIndexBuilt;
53
+ }
54
+ /** Whether an id-keyed operation has had to build the id index. */
55
+ get isIdIndexBuilt() {
56
+ return this.#byId !== undefined;
57
+ }
58
+ /** Whether a token carrying more than one default-slot binding has had to build the record map. */
59
+ get isRecordMapBuilt() {
60
+ return this.#records !== undefined;
61
+ }
34
62
  /**
35
63
  * Registers a mutation the indexes don't care about (a fluent chain refining scope or an
36
64
  * activation hook in place), so version-stamped resolver caches still invalidate.
37
65
  */
38
66
  touch() {
39
- this.#version += 1;
67
+ this.#bump();
40
68
  }
41
69
  /**
42
70
  * Adds or replaces a binding using slot-aware last-wins. Returns the displaced binding, if any.
43
71
  *
44
- * @remarks The binding is stored by reference — it must come from `createBinding`, which is
45
- * what guarantees the single hidden class the resolver's hot reads depend on.
72
+ * @remarks The binding is stored by reference — it must come from the one binding builder, which
73
+ * is what guarantees the single hidden class the resolver's hot reads depend on.
46
74
  */
47
75
  add(binding) {
48
- this.#version += 1;
76
+ this.#bump();
49
77
  if (binding.kind === "constant") {
50
78
  this.#heldConstantBinding = true;
51
79
  }
52
80
  const key = binding.token;
53
- // Copy-on-write: a selection may be walking the current list inside a `when()` predicate, so
54
- // mutation replaces the array and never splices one that has been handed out.
55
- const bindingsForToken = this.#bindings.get(key);
56
- // Only apply last-wins for slot-based bindings (not predicate-only)
57
- let displacedBinding;
58
- let nextBindings;
59
- if (bindingsForToken === undefined) {
60
- nextBindings = [binding];
81
+ const record = this.#records?.get(key);
82
+ if (record !== undefined) {
83
+ return this.#addToRecord(key, record, binding);
61
84
  }
62
- else {
63
- if (!isPurePredicateBinding(binding)) {
64
- const existingIndex = bindingsForToken.findIndex((candidate) => !isPurePredicateBinding(candidate) && bindingSlotEquals(candidate.slot, binding.slot));
65
- if (existingIndex !== -1) {
66
- displacedBinding = bindingsForToken[existingIndex];
67
- this.#byId.delete(displacedBinding.id);
68
- this.#deindexSimpleTaggedBinding(key, displacedBinding);
69
- this.#deindexMultiTaggedBinding(key, displacedBinding);
70
- }
85
+ const lone = this.#lone.get(key);
86
+ if (lone === undefined) {
87
+ this.#byId?.set(binding.identifier, binding);
88
+ if (isDefaultSlotBinding(binding)) {
89
+ this.#lone.set(key, binding);
71
90
  }
72
- nextBindings =
73
- displacedBinding === undefined
74
- ? [...bindingsForToken, binding]
75
- : [...bindingsForToken.filter((candidate) => candidate !== displacedBinding), binding];
76
- }
77
- this.#bindings.set(key, nextBindings);
78
- this.#byId.set(binding.id, binding);
79
- this.#indexSimpleTaggedBinding(key, binding);
80
- this.#indexMultiTaggedBinding(key, binding);
81
- this.#refreshFastDefaultForToken(key);
82
- return displacedBinding;
91
+ else {
92
+ this.#createRecord(key, [binding]);
93
+ }
94
+ return undefined;
95
+ }
96
+ // Same slot, last wins: the newcomer takes the lone seat and nothing else moves.
97
+ if (isDefaultSlotBinding(binding)) {
98
+ this.#lone.set(key, binding);
99
+ if (this.#byId !== undefined) {
100
+ this.#byId.delete(lone.identifier);
101
+ this.#byId.set(binding.identifier, binding);
102
+ }
103
+ return lone;
104
+ }
105
+ // A second shape joins the token, which is what a record is for.
106
+ this.#lone.delete(key);
107
+ this.#byId?.set(binding.identifier, binding);
108
+ this.#createRecord(key, [lone, binding]);
109
+ return undefined;
83
110
  }
84
111
  /** Remove all bindings for a token. Returns removed bindings. */
85
112
  removeByToken(token) {
86
- this.#version += 1;
87
- const key = token;
88
- const bindingsForToken = this.#bindings.get(key) ?? [];
89
- this.#bindings.delete(key);
90
- this.#simpleTagged?.delete(key);
91
- this.#multiTagged?.delete(key);
92
- this.#fastDefault.delete(key);
93
- for (const binding of bindingsForToken) {
94
- this.#byId.delete(binding.id);
113
+ this.#bump();
114
+ const lone = this.#lone.get(token);
115
+ if (lone !== undefined) {
116
+ this.#lone.delete(token);
117
+ this.#byId?.delete(lone.identifier);
118
+ return [lone];
119
+ }
120
+ const records = this.#records;
121
+ const record = records?.get(token);
122
+ if (records === undefined || record === undefined) {
123
+ return [];
95
124
  }
96
- return bindingsForToken;
125
+ records.delete(token);
126
+ if (this.#byId !== undefined) {
127
+ for (const binding of record.bindings) {
128
+ this.#byId.delete(binding.identifier);
129
+ }
130
+ }
131
+ return [...record.bindings];
97
132
  }
98
133
  /** Remove a specific binding by ID. Returns the removed binding or undefined. */
99
134
  removeById(id) {
100
- const binding = this.#byId.get(id);
135
+ const byId = this.#ensureById();
136
+ const binding = byId.get(id);
101
137
  if (binding === undefined) {
102
138
  return undefined;
103
139
  }
104
- this.#version += 1;
105
- this.#byId.delete(id);
140
+ this.#bump();
141
+ byId.delete(id);
106
142
  const key = binding.token;
107
- const bindingsForToken = this.#bindings.get(key);
108
- if (bindingsForToken !== undefined) {
109
- const bindingIndex = bindingsForToken.findIndex((candidate) => candidate.id === id);
110
- // Copy-on-write, like `add`: a walk holding the current array must not lose its place.
111
- const remaining = bindingIndex === -1 ? bindingsForToken : bindingsForToken.toSpliced(bindingIndex, 1);
112
- this.#deindexSimpleTaggedBinding(key, binding);
113
- this.#deindexMultiTaggedBinding(key, binding);
114
- if (remaining.length === 0) {
115
- this.#bindings.delete(key);
116
- this.#simpleTagged?.delete(key);
117
- this.#multiTagged?.delete(key);
118
- this.#fastDefault.delete(key);
119
- }
120
- else {
121
- this.#bindings.set(key, remaining);
122
- this.#refreshFastDefaultForToken(key);
143
+ if (this.#lone.get(key)?.identifier === id) {
144
+ this.#lone.delete(key);
145
+ return binding;
146
+ }
147
+ const record = this.#records?.get(key);
148
+ if (record !== undefined) {
149
+ const bindingIndex = record.bindings.findIndex((candidate) => candidate.identifier === id);
150
+ // Replaced, never spliced: a walk holding the current array must not lose its place.
151
+ if (bindingIndex !== -1) {
152
+ record.bindings = record.bindings.toSpliced(bindingIndex, 1);
123
153
  }
154
+ this.#deindexSlot(record, binding);
155
+ this.#settle(key, record);
124
156
  }
125
157
  return binding;
126
158
  }
127
- /** Get all bindings for a token. */
159
+ /**
160
+ * Get all bindings for a token.
161
+ *
162
+ * @remarks Allocates a one-element list for a lone default-slot binding, so a hot path asks
163
+ * `getFastDefault()` first and reaches here only for a token that keeps a record.
164
+ */
128
165
  getAll(token) {
129
- return this.#bindings.get(token) ?? [];
166
+ const record = this.#records?.get(token);
167
+ if (record !== undefined) {
168
+ return record.bindings;
169
+ }
170
+ const lone = this.#lone.get(token);
171
+ return lone === undefined ? NO_BINDINGS : [lone];
172
+ }
173
+ /**
174
+ * The bindings of a token that keeps a record, or none.
175
+ *
176
+ * @remarks For a caller whose lone-map probe has just missed: the record map is all that is left
177
+ * to ask, and a lone binding's one-element list is never materialised here.
178
+ */
179
+ getRecorded(token) {
180
+ return this.#records?.get(token)?.bindings ?? NO_BINDINGS;
181
+ }
182
+ /** How many bindings a token holds, without materialising a lone binding's list. */
183
+ countBindings(token) {
184
+ const record = this.#records?.get(token);
185
+ if (record !== undefined) {
186
+ return record.bindings.length;
187
+ }
188
+ return this.#lone.has(token) ? 1 : 0;
130
189
  }
131
190
  /** Get binding by ID. */
132
191
  getById(id) {
133
- return this.#byId.get(id);
192
+ return this.#ensureById().get(id);
134
193
  }
135
194
  /** Check if any binding exists for token. */
136
195
  has(token) {
137
- const key = token;
138
- const list = this.#bindings.get(key);
139
- return list !== undefined && list.length > 0;
196
+ // A record is dropped with its last binding, so presence in either map is the whole answer. The
197
+ // size read keeps a container that never bound anything — every per-request child — off the probe.
198
+ return ((this.#lone.size !== 0 && this.#lone.has(token)) || (this.#records !== undefined && this.#records.has(token)));
140
199
  }
141
200
  /** All bindings in the registry. */
142
201
  allBindings() {
143
- const allBindings = [];
144
- for (const bindingsForToken of this.#bindings.values()) {
145
- allBindings.push(...bindingsForToken);
202
+ if (this.#lone.size === 0 && this.#records === undefined) {
203
+ return NO_BINDINGS;
204
+ }
205
+ const allBindings = [...this.#lone.values()];
206
+ if (this.#records !== undefined) {
207
+ for (const record of this.#records.values()) {
208
+ allBindings.push(...record.bindings);
209
+ }
146
210
  }
147
211
  return allBindings;
148
212
  }
149
213
  /** Remove all bindings. Returns all removed. */
150
214
  clear() {
151
- this.#version += 1;
215
+ this.#bump();
152
216
  const all = this.allBindings();
153
- this.#bindings.clear();
154
- this.#byId.clear();
155
- this.#simpleTagged?.clear();
156
- this.#multiTagged?.clear();
157
- this.#fastDefault.clear();
217
+ this.#lone.clear();
218
+ this.#records?.clear();
219
+ this.#byId?.clear();
158
220
  return all;
159
221
  }
160
222
  /** Whether a slot-based binding currently occupies `binding`'s slot, so adding it would displace. */
@@ -162,11 +224,15 @@ export class BindingRegistry {
162
224
  if (isPurePredicateBinding(binding)) {
163
225
  return false;
164
226
  }
165
- const candidates = this.#bindings.get(binding.token);
166
- if (candidates === undefined) {
227
+ if (this.#lone.has(binding.token)) {
228
+ // The lone seat is the default slot, so only a default-slot newcomer collides with it.
229
+ return binding.slot.tags.length === 0;
230
+ }
231
+ const record = this.#records?.get(binding.token);
232
+ if (record === undefined) {
167
233
  return false;
168
234
  }
169
- return candidates.some((candidate) => !isPurePredicateBinding(candidate) && bindingSlotEquals(candidate.slot, binding.slot));
235
+ return this.#slotOccupant(record, binding.slot) !== undefined;
170
236
  }
171
237
  /**
172
238
  * The binding indexed under one criterion.
@@ -175,7 +241,15 @@ export class BindingRegistry {
175
241
  * identity — where a value-keyed map answered by SameValueZero and parted from `Object.is` on ±0.
176
242
  */
177
243
  getSimpleTagged(token, criterion) {
178
- return this.#simpleTagged?.get(token)?.get(criterion);
244
+ return this.#records?.get(token)?.simple?.get(criterion);
245
+ }
246
+ /** The binding whose slot is exactly these two criteria, declared in either order, or `undefined`. */
247
+ getPairTagged(token, first, second) {
248
+ const multi = this.#records?.get(token)?.multi;
249
+ if (multi === undefined) {
250
+ return undefined;
251
+ }
252
+ return findPairIn(multi.get(first), first, second) ?? findPairIn(multi.get(second), first, second);
179
253
  }
180
254
  /**
181
255
  * The multi-tag bindings whose slot's first criterion is `criterion`.
@@ -184,100 +258,240 @@ export class BindingRegistry {
184
258
  * against the request — first-criterion bucketing only guarantees each candidate appears once.
185
259
  */
186
260
  getMultiTagged(token, criterion) {
187
- return this.#multiTagged?.get(token)?.get(criterion);
261
+ return this.#records?.get(token)?.multi?.get(criterion);
188
262
  }
263
+ /** A token's lone default-slot binding — the first read of every synchronous resolve. */
189
264
  getFastDefault(token) {
190
- return this.#fastDefault.get(token);
265
+ return this.#lone.get(token);
191
266
  }
192
- /** Summarize available slot strings for a token (for error messages). */
193
- availableSlotStrings(token) {
194
- const bindingsForToken = this.#bindings.get(token) ?? [];
195
- return bindingsForToken.map((binding) => bindingSlotToString(binding.slot));
267
+ /**
268
+ * Takes a live binding out of every index, lets `rewrite` change its slot or predicate, and reports
269
+ * whether it was live; the caller registers it again with `add`.
270
+ *
271
+ * @remarks The binding keeps its object and its id, so the id index needs no touch and nothing
272
+ * that holds the object has to be told. `false` means the binding was unbound or displaced since
273
+ * it registered, and a refinement must not resurrect it.
274
+ */
275
+ reslot(binding, rewrite) {
276
+ const key = binding.token;
277
+ if (this.#lone.get(key) === binding) {
278
+ this.#bump();
279
+ this.#lone.delete(key);
280
+ }
281
+ else {
282
+ const record = this.#records?.get(key);
283
+ const index = record === undefined ? -1 : record.bindings.indexOf(binding);
284
+ if (record === undefined || index === -1) {
285
+ return false;
286
+ }
287
+ this.#bump();
288
+ // Replaced, never spliced: a walk holding the current array must not lose its place.
289
+ record.bindings = record.bindings.toSpliced(index, 1);
290
+ this.#deindexSlot(record, binding);
291
+ this.#settle(key, record);
292
+ }
293
+ rewrite();
294
+ return true;
196
295
  }
197
- #indexSimpleTaggedBinding(tokenKey, binding) {
198
- const criterion = simpleTagOf(binding);
199
- if (criterion === undefined) {
296
+ /**
297
+ * Marks a live binding as a collection member in place, moving it out of the lone map: a member is
298
+ * never the token's lone default answer, and nothing else indexes on membership.
299
+ */
300
+ setMany(binding) {
301
+ this.#bump();
302
+ // Set before any (re)indexing so `#indexSlot` sees a member and leaves it out of every slot.
303
+ writableMembership(binding).isMany = true;
304
+ if (this.#promoteLoneToRecord(binding.token, binding)) {
200
305
  return;
201
306
  }
202
- this.#simpleTagged ??= new Map();
203
- const byCriterion = getOrInsert(this.#simpleTagged, tokenKey, new Map());
204
- byCriterion.set(criterion, binding);
307
+ // A default occupant that becomes a member frees its slot, so drop the stale index entry. A
308
+ // member cannot collapse a record back to lone, so this path does not settle.
309
+ this.#clearDefaultOccupant(binding.token, binding);
205
310
  }
206
- #deindexSimpleTaggedBinding(tokenKey, binding) {
207
- const criterion = simpleTagOf(binding);
208
- if (criterion === undefined) {
311
+ /**
312
+ * Adds a predicate to a live binding in place.
313
+ *
314
+ * @remarks Only ever narrows — `when()` composes with any existing predicate — so the argument is
315
+ * never absent. Nothing indexes on the predicate, so the binding object and its id stay; a lone
316
+ * binding moves to a record because the lone map holds default-slot bindings with no predicate.
317
+ */
318
+ setPredicate(binding, predicate) {
319
+ this.#bump();
320
+ // Set before any (re)indexing: a predicate-only binding holds no default slot.
321
+ writablePredicate(binding).predicate = predicate;
322
+ if (this.#promoteLoneToRecord(binding.token, binding)) {
209
323
  return;
210
324
  }
211
- const byCriterion = this.#simpleTagged?.get(tokenKey);
212
- if (byCriterion === undefined) {
213
- return;
325
+ // The binding may have vacated the default slot, and a narrowed record can collapse back to lone.
326
+ const record = this.#clearDefaultOccupant(binding.token, binding);
327
+ if (record !== undefined) {
328
+ this.#settle(binding.token, record);
329
+ }
330
+ }
331
+ /**
332
+ * Moves a binding still holding the lone seat into a fresh one-binding record, returning whether it did.
333
+ *
334
+ * @remarks Its field (`isMany` or `predicate`) is written before this call, so the founding
335
+ * `#indexSlot` files it under the slot it now holds.
336
+ */
337
+ #promoteLoneToRecord(key, binding) {
338
+ if (this.#lone.get(key) !== binding) {
339
+ return false;
340
+ }
341
+ this.#lone.delete(key);
342
+ this.#createRecord(key, [binding]);
343
+ return true;
344
+ }
345
+ /** Clears the default-slot index entry a binding has vacated, returning its record if one exists. */
346
+ #clearDefaultOccupant(key, binding) {
347
+ const record = this.#records?.get(key);
348
+ if (record?.defaultOccupant === binding) {
349
+ record.defaultOccupant = undefined;
350
+ }
351
+ return record;
352
+ }
353
+ /** Summarize available slot strings for a token (for error messages). */
354
+ availableSlotStrings(token) {
355
+ return this.getAll(token).map((binding) => bindingSlotToString(binding.slot));
356
+ }
357
+ #createRecord(key, bindings) {
358
+ const record = createTokenRecord(bindings);
359
+ (this.#records ??= new Map()).set(key, record);
360
+ // A promoted lone binding carries its slot into the record, so index every founding binding.
361
+ for (const binding of bindings) {
362
+ this.#indexSlot(record, binding);
214
363
  }
215
- if (byCriterion.get(criterion)?.id === binding.id) {
216
- byCriterion.delete(criterion);
217
- if (byCriterion.size === 0) {
218
- this.#simpleTagged.delete(tokenKey);
364
+ return record;
365
+ }
366
+ /** The binding occupying `slot` in this record, found through the slot indexes, or `undefined`. */
367
+ #slotOccupant(record, slot) {
368
+ const { tags } = slot;
369
+ if (tags.length === 0) {
370
+ return record.defaultOccupant;
371
+ }
372
+ if (tags.length === 1) {
373
+ return record.simple?.get(tags[0]);
374
+ }
375
+ // Two-plus criteria are rare and can be bucketed under either criterion, so the list decides.
376
+ return record.bindings.find((candidate) => !isPurePredicateBinding(candidate) && bindingSlotEquals(candidate.slot, slot));
377
+ }
378
+ #addToRecord(key, record, binding) {
379
+ // Last-wins applies only to a slot-based newcomer, and the slot indexes name its occupant
380
+ // directly — so a member joining a collection of members displaces nobody without a list walk.
381
+ let displacedBinding;
382
+ if (!isPurePredicateBinding(binding)) {
383
+ displacedBinding = this.#slotOccupant(record, binding.slot);
384
+ if (displacedBinding !== undefined) {
385
+ this.#byId?.delete(displacedBinding.identifier);
386
+ this.#deindexSlot(record, displacedBinding);
219
387
  }
220
388
  }
389
+ // A selection may be walking this list inside a `when()` predicate. An append past the length it
390
+ // read cannot shift it, so it lands in place; a displacement replaces the array instead.
391
+ if (displacedBinding === undefined) {
392
+ record.bindings.push(binding);
393
+ }
394
+ else {
395
+ record.bindings = [...record.bindings.filter((candidate) => candidate !== displacedBinding), binding];
396
+ }
397
+ this.#byId?.set(binding.identifier, binding);
398
+ this.#indexSlot(record, binding);
399
+ this.#settle(key, record);
400
+ return displacedBinding;
221
401
  }
222
- #indexMultiTaggedBinding(tokenKey, binding) {
223
- const firstCriterion = multiTagFirstOf(binding);
224
- if (firstCriterion === undefined) {
225
- return;
402
+ // A record that shrank to one default-slot binding goes back to the lone map; an empty one goes.
403
+ #settle(key, record) {
404
+ const { bindings } = record;
405
+ if (bindings.length === 0) {
406
+ this.#records.delete(key);
407
+ }
408
+ else if (bindings.length === 1 && isDefaultSlotBinding(bindings[0])) {
409
+ this.#records.delete(key);
410
+ this.#lone.set(key, bindings[0]);
226
411
  }
227
- this.#multiTagged ??= new Map();
228
- const buckets = getOrInsert(this.#multiTagged, tokenKey, new Map());
229
- getOrInsert(buckets, firstCriterion, []).push(binding);
230
412
  }
231
- #deindexMultiTaggedBinding(tokenKey, binding) {
232
- const firstCriterion = multiTagFirstOf(binding);
233
- if (firstCriterion === undefined) {
413
+ #ensureById() {
414
+ if (this.#byId === undefined) {
415
+ const byId = new Map();
416
+ for (const binding of this.#lone.values()) {
417
+ byId.set(binding.identifier, binding);
418
+ }
419
+ if (this.#records !== undefined) {
420
+ for (const record of this.#records.values()) {
421
+ for (const binding of record.bindings) {
422
+ byId.set(binding.identifier, binding);
423
+ }
424
+ }
425
+ }
426
+ this.#byId = byId;
427
+ }
428
+ return this.#byId;
429
+ }
430
+ // Indexes a binding by its slot so an add finds who it displaces without walking the list: the
431
+ // default slot in `defaultOccupant`, one criterion in the exact map, more in the first-criterion
432
+ // bucket. A collection member or a predicate-only binding occupies no slot and is left unindexed.
433
+ #indexSlot(record, binding) {
434
+ if (isPurePredicateBinding(binding)) {
234
435
  return;
235
436
  }
236
- const bucket = this.#multiTagged?.get(tokenKey)?.get(firstCriterion);
237
- if (bucket === undefined) {
437
+ const { tags } = binding.slot;
438
+ if (tags.length === 0) {
439
+ record.defaultOccupant = binding;
238
440
  return;
239
441
  }
240
- const bindingIndex = bucket.findIndex((candidate) => candidate.id === binding.id);
241
- // Spliced in place: nothing walks a bucket while user code runs — candidates are gathered
242
- // into their own array before any predicate is evaluated.
243
- if (bindingIndex !== -1) {
244
- bucket.splice(bindingIndex, 1);
442
+ this.#taggedIndexBuilt = true;
443
+ if (tags.length === 1) {
444
+ (record.simple ??= new Map()).set(tags[0], binding);
445
+ }
446
+ else {
447
+ getOrInsert((record.multi ??= new Map()), tags[0], []).push(binding);
245
448
  }
246
449
  }
247
- #refreshFastDefaultForToken(tokenKey) {
248
- const bindingsForToken = this.#bindings.get(tokenKey);
249
- const onlyBinding = bindingsForToken?.length === 1 ? bindingsForToken[0] : undefined;
250
- if (onlyBinding !== undefined && isDefaultSlotBinding(onlyBinding)) {
251
- this.#fastDefault.set(tokenKey, onlyBinding);
450
+ #deindexSlot(record, binding) {
451
+ if (isPurePredicateBinding(binding)) {
252
452
  return;
253
453
  }
254
- this.#fastDefault.delete(tokenKey);
255
- }
256
- /** Whether the deferred tagged-slot index has had to be built. */
257
- get isTaggedIndexBuilt() {
258
- return this.#simpleTagged !== undefined;
454
+ const { tags } = binding.slot;
455
+ if (tags.length === 0) {
456
+ if (record.defaultOccupant?.identifier === binding.identifier) {
457
+ record.defaultOccupant = undefined;
458
+ }
459
+ return;
460
+ }
461
+ if (tags.length === 1) {
462
+ if (record.simple?.get(tags[0])?.identifier === binding.identifier) {
463
+ record.simple.delete(tags[0]);
464
+ }
465
+ }
466
+ else if (tags.length >= 2) {
467
+ const bucket = record.multi?.get(tags[0]);
468
+ const bindingIndex = bucket?.findIndex((candidate) => candidate.identifier === binding.identifier) ?? -1;
469
+ // Spliced in place: nothing walks a bucket while user code runs — candidates are gathered
470
+ // into their own array before any predicate is evaluated.
471
+ if (bindingIndex !== -1) {
472
+ bucket.splice(bindingIndex, 1);
473
+ }
474
+ }
259
475
  }
260
476
  }
261
- /**
262
- * The criterion a binding is indexed under, or `undefined` when its slot carries more than one.
263
- *
264
- * @remarks Carries predicate-bearing bindings too: every lane that reads this index already
265
- * re-checks what it finds, so an indexed hit was never unconditional.
266
- */
267
- function simpleTagOf(binding) {
268
- const { tags } = binding.slot;
269
- return tags.length === 1 ? tags[0] : undefined;
270
- }
271
- /** The first criterion a multi-criterion slot is bucketed under, or `undefined` for any other shape. */
272
- function multiTagFirstOf(binding) {
273
- const { tags } = binding.slot;
274
- return tags.length >= 2 ? tags[0] : undefined;
275
- }
276
- /** A binding nothing has to be matched against: the default slot, no predicate. */
477
+ /** A binding nothing has to be matched against: the default slot, no predicate, not a collection member. */
277
478
  function isDefaultSlotBinding(binding) {
278
- return binding.slot.tags.length === 0 && binding.predicate === undefined;
479
+ return binding.slot.tags.length === 0 && binding.predicate === undefined && !binding.isMany;
279
480
  }
280
- /** A predicate with no slot constraint: last-wins does not apply to it. */
481
+ /** A binding that occupies no slot — a collection member, or a predicate with no slot constraint — so last-wins does not apply to it. */
281
482
  function isPurePredicateBinding(binding) {
282
- return binding.predicate !== undefined && binding.slot.tags.length === 0;
483
+ return binding.isMany || (binding.predicate !== undefined && binding.slot.tags.length === 0);
484
+ }
485
+ // A bucket is keyed by its members' first criterion, so the pair is read from whichever came first.
486
+ function findPairIn(bucket, first, second) {
487
+ if (bucket === undefined) {
488
+ return undefined;
489
+ }
490
+ for (let index = 0; index < bucket.length; index += 1) {
491
+ const { tags } = bucket[index].slot;
492
+ if (tags.length === 2 && (tags[0] === first ? tags[1] === second : tags[0] === second && tags[1] === first)) {
493
+ return bucket[index];
494
+ }
495
+ }
496
+ return undefined;
283
497
  }