@mark1russell7/collections 0.7.2 → 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.7.2",
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
  }
@@ -231,6 +234,19 @@ export function boundedList<T>(
231
234
  };
232
235
  }
233
236
 
237
+ /**
238
+ * This function adds each element with `add`, one after the other, until `add` refuses one.
239
+ * It gives true when one element goes in. A throw of `add` stops it, and the earlier elements stay.
240
+ */
241
+ function addEach<T>(elements: Iterable<T>, add: (element: T) => boolean): boolean {
242
+ let added = false;
243
+ for (const element of Array.from(elements)) {
244
+ if (!add(element)) break;
245
+ added = true;
246
+ }
247
+ return added;
248
+ }
249
+
234
250
  /**
235
251
  * This function makes a bounded Queue middleware.
236
252
  */
@@ -241,6 +257,7 @@ export function boundedQueue<T>(
241
257
  const capacity = checkCapacity(options.capacity, "boundedQueue");
242
258
 
243
259
  return (next: Queue<T>): Queue<T> & BoundedCollection => {
260
+ const source = removalSource<ElementRemoval<T>>(next);
244
261
  const handleOverflow = (element: T): void => {
245
262
  if (onOverflow) {
246
263
  const context: OverflowContext<T> = {
@@ -257,7 +274,7 @@ export function boundedQueue<T>(
257
274
  throw new Error(`Queue is full (capacity: ${capacity})`);
258
275
  case "drop-oldest":
259
276
  if (!next.isEmpty) {
260
- next.poll();
277
+ source.emit({ value: next.poll() as T, end: "first", reason: "drop" });
261
278
  }
262
279
  break;
263
280
  case "drop-newest":
@@ -274,6 +291,7 @@ export function boundedQueue<T>(
274
291
 
275
292
  return new Proxy(next, {
276
293
  get(target, prop, receiver) {
294
+ if (prop === removals) return source.channel;
277
295
  if (prop === "capacity") return capacity;
278
296
  if (prop === "isFull") return target.size >= capacity;
279
297
  if (prop === "remainingCapacity") return Math.max(0, capacity - target.size);
@@ -294,6 +312,12 @@ export function boundedQueue<T>(
294
312
  }
295
313
  return (value as Function).call(target, element);
296
314
  };
315
+
316
+ // The base addAll goes past the bound, so each element goes through add of this proxy (C10)
317
+ case "addAll":
318
+ return function (elements: Iterable<T>): boolean {
319
+ return addEach(elements, (element) => (receiver as Queue<T>).add(element));
320
+ };
297
321
  }
298
322
  }
299
323
 
@@ -313,7 +337,9 @@ export function boundedDeque<T>(
313
337
  const capacity = checkCapacity(options.capacity, "boundedDeque");
314
338
 
315
339
  return (next: Deque<T>): Deque<T> & BoundedCollection => {
316
- const handleOverflow = (element: T): void => {
340
+ const source = removalSource<ElementRemoval<T>>(next);
341
+ // An insert at the front drops the last element, as unshift of boundedList does (C10)
342
+ const handleOverflow = (element: T, dropFrom: "first" | "last"): void => {
317
343
  if (onOverflow) {
318
344
  const context: OverflowContext<T> = {
319
345
  newElement: element,
@@ -329,7 +355,8 @@ export function boundedDeque<T>(
329
355
  throw new Error(`Deque is full (capacity: ${capacity})`);
330
356
  case "drop-oldest":
331
357
  if (!next.isEmpty) {
332
- next.removeFirst();
358
+ const value = dropFrom === "last" ? next.removeLast() : next.removeFirst();
359
+ source.emit({ value, end: dropFrom, reason: "drop" });
333
360
  }
334
361
  break;
335
362
  case "drop-newest":
@@ -346,6 +373,7 @@ export function boundedDeque<T>(
346
373
 
347
374
  return new Proxy(next, {
348
375
  get(target, prop, receiver) {
376
+ if (prop === removals) return source.channel;
349
377
  if (prop === "capacity") return capacity;
350
378
  if (prop === "isFull") return target.size >= capacity;
351
379
  if (prop === "remainingCapacity") return Math.max(0, capacity - target.size);
@@ -363,13 +391,19 @@ export function boundedDeque<T>(
363
391
  case "add":
364
392
  return function (element: T): any {
365
393
  if (target.size >= capacity && policy !== "grow") {
366
- handleOverflow(element);
394
+ handleOverflow(element, prop === "addFirst" || prop === "offerFirst" ? "last" : "first");
367
395
  if (policy === "reject" || policy === "drop-newest") {
368
396
  return prop.startsWith("offer") || prop === "add" ? false : undefined;
369
397
  }
370
398
  }
371
399
  return (value as Function).call(target, element);
372
400
  };
401
+
402
+ // The base addAll goes past the bound, so each element goes through add of this proxy (C10)
403
+ case "addAll":
404
+ return function (elements: Iterable<T>): boolean {
405
+ return addEach(elements, (element) => (receiver as Deque<T>).add(element));
406
+ };
373
407
  }
374
408
  }
375
409
 
@@ -392,6 +426,10 @@ export function boundedMap<K, V>(
392
426
 
393
427
  return (next: MapLike<K, V>): MapLike<K, V> & BoundedCollection => {
394
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
+ });
395
433
 
396
434
  const handleOverflow = (key: K, value: V): void => {
397
435
  if (onOverflow) {
@@ -409,9 +447,15 @@ export function boundedMap<K, V>(
409
447
  throw new Error(`Map is full (capacity: ${capacity})`);
410
448
  case "drop-oldest":
411
449
  // Remove oldest key
412
- 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) {
413
453
  const oldestKey = insertionOrder.shift()!;
454
+ if (!next.has(oldestKey)) continue;
455
+ const value = next.get(oldestKey);
414
456
  next.delete(oldestKey);
457
+ source.emit({ key: oldestKey, value, reason: "drop" });
458
+ break;
415
459
  }
416
460
  break;
417
461
  case "drop-newest":
@@ -428,6 +472,7 @@ export function boundedMap<K, V>(
428
472
 
429
473
  return new Proxy(next, {
430
474
  get(target, prop, receiver) {
475
+ if (prop === removals) return source.channel;
431
476
  if (prop === "capacity") return capacity;
432
477
  if (prop === "isFull") return target.size >= capacity;
433
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
  };
@@ -240,8 +236,10 @@ export function lruMap<K, V>(
240
236
  if (stale) {
241
237
  removeNode(stale);
242
238
  }
243
- // Keep the node list bounded: when it reaches capacity, drop stale nodes first
244
- if (nodeMap.size >= capacity) {
239
+ // Keep the node list bounded. At most `capacity` nodes are live, so at twice the capacity a scan
240
+ // removes at least `capacity` stale nodes: the cost of a set is amortized O(1). A scan at the
241
+ // capacity read each node at each set into a full map (C10). evictLRU skips the other stale nodes.
242
+ if (nodeMap.size >= 2 * capacity) {
245
243
  dropStaleNodes();
246
244
  }
247
245
  if (target.size >= capacity) {