@codefast/di 0.9.0 → 0.10.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +137 -0
  2. package/README.md +3 -3
  3. package/dist/ambient/active-container.d.ts +13 -0
  4. package/dist/ambient/active-container.js +24 -0
  5. package/dist/container/binding-builders.d.ts +31 -9
  6. package/dist/container/binding-builders.js +155 -87
  7. package/dist/container/container.d.ts +2 -2
  8. package/dist/container/container.js +33 -16
  9. package/dist/core/binding.d.ts +36 -43
  10. package/dist/core/binding.js +11 -37
  11. package/dist/core/registry.d.ts +47 -4
  12. package/dist/core/registry.js +361 -159
  13. package/dist/core/state-epoch.d.ts +16 -0
  14. package/dist/core/state-epoch.js +21 -0
  15. package/dist/core/types.d.ts +7 -4
  16. package/dist/errors/diagnostics.d.ts +2 -0
  17. package/dist/errors/errors.d.ts +29 -0
  18. package/dist/errors/errors.js +35 -0
  19. package/dist/index.d.ts +1 -1
  20. package/dist/index.js +1 -1
  21. package/dist/injection/descriptor.d.ts +6 -4
  22. package/dist/injection/descriptor.js +3 -1
  23. package/dist/injection/resolve-options.d.ts +6 -0
  24. package/dist/injection/resolve-options.js +16 -0
  25. package/dist/introspection/dependency-graph.js +10 -5
  26. package/dist/introspection/inspector.d.ts +3 -1
  27. package/dist/introspection/inspector.js +10 -24
  28. package/dist/lifecycle/lifecycle-manager.js +10 -8
  29. package/dist/lifecycle/scope-manager.js +1 -1
  30. package/dist/resolution/cache/activation-need.d.ts +2 -0
  31. package/dist/resolution/cache/activation-need.js +13 -6
  32. package/dist/resolution/cache/binding-lookup-cache.d.ts +34 -1
  33. package/dist/resolution/cache/binding-lookup-cache.js +96 -12
  34. package/dist/resolution/cache/class-introspector.d.ts +1 -1
  35. package/dist/resolution/cache/class-introspector.js +28 -14
  36. package/dist/resolution/context.d.ts +8 -8
  37. package/dist/resolution/plan/instantiation-plan.d.ts +12 -0
  38. package/dist/resolution/plan/instantiation-plan.js +156 -65
  39. package/dist/resolution/plan/plan-codegen.d.ts +100 -0
  40. package/dist/resolution/plan/plan-codegen.js +185 -0
  41. package/dist/resolution/resolver.d.ts +14 -4
  42. package/dist/resolution/resolver.js +375 -134
  43. package/dist/resolution/select/binding-select.js +12 -7
  44. package/package.json +9 -1
@@ -1,5 +1,16 @@
1
- import { bindingSlotEquals, bindingSlotToString } from "#/core/binding";
1
+ import { bindingSlotEquals, bindingSlotToString, writableMembership, writablePredicate } from "#/core/binding";
2
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,16 +37,34 @@ 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.
@@ -45,116 +73,150 @@ export class BindingRegistry {
45
73
  * 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];
95
119
  }
96
- return bindingsForToken;
120
+ const records = this.#records;
121
+ const record = records?.get(token);
122
+ if (records === undefined || record === undefined) {
123
+ return [];
124
+ }
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,228 @@ 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
+ const key = binding.token;
303
+ if (this.#lone.get(key) === binding) {
304
+ writableMembership(binding).isMany = true;
305
+ this.#lone.delete(key);
306
+ this.#createRecord(key, [binding]);
200
307
  return;
201
308
  }
202
- this.#simpleTagged ??= new Map();
203
- const byCriterion = getOrInsert(this.#simpleTagged, tokenKey, new Map());
204
- byCriterion.set(criterion, binding);
309
+ // A default occupant that becomes a member frees its slot, so drop the stale index entry.
310
+ const record = this.#records?.get(key);
311
+ if (record?.defaultOccupant === binding) {
312
+ record.defaultOccupant = undefined;
313
+ }
314
+ writableMembership(binding).isMany = true;
205
315
  }
