@mark1russell7/collections 0.8.0 → 0.9.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package",
3
3
  "name": "@mark1russell7/collections",
4
- "version": "0.8.0",
4
+ "version": "0.9.0",
5
5
  "description": "Collections with no dependencies: lists, maps, queues, a summary tree, channels, lazy operations and dependency graphs.",
6
6
  "keywords": [
7
7
  "collections",
@@ -20,6 +20,7 @@ import type { Middleware } from "../core/middleware.js";
20
20
  import type { List } from "../interfaces/list.js";
21
21
  import type { Queue, Deque } from "../interfaces/queue.js";
22
22
  import type { MapLike } from "../interfaces/map.js";
23
+ import { removals, removalSource, type ElementRemoval, type MapRemoval } from "../core/removals.js";
23
24
  import type { Collection } from "../interfaces/collection.js";
24
25
  import type {
25
26
  OverflowPolicy,
@@ -71,6 +72,7 @@ export function boundedList<T>(
71
72
  const capacity = checkCapacity(options.capacity, "boundedList");
72
73
 
73
74
  return (next: List<T>): List<T> & BoundedCollection => {
75
+ const source = removalSource<ElementRemoval<T>>(next);
74
76
  // An unshift puts the element at the front, so drop-oldest drops the last element for it
75
77
  const handleOverflow = (element: T, dropFrom: "first" | "last" = "first"): void => {
76
78
  // Call overflow handler if provided
@@ -92,8 +94,8 @@ export function boundedList<T>(
92
94
  case "drop-oldest":
93
95
  // Remove one element: the first, or the last for an unshift
94
96
  if (!next.isEmpty) {
95
- if (dropFrom === "last") next.pop();
96
- else next.shift();
97
+ const value = dropFrom === "last" ? next.pop() : next.shift();
98
+ source.emit({ value, end: dropFrom, reason: "drop" });
97
99
  }
98
100
  break;
99
101
 
@@ -123,6 +125,7 @@ export function boundedList<T>(
123
125
  return new Proxy(next, {
124
126
  get(target, prop, receiver) {
125
127
  // Capacity property
128
+ if (prop === removals) return source.channel;
126
129
  if (prop === "capacity") {
127
130
  return capacity;
128
131
  }
@@ -254,6 +257,7 @@ export function boundedQueue<T>(
254
257
  const capacity = checkCapacity(options.capacity, "boundedQueue");
255
258
 
256
259
  return (next: Queue<T>): Queue<T> & BoundedCollection => {
260
+ const source = removalSource<ElementRemoval<T>>(next);
257
261
  const handleOverflow = (element: T): void => {
258
262
  if (onOverflow) {
259
263
  const context: OverflowContext<T> = {
@@ -270,7 +274,7 @@ export function boundedQueue<T>(
270
274
  throw new Error(`Queue is full (capacity: ${capacity})`);
271
275
  case "drop-oldest":
272
276
  if (!next.isEmpty) {
273
- next.poll();
277
+ source.emit({ value: next.poll() as T, end: "first", reason: "drop" });
274
278
  }
275
279
  break;
276
280
  case "drop-newest":
@@ -287,6 +291,7 @@ export function boundedQueue<T>(
287
291
 
288
292
  return new Proxy(next, {
289
293
  get(target, prop, receiver) {
294
+ if (prop === removals) return source.channel;
290
295
  if (prop === "capacity") return capacity;
291
296
  if (prop === "isFull") return target.size >= capacity;
292
297
  if (prop === "remainingCapacity") return Math.max(0, capacity - target.size);
@@ -332,6 +337,7 @@ export function boundedDeque<T>(
332
337
  const capacity = checkCapacity(options.capacity, "boundedDeque");
333
338
 
334
339
  return (next: Deque<T>): Deque<T> & BoundedCollection => {
340
+ const source = removalSource<ElementRemoval<T>>(next);
335
341
  // An insert at the front drops the last element, as unshift of boundedList does (C10)
336
342
  const handleOverflow = (element: T, dropFrom: "first" | "last"): void => {
337
343
  if (onOverflow) {
@@ -349,8 +355,8 @@ export function boundedDeque<T>(
349
355
  throw new Error(`Deque is full (capacity: ${capacity})`);
350
356
  case "drop-oldest":
351
357
  if (!next.isEmpty) {
352
- if (dropFrom === "last") next.removeLast();
353
- else next.removeFirst();
358
+ const value = dropFrom === "last" ? next.removeLast() : next.removeFirst();
359
+ source.emit({ value, end: dropFrom, reason: "drop" });
354
360
  }
355
361
  break;
356
362
  case "drop-newest":
@@ -367,6 +373,7 @@ export function boundedDeque<T>(
367
373
 
368
374
  return new Proxy(next, {
369
375
  get(target, prop, receiver) {
376
+ if (prop === removals) return source.channel;
370
377
  if (prop === "capacity") return capacity;
371
378
  if (prop === "isFull") return target.size >= capacity;
372
379
  if (prop === "remainingCapacity") return Math.max(0, capacity - target.size);
@@ -419,6 +426,10 @@ export function boundedMap<K, V>(
419
426
 
420
427
  return (next: MapLike<K, V>): MapLike<K, V> & BoundedCollection => {
421
428
  let insertionOrder: K[] = []; // Track insertion order for drop-oldest
429
+ // A removal below (for example an eviction of lruMap) takes its key out of the order
430
+ const source = removalSource<MapRemoval<K, V>>(next, ({ key }) => {
431
+ insertionOrder = insertionOrder.filter((k) => !next.keyEq(k, key));
432
+ });
422
433
 
423
434
  const handleOverflow = (key: K, value: V): void => {
424
435
  if (onOverflow) {
@@ -436,9 +447,15 @@ export function boundedMap<K, V>(
436
447
  throw new Error(`Map is full (capacity: ${capacity})`);
437
448
  case "drop-oldest":
438
449
  // Remove oldest key
439
- if (insertionOrder.length > 0) {
450
+ // The removal channel below keeps the order exact. A layer below with no channel can remove a key
451
+ // without this layer seeing it, so a drop skips each key that is gone.
452
+ while (insertionOrder.length > 0) {
440
453
  const oldestKey = insertionOrder.shift()!;
454
+ if (!next.has(oldestKey)) continue;
455
+ const value = next.get(oldestKey);
441
456
  next.delete(oldestKey);
457
+ source.emit({ key: oldestKey, value, reason: "drop" });
458
+ break;
442
459
  }
443
460
  break;
444
461
  case "drop-newest":
@@ -455,6 +472,7 @@ export function boundedMap<K, V>(
455
472
 
456
473
  return new Proxy(next, {
457
474
  get(target, prop, receiver) {
475
+ if (prop === removals) return source.channel;
458
476
  if (prop === "capacity") return capacity;
459
477
  if (prop === "isFull") return target.size >= capacity;
460
478
  if (prop === "remainingCapacity") return Math.max(0, capacity - target.size);
@@ -22,6 +22,31 @@ import type { Queue, Deque } from "../interfaces/queue.js";
22
22
  import type { MapLike } from "../interfaces/map.js";
23
23
  import type { Collection } from "../interfaces/collection.js";
24
24
  import { Emitter, type Unsubscribe } from "../core/events.js";
25
+ import { removalsOf, type ElementRemoval, type MapRemoval, type RemovalReason } from "../core/removals.js";
26
+
27
+ /** The number of removals that a layer heard from the layers below it, and the number from the first end. */
28
+ interface Heard {
29
+ all: number;
30
+ first: number;
31
+ }
32
+
33
+ /**
34
+ * This function gives each removal below `next` to `listener` and counts the removals.
35
+ * With the counts, a layer tells a refused add (the size did not grow) from an add that caused a drop.
36
+ */
37
+ function hear<R extends ElementRemoval<unknown> | MapRemoval<unknown, unknown>>(next: object, listener: (removal: R) => void): Heard {
38
+ const heard: Heard = { all: 0, first: 0 };
39
+ removalsOf<R>(next)?.on((removal) => {
40
+ heard.all++;
41
+ if ("end" in removal && removal.end === "first") heard.first++;
42
+ listener(removal);
43
+ });
44
+ return heard;
45
+ }
46
+
47
+ /** This function tells if a call added an element: the size grew, after the removals of the layers below. */
48
+ const added = (size: number, sizeBefore: number, heard: Heard, heardBefore: number): boolean =>
49
+ size - sizeBefore + heard.all - heardBefore > 0;
25
50
 
26
51
  // ============================================================================
27
52
  // Event type definitions
@@ -33,8 +58,8 @@ import { Emitter, type Unsubscribe } from "../core/events.js";
33
58
  export interface ListEvents<T> {
34
59
  /** Element was added */
35
60
  add: { value: T; index?: number };
36
- /** Element was removed */
37
- remove: { value: T; index?: number };
61
+ /** Element was removed. A removal by a layer below (a drop) has a `reason`. */
62
+ remove: { value: T; index?: number; reason?: RemovalReason };
38
63
  /** Element was replaced */
39
64
  set: { oldValue: T; newValue: T; index: number };
40
65
  /** Collection was cleared */
@@ -51,8 +76,8 @@ export interface ListEvents<T> {
51
76
  export interface QueueEvents<T> {
52
77
  /** Element was added to queue */
53
78
  enqueue: { value: T };
54
- /** Element was removed from queue */
55
- dequeue: { value: T };
79
+ /** Element was removed from queue. A removal by a layer below (a drop) has a `reason`. */
80
+ dequeue: { value: T; reason?: RemovalReason };
56
81
  /** Collection was cleared */
57
82
  clear: {};
58
83
  }
@@ -65,10 +90,10 @@ export interface DequeEvents<T> {
65
90
  addFirst: { value: T };
66
91
  /** Element was added to back */
67
92
  addLast: { value: T };
68
- /** Element was removed from front */
69
- removeFirst: { value: T };
70
- /** Element was removed from back */
71
- removeLast: { value: T };
93
+ /** Element was removed from front. A removal by a layer below (a drop) has a `reason`. */
94
+ removeFirst: { value: T; reason?: RemovalReason };
95
+ /** Element was removed from back. A removal by a layer below (a drop) has a `reason`. */
96
+ removeLast: { value: T; reason?: RemovalReason };
72
97
  /** Collection was cleared */
73
98
  clear: {};
74
99
  }
@@ -79,8 +104,8 @@ export interface DequeEvents<T> {
79
104
  export interface MapEvents<K, V> {
80
105
  /** Key-value pair was set */
81
106
  set: { key: K; value: V; oldValue?: V };
82
- /** Key was deleted */
83
- delete: { key: K; value: V };
107
+ /** Key was deleted. A removal by a layer below (a drop, an eviction or an expiry) has a `reason`. */
108
+ delete: { key: K; value: V; reason?: RemovalReason };
84
109
  /** Collection was cleared */
85
110
  clear: {};
86
111
  }
@@ -91,8 +116,8 @@ export interface MapEvents<K, V> {
91
116
  export interface CollectionEvents<T> {
92
117
  /** Element was added */
93
118
  add: { value: T };
94
- /** Element was removed */
95
- remove: { value: T };
119
+ /** Element was removed. A removal by a layer below has a `reason`. */
120
+ remove: { value: T; reason?: RemovalReason };
96
121
  /** Collection was cleared */
97
122
  clear: {};
98
123
  }
@@ -117,6 +142,10 @@ export function eventedList<T>(): Middleware<
117
142
  > {
118
143
  return (next: List<T>) => {
119
144
  const emitter = new Emitter<ListEvents<T>>();
145
+ // A removal from the last end left the index that is now the size
146
+ const heard = hear<ElementRemoval<T>>(next, ({ value, end, reason }) =>
147
+ emitter.emit("remove", end === undefined ? { value, reason } : { value, index: end === "first" ? 0 : next.size, reason }),
148
+ );
120
149
 
121
150
  return new Proxy(next, {
122
151
  get(target, prop, receiver) {
@@ -129,24 +158,34 @@ export function eventedList<T>(): Middleware<
129
158
 
130
159
  if (typeof value === "function") {
131
160
  switch (prop) {
161
+ // A layer below can refuse the element (reject, drop-newest): then no event
132
162
  case "add":
133
163
  case "push":
134
164
  return function (element: T): any {
165
+ const size = target.size;
166
+ const all = heard.all;
135
167
  const result = (value as Function).call(target, element);
136
- emitter.emit("add", { value: element });
168
+ if (added(target.size, size, heard, all)) emitter.emit("add", { value: element });
137
169
  return result;
138
170
  };
139
171
 
140
172
  case "unshift":
141
173
  return function (element: T): void {
174
+ const size = target.size;
175
+ const all = heard.all;
142
176
  (value as Function).call(target, element);
143
- emitter.emit("add", { value: element, index: 0 });
177
+ if (added(target.size, size, heard, all)) emitter.emit("add", { value: element, index: 0 });
144
178
  };
145
179
 
146
180
  case "insert":
147
181
  return function (index: number, element: T): void {
182
+ const size = target.size;
183
+ const all = heard.all;
184
+ const first = heard.first;
148
185
  (value as Function).call(target, index, element);
149
- emitter.emit("add", { value: element, index });
186
+ if (!added(target.size, size, heard, all)) return;
187
+ // A drop of the first element below moved each later index one place lower
188
+ emitter.emit("add", { value: element, index: heard.first > first ? Math.max(0, index - 1) : index });
150
189
  };
151
190
 
152
191
  case "set":
@@ -175,7 +214,8 @@ export function eventedList<T>(): Middleware<
175
214
  case "pop":
176
215
  return function (): T {
177
216
  const removed = (value as Function).call(target);
178
- emitter.emit("remove", { value: removed });
217
+ // The last element had the index that is now the size
218
+ emitter.emit("remove", { value: removed, index: target.size });
179
219
  return removed;
180
220
  };
181
221
 
@@ -226,6 +266,7 @@ export function eventedQueue<T>(): Middleware<
226
266
  > {
227
267
  return (next: Queue<T>) => {
228
268
  const emitter = new Emitter<QueueEvents<T>>();
269
+ hear<ElementRemoval<T>>(next, ({ value, reason }) => emitter.emit("dequeue", { value, reason }));
229
270
 
230
271
  return new Proxy(next, {
231
272
  get(target, prop, receiver) {
@@ -284,6 +325,9 @@ export function eventedDeque<T>(): Middleware<
284
325
  > {
285
326
  return (next: Deque<T>) => {
286
327
  const emitter = new Emitter<DequeEvents<T>>();
328
+ const heard = hear<ElementRemoval<T>>(next, ({ value, end, reason }) =>
329
+ emitter.emit(end === "last" ? "removeLast" : "removeFirst", { value, reason }),
330
+ );
287
331
 
288
332
  return new Proxy(next, {
289
333
  get(target, prop, receiver) {
@@ -295,22 +339,21 @@ export function eventedDeque<T>(): Middleware<
295
339
 
296
340
  if (typeof value === "function") {
297
341
  switch (prop) {
342
+ // push of a deque adds at the end (see Deque.push). A layer below can refuse the element.
298
343
  case "addFirst":
299
344
  case "offerFirst":
300
- case "push":
301
- return function (element: T): any {
302
- const result = (value as Function).call(target, element);
303
- emitter.emit("addFirst", { value: element });
304
- return result;
305
- };
306
-
307
345
  case "addLast":
308
346
  case "offerLast":
309
347
  case "offer":
310
348
  case "add":
349
+ case "push":
311
350
  return function (element: T): any {
351
+ const size = target.size;
352
+ const all = heard.all;
312
353
  const result = (value as Function).call(target, element);
313
- emitter.emit("addLast", { value: element });
354
+ if (added(target.size, size, heard, all)) {
355
+ emitter.emit(prop === "addFirst" || prop === "offerFirst" ? "addFirst" : "addLast", { value: element });
356
+ }
314
357
  return result;
315
358
  };
316
359
 
@@ -365,6 +408,7 @@ export function eventedMap<K, V>(): Middleware<
365
408
  > {
366
409
  return (next: MapLike<K, V>) => {
367
410
  const emitter = new Emitter<MapEvents<K, V>>();
411
+ const heard = hear<MapRemoval<K, V>>(next, ({ key, value, reason }) => emitter.emit("delete", { key, value, reason }));
368
412
 
369
413
  return new Proxy(next, {
370
414
  get(target, prop, receiver) {
@@ -378,8 +422,13 @@ export function eventedMap<K, V>(): Middleware<
378
422
  switch (prop) {
379
423
  case "set":
380
424
  return function (key: K, val: V): V | undefined {
425
+ const size = target.size;
426
+ const all = heard.all;
381
427
  const oldValue = (value as Function).call(target, key, val);
382
- emitter.emit("set", { key, value: val, oldValue });
428
+ // A new key that a layer below refused (reject, drop-newest) gives no event
429
+ if (oldValue !== undefined || added(target.size, size, heard, all)) {
430
+ emitter.emit("set", { key, value: val, oldValue });
431
+ }
383
432
  return oldValue;
384
433
  };
385
434
 
@@ -420,6 +469,7 @@ export function evented<T>(): Middleware<
420
469
  > {
421
470
  return (next: Collection<T>) => {
422
471
  const emitter = new Emitter<CollectionEvents<T>>();
472
+ hear<ElementRemoval<T>>(next, ({ value, reason }) => emitter.emit("remove", { value, reason }));
423
473
 
424
474
  return new Proxy(next, {
425
475
  get(target, prop, receiver) {
@@ -18,6 +18,7 @@ import { checkCapacity } from "../core/capacity.js";
18
18
  import type { Middleware } from "../core/middleware.js";
19
19
  import type { MapLike } from "../interfaces/map.js";
20
20
  import { compositeMapMethod } from "./composite.js";
21
+ import { removals, removalSource, type MapRemoval } from "../core/removals.js";
21
22
 
22
23
  /**
23
24
  * Options for LRU behavior.
@@ -133,9 +134,15 @@ export function lruMap<K, V>(
133
134
  nodeMap.delete(node.key);
134
135
  };
135
136
 
136
- // A layer below this one (for example ttlMap, in compose(lruMap, ttlMap)) can remove an
137
- // entry without this layer seeing it. Its node is then stale: the list must not keep it
138
- // (it holds the value, and it would grow without bound) and evicting it is not an eviction.
137
+ // A removal below (for example an expiry of ttlMap, in compose(lruMap, ttlMap)) removes the node of its key
138
+ const source = removalSource<MapRemoval<K, V>>(next, ({ key }) => {
139
+ const node = nodeMap.get(key);
140
+ if (node) removeNode(node);
141
+ });
142
+
143
+ // A layer below with no removal channel can remove an entry without this layer seeing it. Its node is
144
+ // then stale: the list must not keep it (it holds the value, and it would grow without bound) and evicting
145
+ // it is not an eviction. With the channel, no node is stale and these checks find nothing.
139
146
  const dropStaleNodes = (): void => {
140
147
  for (const node of [...nodeMap.values()]) {
141
148
  if (!next.has(node.key)) {
@@ -161,12 +168,16 @@ export function lruMap<K, V>(
161
168
  timestamp: candidate.lastAccessed,
162
169
  });
163
170
  }
171
+ source.emit({ key: candidate.key, value: candidate.value, reason: "evict" });
164
172
  return;
165
173
  }
166
174
  };
167
175
 
168
176
  return new Proxy(next, {
169
177
  get(target, prop, receiver) {
178
+ if (prop === removals) {
179
+ return source.channel;
180
+ }
170
181
  // Add isFull property
171
182
  if (prop === "isFull") {
172
183
  return target.size >= capacity;
@@ -185,17 +196,7 @@ export function lruMap<K, V>(
185
196
  switch (prop) {
186
197
  case "get":
187
198
  return function (key: K): V {
188
- let result: V;
189
- try {
190
- result = (value as Function).call(target, key);
191
- } catch (error) {
192
- // The entry is gone below (for example expired): forget its node
193
- const stale = nodeMap.get(key);
194
- if (stale) {
195
- removeNode(stale);
196
- }
197
- throw error;
198
- }
199
+ const result: V = (value as Function).call(target, key);
199
200
  // Update access time
200
201
  const node = nodeMap.get(key);
201
202
  if (node) {
@@ -208,14 +209,9 @@ export function lruMap<K, V>(
208
209
  return function (key: K): boolean {
209
210
  const result = (value as Function).call(target, key);
210
211
  const node = nodeMap.get(key);
211
- if (node) {
212
- if (result) {
213
- // Update access time
214
- moveToHead(node);
215
- } else {
216
- // The entry is gone below (for example expired): forget its node
217
- removeNode(node);
218
- }
212
+ if (node && result) {
213
+ // Update access time
214
+ moveToHead(node);
219
215
  }
220
216
  return result;
221
217
  };
@@ -16,6 +16,7 @@
16
16
  import type { Middleware } from "../core/middleware.js";
17
17
  import type { Entry, MapLike } from "../interfaces/map.js";
18
18
  import { compositeMapMethod } from "./composite.js";
19
+ import { removals, removalSource, type ElementRemoval, type MapRemoval } from "../core/removals.js";
19
20
  import type { Collection } from "../interfaces/collection.js";
20
21
 
21
22
  /**
@@ -81,6 +82,8 @@ export function ttlMap<K, V>(
81
82
  // The times of the live keys, in the order of expiry: a set deletes its key before it adds it again
82
83
  const metadata = new Map<K, TTLEntry<V>>();
83
84
  let intervalId: ReturnType<typeof setInterval> | null = null;
85
+ // A removal below (for example an eviction of lruMap) ends the time of its key, so it never expires
86
+ const source = removalSource<MapRemoval<K, V>>(next, ({ key }) => void metadata.delete(key));
84
87
 
85
88
  // This function removes the expired entries from the front of the order, and stops at the first live entry
86
89
  const sweep = (): void => {
@@ -90,6 +93,7 @@ export function ttlMap<K, V>(
90
93
  metadata.delete(key);
91
94
  next.delete(key);
92
95
  onExpire?.({ key, value: entry.value, createdAt: entry.createdAt, expiredAt: now });
96
+ source.emit({ key, value: entry.value, reason: "expire" });
93
97
  }
94
98
  };
95
99
 
@@ -107,6 +111,9 @@ export function ttlMap<K, V>(
107
111
  if (prop === "dispose") {
108
112
  return dispose;
109
113
  }
114
+ if (prop === removals) {
115
+ return source.channel;
116
+ }
110
117
 
111
118
  // Composite methods (setIfAbsent, compute, merge, putAll and others) go through this
112
119
  // proxy's own primitives, so they keep the expiry rules (BUGS-2026-07 L13)
@@ -205,6 +212,7 @@ export function ttlCollection<T>(
205
212
  ): Collection<T> & { readonly dispose: () => void } => {
206
213
  const metadata = new Map<T, { expiresAt: number; createdAt: number }>();
207
214
  let intervalId: ReturnType<typeof setInterval> | null = null;
215
+ const source = removalSource<ElementRemoval<T>>(next, ({ value }) => void metadata.delete(value));
208
216
 
209
217
  const checkExpired = (): void => {
210
218
  const now = Date.now();
@@ -229,6 +237,7 @@ export function ttlCollection<T>(
229
237
  expiredAt: now,
230
238
  });
231
239
  }
240
+ source.emit({ value: element, reason: "expire" });
232
241
  }
233
242
  }
234
243
  };
@@ -247,6 +256,9 @@ export function ttlCollection<T>(
247
256
  if (prop === "dispose") {
248
257
  return dispose;
249
258
  }
259
+ if (prop === removals) {
260
+ return source.channel;
261
+ }
250
262
 
251
263
  const value = Reflect.get(target, prop, receiver);
252
264
 
@@ -0,0 +1,85 @@
1
+ /**
2
+ * The removal channel of a stack of layers (C23).
3
+ *
4
+ * Some layers remove entries or elements by themselves. The bounded layers drop one when they are full.
5
+ * `lruMap` evicts one, and `ttlMap` removes an expired one. A layer
6
+ * above them does not call these removals, so it cannot see them. The removal channel tells it.
7
+ *
8
+ * - Each removing layer gives its channel under the key `removals`. Its channel gives its own removals and the
9
+ * removals of the channel below it.
10
+ * - A layer that does not remove forwards the channel below it: a proxy gives each unknown key of its target.
11
+ * - `eventedMap` and the other evented layers emit an event for each removal of the channel below them.
12
+ *
13
+ * @example
14
+ * const cache = compose(eventedMap<string, number>(), lruMap({ capacity: 2 }))(hashMap());
15
+ * cache.on("delete", ({ key, reason }) => console.log(key, reason)); // an eviction gives the reason "evict"
16
+ */
17
+
18
+ import type { Listener, Unsubscribe } from "./events.js";
19
+
20
+ /** The key of the removal channel on a layer. */
21
+ export const removals: unique symbol = Symbol.for("@mark1russell7/collections/removals");
22
+
23
+ /**
24
+ * The cause of a removal: a bounded layer drops, `lruMap` evicts and `ttlMap` removes an expired entry.
25
+ */
26
+ export type RemovalReason = "drop" | "evict" | "expire";
27
+
28
+ /** A removal from a map: the key and the value that the layer removed. */
29
+ export interface MapRemoval<K, V> {
30
+ readonly key: K;
31
+ readonly value: V;
32
+ readonly reason: RemovalReason;
33
+ }
34
+
35
+ /**
36
+ * A removal of an element: the element and the end that it left. A removal from a collection with no ends,
37
+ * for example by `ttlCollection`, has no `end`.
38
+ */
39
+ export interface ElementRemoval<T> {
40
+ readonly value: T;
41
+ readonly end?: "first" | "last";
42
+ readonly reason: RemovalReason;
43
+ }
44
+
45
+ /** A channel of removals. The listener gets each removal after the collection changed. */
46
+ export interface RemovalChannel<R> {
47
+ on(listener: Listener<R>): Unsubscribe;
48
+ }
49
+
50
+ /** The channel of a removing layer, and the function that sends its own removals. */
51
+ export interface RemovalSource<R> {
52
+ readonly channel: RemovalChannel<R>;
53
+ emit(removal: R): void;
54
+ }
55
+
56
+ /**
57
+ * This function gives the removal channel of a collection, or `undefined` when no layer of it removes.
58
+ */
59
+ export function removalsOf<R>(collection: object): RemovalChannel<R> | undefined {
60
+ const channel: unknown = (collection as { readonly [removals]?: unknown })[removals];
61
+ if (typeof channel !== "object" || channel === null) return undefined;
62
+ return typeof (channel as { on?: unknown }).on === "function" ? (channel as RemovalChannel<R>) : undefined;
63
+ }
64
+
65
+ /**
66
+ * This function makes the channel of a removing layer over `next`.
67
+ * The channel forwards each removal below. It calls `below` first, so the layer corrects its own state first.
68
+ */
69
+ export function removalSource<R>(next: object, below?: (removal: R) => void): RemovalSource<R> {
70
+ const listeners = new Set<Listener<R>>();
71
+ const emit = (removal: R): void => {
72
+ for (const listener of [...listeners]) listener(removal);
73
+ };
74
+ removalsOf<R>(next)?.on((removal) => {
75
+ below?.(removal);
76
+ emit(removal);
77
+ });
78
+ const channel: RemovalChannel<R> = {
79
+ on(listener) {
80
+ listeners.add(listener);
81
+ return () => void listeners.delete(listener);
82
+ },
83
+ };
84
+ return { channel, emit };
85
+ }
package/src/index.ts CHANGED
@@ -13,6 +13,7 @@ export * from "./core/traits.js";
13
13
  export * from "./core/policies.js";
14
14
  export * from "./core/middleware.js";
15
15
  export * from "./core/events.js";
16
+ export * from "./core/removals.js";
16
17
  // ============================================================================
17
18
  // Interfaces
18
19
  // ============================================================================