206
- #deindexSimpleTaggedBinding(tokenKey, binding) {
207
- const criterion = simpleTagOf(binding);
208
- if (criterion === undefined) {
316
+ /**
317
+ * Adds a predicate to a live binding in place.
318
+ *
319
+ * @remarks Only ever narrows — `when()` composes with any existing predicate — so the argument is
320
+ * never absent. Nothing indexes on the predicate, so the binding object and its id stay; a lone
321
+ * binding moves to a record because the lone map holds default-slot bindings with no predicate.
322
+ */
323
+ setPredicate(binding, predicate) {
324
+ this.#bump();
325
+ writablePredicate(binding).predicate = predicate;
326
+ const key = binding.token;
327
+ if (this.#lone.get(key) === binding) {
328
+ this.#lone.delete(key);
329
+ this.#createRecord(key, [binding]);
209
330
  return;
210
331
  }
211
- const byCriterion = this.#simpleTagged?.get(tokenKey);
212
- if (byCriterion === undefined) {
213
- return;
332
+ const record = this.#records?.get(key);
333
+ // The binding is now predicate-only, so it no longer holds the default slot it may have held.
334
+ if (record?.defaultOccupant === binding) {
335
+ record.defaultOccupant = undefined;
214
336
  }
215
- if (byCriterion.get(criterion)?.id === binding.id) {
216
- byCriterion.delete(criterion);
217
- if (byCriterion.size === 0) {
218
- this.#simpleTagged.delete(tokenKey);
337
+ if (record !== undefined) {
338
+ this.#settle(key, record);
339
+ }
340
+ }
341
+ /** Summarize available slot strings for a token (for error messages). */
342
+ availableSlotStrings(token) {
343
+ return this.getAll(token).map((binding) => bindingSlotToString(binding.slot));
344
+ }
345
+ #createRecord(key, bindings) {
346
+ const record = createTokenRecord(bindings);
347
+ (this.#records ??= new Map()).set(key, record);
348
+ // A promoted lone binding carries its slot into the record, so index every founding binding.
349
+ for (const binding of bindings) {
350
+ this.#indexSlot(record, binding);
351
+ }
352
+ return record;
353
+ }
354
+ /** The binding occupying `slot` in this record, found through the slot indexes, or `undefined`. */
355
+ #slotOccupant(record, slot) {
356
+ const { tags } = slot;
357
+ if (tags.length === 0) {
358
+ return record.defaultOccupant;
359
+ }
360
+ if (tags.length === 1) {
361
+ return record.simple?.get(tags[0]);
362
+ }
363
+ // Two-plus criteria are rare and can be bucketed under either criterion, so the list decides.
364
+ return record.bindings.find((candidate) => !isPurePredicateBinding(candidate) && bindingSlotEquals(candidate.slot, slot));
365
+ }
366
+ #addToRecord(key, record, binding) {
367
+ // Last-wins applies only to a slot-based newcomer, and the slot indexes name its occupant
368
+ // directly — so a member joining a collection of members displaces nobody without a list walk.
369
+ let displacedBinding;
370
+ if (!isPurePredicateBinding(binding)) {
371
+ displacedBinding = this.#slotOccupant(record, binding.slot);
372
+ if (displacedBinding !== undefined) {
373
+ this.#byId?.delete(displacedBinding.identifier);
374
+ this.#deindexSlot(record, displacedBinding);
219
375
  }
220
376
  }
377
+ // A selection may be walking this list inside a `when()` predicate. An append past the length it
378
+ // read cannot shift it, so it lands in place; a displacement replaces the array instead.
379
+ if (displacedBinding === undefined) {
380
+ record.bindings.push(binding);
381
+ }
382
+ else {
383
+ record.bindings = [...record.bindings.filter((candidate) => candidate !== displacedBinding), binding];
384
+ }
385
+ this.#byId?.set(binding.identifier, binding);
386
+ this.#indexSlot(record, binding);
387
+ this.#settle(key, record);
388
+ return displacedBinding;
221
389
  }
222
- #indexMultiTaggedBinding(tokenKey, binding) {
223
- const firstCriterion = multiTagFirstOf(binding);
224
- if (firstCriterion === undefined) {
225
- return;
390
+ // A record that shrank to one default-slot binding goes back to the lone map; an empty one goes.
391
+ #settle(key, record) {
392
+ const { bindings } = record;
393
+ if (bindings.length === 0) {
394
+ this.#records.delete(key);
395
+ }
396
+ else if (bindings.length === 1 && isDefaultSlotBinding(bindings[0])) {
397
+ this.#records.delete(key);
398
+ this.#lone.set(key, bindings[0]);
399
+ }
400
+ }
401
+ #ensureById() {
402
+ if (this.#byId === undefined) {
403
+ const byId = new Map();
404
+ for (const binding of this.#lone.values()) {
405
+ byId.set(binding.identifier, binding);
406
+ }
407
+ if (this.#records !== undefined) {
408
+ for (const record of this.#records.values()) {
409
+ for (const binding of record.bindings) {
410
+ byId.set(binding.identifier, binding);
411
+ }
412
+ }
413
+ }
414
+ this.#byId = byId;
226
415
  }
227
- this.#multiTagged ??= new Map();
228
- const buckets = getOrInsert(this.#multiTagged, tokenKey, new Map());
229
- getOrInsert(buckets, firstCriterion, []).push(binding);
416
+ return this.#byId;
230
417
  }
231
- #deindexMultiTaggedBinding(tokenKey, binding) {
232
- const firstCriterion = multiTagFirstOf(binding);
233
- if (firstCriterion === undefined) {
418
+ // Indexes a binding by its slot so an add finds who it displaces without walking the list: the
419
+ // default slot in `defaultOccupant`, one criterion in the exact map, more in the first-criterion
420
+ // bucket. A collection member or a predicate-only binding occupies no slot and is left unindexed.
421
+ #indexSlot(record, binding) {
422
+ if (isPurePredicateBinding(binding)) {
234
423
  return;
235
424
  }
236
- const bucket = this.#multiTagged?.get(tokenKey)?.get(firstCriterion);
237
- if (bucket === undefined) {
425
+ const { tags } = binding.slot;
426
+ if (tags.length === 0) {
427
+ record.defaultOccupant = binding;
238
428
  return;
239
429
  }
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);
430
+ this.#taggedIndexBuilt = true;
431
+ if (tags.length === 1) {
432
+ (record.simple ??= new Map()).set(tags[0], binding);
433
+ }
434
+ else {
435
+ getOrInsert((record.multi ??= new Map()), tags[0], []).push(binding);
245
436
  }
246
437
  }
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);
438
+ #deindexSlot(record, binding) {
439
+ if (isPurePredicateBinding(binding)) {
252
440
  return;
253
441
  }
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;
442
+ const { tags } = binding.slot;
443
+ if (tags.length === 0) {
444
+ if (record.defaultOccupant?.identifier === binding.identifier) {
445
+ record.defaultOccupant = undefined;
446
+ }
447
+ return;
448
+ }
449
+ if (tags.length === 1) {
450
+ if (record.simple?.get(tags[0])?.identifier === binding.identifier) {
451
+ record.simple.delete(tags[0]);
452
+ }
453
+ }
454
+ else if (tags.length >= 2) {
455
+ const bucket = record.multi?.get(tags[0]);
456
+ const bindingIndex = bucket?.findIndex((candidate) => candidate.identifier === binding.identifier) ?? -1;
457
+ // Spliced in place: nothing walks a bucket while user code runs — candidates are gathered
458
+ // into their own array before any predicate is evaluated.
459
+ if (bindingIndex !== -1) {
460
+ bucket.splice(bindingIndex, 1);
461
+ }
462
+ }
259
463
  }
260
464
  }
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. */
465
+ /** A binding nothing has to be matched against: the default slot, no predicate, not a collection member. */
277
466
  function isDefaultSlotBinding(binding) {
278
- return binding.slot.tags.length === 0 && binding.predicate === undefined;
467
+ return binding.slot.tags.length === 0 && binding.predicate === undefined && !binding.isMany;
279
468
  }
280
- /** A predicate with no slot constraint: last-wins does not apply to it. */
469
+ /** 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
470
  function isPurePredicateBinding(binding) {
282
- return binding.predicate !== undefined && binding.slot.tags.length === 0;
471
+ return binding.isMany || (binding.predicate !== undefined && binding.slot.tags.length === 0);
472
+ }
473
+ // A bucket is keyed by its members' first criterion, so the pair is read from whichever came first.
474
+ function findPairIn(bucket, first, second) {
475
+ if (bucket === undefined) {
476
+ return undefined;
477
+ }
478
+ for (let index = 0; index < bucket.length; index += 1) {
479
+ const { tags } = bucket[index].slot;
480
+ if (tags.length === 2 && (tags[0] === first ? tags[1] === second : tags[0] === second && tags[1] === first)) {
481
+ return bucket[index];
482
+ }
483
+ }
484
+ return undefined;
283
485
  }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The process-wide counter every container mutation advances, so a chain walk can be skipped while nothing moved.
3
+ */
4
+ /**
5
+ * The current state epoch: unchanged between two reads exactly when no registry or lifecycle table
6
+ * in the process was mutated in between.
7
+ *
8
+ * @since 0.10.0
9
+ */
10
+ export declare function stateEpoch(): number;
11
+ /**
12
+ * Advances the epoch; every registry version bump and every activation-hook registration calls it.
13
+ *
14
+ * @since 0.10.0
15
+ */
16
+ export declare function advanceStateEpoch(): void;