event-storage 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,517 @@
1
+ import crypto from 'crypto';
2
+ import {assert, assertEqual} from './util.js';
3
+ import {
4
+ indexOfSameLevel,
5
+ findJsonValueEnd,
6
+ parseJsonValue,
7
+ matchesAnyValuePattern,
8
+ isOpeningObject,
9
+ compareNumeric
10
+ } from './jsonUtil.js';
11
+
12
+ const compiledOperatorMatcherCache = new WeakMap();
13
+
14
+ /**
15
+ * @param {any} value Value to classify.
16
+ * @returns {boolean} True when `value` is a non-null object.
17
+ */
18
+ function isObject(value) {
19
+ return value !== null && typeof value === 'object';
20
+ }
21
+
22
+ /**
23
+ * @param {any} value Value to classify.
24
+ * @returns {boolean} True when `value` is a non-array object.
25
+ */
26
+ function isPlainObject(value) {
27
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
28
+ }
29
+
30
+ /**
31
+ * @param {object} obj Candidate matcher object.
32
+ * @returns {boolean} True when all keys are operator keys (`$...`).
33
+ */
34
+ function isOperatorObject(obj) {
35
+ const keys = Object.keys(obj);
36
+ return keys.length > 0 && keys.every(key => key.startsWith('$'));
37
+ }
38
+
39
+ /**
40
+ * Dispatch between array (OR), operator object, nested object, and scalar equality matching
41
+ * so callers don't need to know the shape of `matcherValue`.
42
+ *
43
+ * @param {any} documentValue Value from the document.
44
+ * @param {any} matcherValue Value from the matcher definition.
45
+ * @returns {boolean} True when both values match under matcher semantics.
46
+ */
47
+ function propertyMatchesValue(documentValue, matcherValue) {
48
+ if (isObject(matcherValue)) {
49
+ if (Array.isArray(matcherValue)) {
50
+ return matcherValue.includes(documentValue);
51
+ } else if (isOperatorObject(matcherValue)) {
52
+ const operatorChecks = getCompiledOperatorChecks(matcherValue);
53
+ return matchesCompiledOperators(documentValue, operatorChecks);
54
+ }
55
+ return matches(documentValue, matcherValue);
56
+ }
57
+ return typeof matcherValue === 'undefined' || documentValue === matcherValue;
58
+ }
59
+
60
+ /**
61
+ * Pre-compile an operator object into an array of comparison closures so the hot path avoids
62
+ * repeated `Object.entries` + switch dispatch per matched document.
63
+ *
64
+ * @param {object} operatorObj Object containing operator/value pairs.
65
+ * @returns {Array<function(any): boolean>} Compiled predicate checks in evaluation order.
66
+ */
67
+ function buildOperatorChecks(operatorObj) {
68
+ const checks = [];
69
+ for (const [operator, expectedValue] of Object.entries(operatorObj)) {
70
+ switch (operator) {
71
+ case '$gt':
72
+ checks.push(value => value > expectedValue);
73
+ break;
74
+ case '$gte':
75
+ checks.push(value => value >= expectedValue);
76
+ break;
77
+ case '$lt':
78
+ checks.push(value => value < expectedValue);
79
+ break;
80
+ case '$lte':
81
+ checks.push(value => value <= expectedValue);
82
+ break;
83
+ case '$eq':
84
+ checks.push(value => value === expectedValue);
85
+ break;
86
+ case '$ne':
87
+ checks.push(value => value !== expectedValue);
88
+ break;
89
+ default:
90
+ throw new TypeError(`Unknown operator: ${operator}`);
91
+ }
92
+ }
93
+ return checks;
94
+ }
95
+
96
+ /**
97
+ * Return cached compiled checks for `operatorObj`, compiling and caching on first access.
98
+ * Using WeakMap keeps operator objects GC-eligible and avoids mutating user-supplied objects.
99
+ *
100
+ * @param {object} operatorObj Object containing operator/value pairs.
101
+ * @returns {Array<function(any): boolean>} Cached or newly compiled operator checks.
102
+ */
103
+ function getCompiledOperatorChecks(operatorObj) {
104
+ const cachedChecks = compiledOperatorMatcherCache.get(operatorObj);
105
+ if (cachedChecks) {
106
+ return cachedChecks;
107
+ }
108
+ const checks = buildOperatorChecks(operatorObj);
109
+ compiledOperatorMatcherCache.set(operatorObj, checks);
110
+ return checks;
111
+ }
112
+
113
+ /**
114
+ * @param {any} documentValue Parsed scalar value from the document.
115
+ * @param {Array<function(any): boolean>} checks Compiled operator checks.
116
+ * @returns {boolean} True when all checks pass.
117
+ */
118
+ function matchesCompiledOperators(documentValue, checks) {
119
+ if (documentValue === undefined) {
120
+ return false;
121
+ }
122
+ for (const check of checks) {
123
+ if (!check(documentValue)) {
124
+ return false;
125
+ }
126
+ }
127
+ return true;
128
+ }
129
+
130
+ /**
131
+ * Build a buffer containing the file magic header and a JSON stringified metadata block, padded to be a multiple of 16 bytes long.
132
+ *
133
+ * @param {string} magic
134
+ * @param {object} metadata
135
+ * @returns {Buffer} A buffer containing the header data
136
+ */
137
+ function buildMetadataHeader(magic, metadata) {
138
+ assertEqual(magic.length, 8, 'The header magic bytes length is wrong.');
139
+ let metadataString = JSON.stringify(metadata);
140
+ let metadataSize = Buffer.byteLength(metadataString, 'utf8');
141
+ // 8 byte MAGIC, 4 byte metadata size, 1 byte line break
142
+ const pad = (16 - ((8 + 4 + metadataSize + 1) % 16)) % 16;
143
+ metadataString += ' '.repeat(pad) + "\n";
144
+ metadataSize += pad + 1;
145
+ const metadataBuffer = Buffer.allocUnsafe(8 + 4 + metadataSize);
146
+ metadataBuffer.write(magic, 0, 8, 'utf8');
147
+ metadataBuffer.writeUInt32BE(metadataSize, 8);
148
+ metadataBuffer.write(metadataString, 8 + 4, metadataSize, 'utf8');
149
+ return metadataBuffer;
150
+ }
151
+
152
+ /**
153
+ * @param {string} secret The secret to use for calculating further HMACs
154
+ * @returns {function(string)} A function that calculates the HMAC for a given string
155
+ */
156
+ const createHmac = secret => string => {
157
+ const hmac = crypto.createHmac('sha256', secret);
158
+ hmac.update(string);
159
+ return hmac.digest('hex');
160
+ };
161
+
162
+ /**
163
+ * @typedef {object|function(object):boolean} Matcher
164
+ */
165
+
166
+ /**
167
+ * @param {object} document The document to check against the matcher.
168
+ * @param {Matcher} matcher An object of properties and their values that need to match in the object or a function that checks if the document matches.
169
+ * @returns {boolean} True if the document matches the matcher or false otherwise.
170
+ */
171
+ function matches(document, matcher) {
172
+ if (typeof document === 'undefined') return false;
173
+ if (typeof matcher === 'undefined') return true;
174
+
175
+ if (typeof matcher === 'function') return matcher(document);
176
+
177
+ for (let prop of Object.getOwnPropertyNames(matcher)) {
178
+ if (!propertyMatchesValue(document[prop], matcher[prop])) {
179
+ return false;
180
+ }
181
+ }
182
+ return true;
183
+ }
184
+
185
+ /**
186
+ * @param {Matcher} matcher The matcher object or function that should be serialized.
187
+ * @param {function(string)} hmac A function that calculates a HMAC of the given string.
188
+ * @returns {{matcher: string|object, hmac?: string}}
189
+ */
190
+ function buildMetadataForMatcher(matcher, hmac) {
191
+ /* c8 ignore next 2 */
192
+ if (!matcher) {
193
+ return undefined;
194
+ }
195
+ if (typeof matcher === 'object') {
196
+ return {matcher};
197
+ }
198
+ const matcherString = matcher.toString();
199
+ return {matcher: matcherString, hmac: hmac(matcherString)};
200
+ }
201
+
202
+ /**
203
+ * @param {{matcher: string|object, hmac: string}} matcherMetadata The serialized matcher and its HMAC
204
+ * @param {function(string)} hmac A function that calculates a HMAC of the given string.
205
+ * @returns {Matcher} The matcher object or function.
206
+ */
207
+ function buildMatcherFromMetadata(matcherMetadata, hmac) {
208
+ let matcher;
209
+ if (typeof matcherMetadata.matcher === 'object') {
210
+ matcher = matcherMetadata.matcher;
211
+ } else {
212
+ /* c8 ignore next 1 */
213
+ assert(matcherMetadata.hmac === hmac(matcherMetadata.matcher), 'Invalid HMAC for matcher.');
214
+
215
+ matcher = eval('(' + matcherMetadata.matcher + ')').bind({}); // jshint ignore:line
216
+ }
217
+ return matcher;
218
+ }
219
+
220
+ /**
221
+ * Builds a factory function that, given a type string, returns an object matcher for
222
+ * documents whose payload contains that type at the given dot-notation path.
223
+ *
224
+ * @param {string} payloadPath Dot-notation path relative to the event payload (e.g. `'type'`, `'meta.kind'`).
225
+ * @returns {function(string): object} A function `(typeValue) => objectMatcher`.
226
+ */
227
+ function buildTypeMatcherFn(payloadPath) {
228
+ const parts = payloadPath.split('.');
229
+ return function (typeValue) {
230
+ let obj = typeValue;
231
+ for (let i = parts.length - 1; i >= 0; i--) {
232
+ obj = {[parts[i]]: obj};
233
+ }
234
+ return {payload: obj};
235
+ };
236
+ }
237
+
238
+ /**
239
+ * Compile an object matcher into a raw-buffer predicate so raw-mode reads can filter compact
240
+ * JSON without parsing every document first.
241
+ *
242
+ * @param {object} matcher Object matcher to compile.
243
+ * @param {{enableOperatorBufferMatcher?: boolean}} [options] Raw matcher build options.
244
+ * @returns {function(Buffer): boolean} Predicate over compact JSON buffers.
245
+ */
246
+ function buildRawBufferMatcher(matcher = {}, options = {}) {
247
+ assert(isPlainObject(matcher), 'Matcher must be an object.', TypeError);
248
+ const enableOperatorBufferMatcher = options.enableOperatorBufferMatcher !== false;
249
+
250
+ const root = buildMatcherTree(matcher, enableOperatorBufferMatcher);
251
+ /* c8 ignore next 3 */
252
+ if (root.children.length === 0) {
253
+ return () => true;
254
+ }
255
+
256
+ return function matchesRawBuffer(buffer) {
257
+ if (!isOpeningObject(buffer[0])) {
258
+ return false;
259
+ }
260
+ if (!preCheck(buffer, 1, root)) {
261
+ return false;
262
+ }
263
+ return matchesNode(buffer, 1, root);
264
+ };
265
+ }
266
+
267
+ /**
268
+ * Compile a matcher object into a tree whose children each carry one primary byte pattern plus
269
+ * optional follow-up checks for nested objects, operators, or multi-value scalars.
270
+ * Fast scalar-equality children are placed first so preCheck and matchesNode short-circuit early.
271
+ *
272
+ * @param {object} matcher Matcher object for this tree level.
273
+ * @returns {{children: Array<object>}} Compiled child descriptors for this level.
274
+ */
275
+ function buildMatcherTree(matcher) {
276
+ const fast = [];
277
+ const slow = [];
278
+
279
+ for (const [key, value] of Object.entries(matcher)) {
280
+ const child = buildMatcherTreeChild(key, value);
281
+ // A child with only one byte pattern and no follow-up matcher cannot be outperformed by any extra matcher logic.
282
+ (!child.matches ? fast : slow).push(child);
283
+ }
284
+
285
+ return {children: [...fast, ...slow]};
286
+ }
287
+
288
+ /**
289
+ * Normalize one matcher property into the cheapest raw-buffer strategy for that value shape.
290
+ * Children that compile to a plain byte-equality pattern leave `matches` as null.
291
+ *
292
+ * @param {string} key Property name at this matcher level.
293
+ * @param {any} value Matcher value for `key`.
294
+ * @returns {{pattern: Buffer, isKeyPattern: boolean, matches: ((function(Buffer, number): boolean)|null), node: ({children: Array<object>}|null), lastMatch: number}} Compiled descriptor consumed by preCheck/matchesNode.
295
+ */
296
+ function buildMatcherTreeChild(key, value) {
297
+ const keyPrefix = Buffer.from(`${JSON.stringify(key)}:`, 'utf8');
298
+ const child = {
299
+ pattern: null,
300
+ isKeyPattern: false,
301
+ matches: null,
302
+ node: null,
303
+ lastMatch: -1
304
+ };
305
+
306
+ if (isObject(value)) {
307
+ if (Array.isArray(value)) {
308
+ assert(!value.some(isObject), 'Array matcher values must be scalars.', TypeError);
309
+ if (value.length === 1) {
310
+ child.pattern = buildKeyValuePattern(keyPrefix, value[0]);
311
+ } else {
312
+ child.isKeyPattern = true;
313
+ child.pattern = keyPrefix;
314
+ const valuePatterns = value.map(item => Buffer.from(JSON.stringify(item), 'utf8'));
315
+ child.matches = (buffer, startOffset) => matchesAnyValuePattern(buffer, startOffset, valuePatterns);
316
+ }
317
+ } else if ('$eq' in value && Object.keys(value).length === 1) {
318
+ // A lone $eq is semantically identical to a scalar equality check — fold it into a
319
+ // value pattern at compile time so the buffer scan takes the same fast path as { key: value }.
320
+ child.pattern = buildKeyValuePattern(keyPrefix, value['$eq']);
321
+ } else if ('$ne' in value && Object.keys(value).length === 1) {
322
+ // A lone $ne is the logical negation of $eq: confirm the key exists at this level,
323
+ // then reject only when the value byte-matches the excluded pattern.
324
+ child.isKeyPattern = true;
325
+ child.pattern = keyPrefix;
326
+ const nePattern = [Buffer.from(JSON.stringify(value['$ne']), 'utf8')];
327
+ child.matches = (buffer, valueStart) => !matchesAnyValuePattern(buffer, valueStart, nePattern);
328
+ } else if (isOperatorObject(value)) {
329
+ child.isKeyPattern = true;
330
+ child.pattern = keyPrefix;
331
+ child.matches = buildOperatorBufferMatcher(value);
332
+ } else {
333
+ child.pattern = Buffer.concat([keyPrefix, Buffer.from('{', 'utf8')]);
334
+ child.node = buildMatcherTree(value);
335
+ child.matches = (buffer, startOffset) => matchesNode(buffer, startOffset, child.node);
336
+ }
337
+ } else {
338
+ child.pattern = buildKeyValuePattern(keyPrefix, value);
339
+ }
340
+ return child;
341
+ }
342
+
343
+ /**
344
+ * @param {Buffer} keyPrefix Serialized key prefix (`"key":`).
345
+ * @param {any} value Scalar value to append.
346
+ * @returns {Buffer} Full serialized `"key":value` pattern.
347
+ */
348
+ function buildKeyValuePattern(keyPrefix, value) {
349
+ return Buffer.concat([keyPrefix, Buffer.from(JSON.stringify(value), 'utf8')]);
350
+ }
351
+
352
+ /**
353
+ * Cheap pass: confirm each child's primary pattern exists somewhere and cache that position as a
354
+ * hint for the depth-aware pass that follows.
355
+ *
356
+ * @param {Buffer} buffer Compact JSON document buffer.
357
+ * @param {number} startOffset Start offset within `buffer`.
358
+ * @param {{children: Array<object>}} node Compiled matcher node for this level.
359
+ * @returns {boolean} True when every child pattern exists somewhere from `startOffset`.
360
+ */
361
+ function preCheck(buffer, startOffset, node) {
362
+ for (const child of node.children) {
363
+ child.lastMatch = buffer.indexOf(child.pattern, startOffset);
364
+ if (child.lastMatch === -1) {
365
+ return false;
366
+ }
367
+ if (child.node && !preCheck(buffer, child.lastMatch, child.node)) {
368
+ return false;
369
+ }
370
+ }
371
+ return true;
372
+ }
373
+
374
+ /**
375
+ * Confirm that each prechecked child really matches at the requested JSON level and then run the
376
+ * optional value-specific follow-up checks from the compiled tree.
377
+ *
378
+ * @param {Buffer} buffer Compact JSON document buffer.
379
+ * @param {number} startOffset Start offset within `buffer`.
380
+ * @param {{children: Array<object>}} node Compiled matcher node for this level.
381
+ * @returns {boolean} True when all children match at the requested JSON level.
382
+ */
383
+ function matchesNode(buffer, startOffset, node) {
384
+ for (const child of node.children) {
385
+ const matchPosition = indexOfSameLevel(buffer, child.pattern, startOffset, child.lastMatch, child.isKeyPattern);
386
+ if (matchPosition === -1) {
387
+ return false;
388
+ }
389
+
390
+ const valueStart = matchPosition + child.pattern.length;
391
+ if (child.matches && !child.matches(buffer, valueStart)) {
392
+ return false;
393
+ }
394
+ }
395
+
396
+ return true;
397
+ }
398
+
399
+ /**
400
+ * Parse the scalar at a matched key position only when operator objects require a real JavaScript
401
+ * comparison instead of a pure byte-pattern check.
402
+ *
403
+ * @param {Buffer} buffer Compact JSON document buffer.
404
+ * @param {number} startOffset Offset of the scalar value to parse.
405
+ * @param {Array<function(any): boolean>} operatorChecks Compiled operator checks.
406
+ * @returns {boolean} True when the parsed scalar satisfies all operators.
407
+ */
408
+ function matchesOperatorInBuffer(buffer, startOffset, operatorChecks) {
409
+ const valueStart = startOffset;
410
+ const valueEnd = findJsonValueEnd(buffer, valueStart);
411
+ /* c8 ignore next 2 */
412
+ if (valueEnd === -1 || valueEnd <= valueStart) {
413
+ return false;
414
+ }
415
+
416
+ const parsedValue = parseJsonValue(buffer, valueStart, valueEnd);
417
+
418
+ return matchesCompiledOperators(parsedValue, operatorChecks);
419
+ }
420
+
421
+ /**
422
+ * Map pre-computed ordering information to operator semantics.
423
+ * ordering: -1 (actual < expected), 0 (equal), 1 (actual > expected)
424
+ *
425
+ * @param {string} operator
426
+ * @param {-1|0|1} ordering
427
+ * @returns {boolean}
428
+ */
429
+ function matchesOrdering(operator, ordering) {
430
+ switch (operator) {
431
+ case '$gt':
432
+ return ordering === 1;
433
+ case '$gte':
434
+ return ordering >= 0;
435
+ case '$lt':
436
+ return ordering === -1;
437
+ case '$lte':
438
+ return ordering <= 0;
439
+ case '$eq':
440
+ return ordering === 0;
441
+ case '$ne':
442
+ return ordering !== 0;
443
+ /* c8 ignore next 1 */
444
+ default:
445
+ return false;
446
+ }
447
+ }
448
+
449
+ /**
450
+ * @param {Array<[string, any]>} entries
451
+ * @returns {Array<{operator: string, expectedNumeric: {isNegative: boolean, integerPart: string, fractionPart: string}}>|null}
452
+ */
453
+ function buildNumericOperatorComparisons(entries) {
454
+ const comparisons = [];
455
+ for (const [operator, expectedValue] of entries) {
456
+ if (typeof expectedValue !== 'number') {
457
+ return null;
458
+ }
459
+ const expectedStr = JSON.stringify(expectedValue);
460
+ const expectedIsNegative = expectedStr[0] === '-';
461
+ const intStart = expectedIsNegative ? 1 : 0;
462
+ const [expectedIntegerPart, expectedFractionPart = ''] = expectedStr.substring(intStart).split('.');
463
+ comparisons.push({
464
+ operator,
465
+ expectedNumeric: {
466
+ isNegative: expectedIsNegative,
467
+ integerPart: expectedIntegerPart,
468
+ fractionPart: expectedFractionPart
469
+ }
470
+ });
471
+ }
472
+ return comparisons;
473
+ }
474
+
475
+ /**
476
+ * Build a specialized buffer-based operator comparator by pre-compiling operator-specific
477
+ * byte shortcuts at matcher build time. This avoids runtime dispatch and enables aggressive
478
+ * short-circuit evaluation for sign mismatches and digit-count differences.
479
+ *
480
+ * Assumes no scientific notation in the JSON buffer.
481
+ *
482
+ * @param {object} operatorObj Operator object (e.g., { $gt: 100 }).
483
+ * @returns {function(Buffer, number): boolean} Predicate that reads the buffer at given offset.
484
+ */
485
+ function buildOperatorBufferMatcher(operatorObj) {
486
+ const entries = Object.entries(operatorObj);
487
+ const numericComparisons = buildNumericOperatorComparisons(entries);
488
+ if (numericComparisons) {
489
+ return (buffer, startOffset) => {
490
+ for (const comparison of numericComparisons) {
491
+ const ordering = compareNumeric(buffer, startOffset, comparison.expectedNumeric);
492
+ /* c8 ignore next 2 */
493
+ if (ordering === null) {
494
+ return false;
495
+ }
496
+ if (!matchesOrdering(comparison.operator, ordering)) {
497
+ return false;
498
+ }
499
+ }
500
+ return true;
501
+ };
502
+ }
503
+
504
+ // Non-numeric expected value: use generic operator checks.
505
+ const operatorChecks = getCompiledOperatorChecks(operatorObj);
506
+ return (buffer, startOffset) => matchesOperatorInBuffer(buffer, startOffset, operatorChecks);
507
+ }
508
+
509
+ export {
510
+ createHmac,
511
+ matches,
512
+ buildMetadataHeader,
513
+ buildMetadataForMatcher,
514
+ buildMatcherFromMetadata,
515
+ buildTypeMatcherFn,
516
+ buildRawBufferMatcher
517
+ };
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * Assert that actual and expected match or throw an Error with the given message appended by information about expected and actual value.
3
3
  *
4
- * @param {*} actual
5
- * @param {*} expected
6
- * @param {string} message
4
+ * @param {*} actual Actual value.
5
+ * @param {*} expected Expected value.
6
+ * @param {string} message Error message prefix.
7
+ * @returns {void}
7
8
  */
8
9
  function assertEqual(actual, expected, message) {
9
10
  if (actual !== expected) {
@@ -14,9 +15,10 @@ function assertEqual(actual, expected, message) {
14
15
  /**
15
16
  * Assert that the condition holds and if not, throw an error with the given message.
16
17
  *
17
- * @param {boolean} condition
18
- * @param {string} message
19
- * @param {typeof Error} ErrorType
18
+ * @param {boolean} condition Condition to verify.
19
+ * @param {string} message Error message when the condition fails.
20
+ * @param {typeof Error} ErrorType Error class to throw.
21
+ * @returns {void}
20
22
  */
21
23
  function assert(condition, message, ErrorType = Error) {
22
24
  if (!condition) {
@@ -27,9 +29,9 @@ function assert(condition, message, ErrorType = Error) {
27
29
  /**
28
30
  * Return the amount required to align value to the given alignment.
29
31
  * It calculates the difference of the alignment and the modulo of value by alignment.
30
- * @param {number} value
31
- * @param {number} alignment
32
- * @returns {number}
32
+ * @param {number} value Source value.
33
+ * @param {number} alignment Target alignment.
34
+ * @returns {number} Additional offset needed to reach the next aligned value.
33
35
  */
34
36
  function alignTo(value, alignment) {
35
37
  return (alignment - (value % alignment)) % alignment;
@@ -38,11 +40,11 @@ function alignTo(value, alignment) {
38
40
  /**
39
41
  * Method for hashing a string (e.g. a partition name) to a 32-bit unsigned integer.
40
42
  *
41
- * @param {string} str
42
- * @returns {number}
43
+ * @param {string} str Input string.
44
+ * @returns {number} 32-bit unsigned hash value.
43
45
  */
44
46
  function hash(str) {
45
- /* istanbul ignore if */
47
+ /* c8 ignore next 3 */
46
48
  if (str.length === 0) {
47
49
  return 0;
48
50
  }
@@ -113,26 +115,61 @@ function wrapAndCheck(index, length) {
113
115
  }
114
116
 
115
117
  /**
116
- * Perform a k-way merge over multiple streams, invoking a callback for each item in ascending key order.
117
- * Each stream object is mutated in place by the `advance` function.
118
+ * Iterate an array-like list in forward or reverse order.
118
119
  *
119
- * @param {object[]} streams Array of stream state objects; entries are removed when exhausted.
120
- * @param {function(object): number} getKey Returns the current sort key for a stream state.
121
- * @param {function(object): boolean} advance Advances the stream to its next item.
122
- * Returns true if the stream has more items within range, false if exhausted.
123
- * @param {function(object): void} visit Called for each stream state in merged order.
120
+ * @param {Iterable|ArrayLike<any>} entries Entries to iterate.
121
+ * @param {boolean} forwards Iteration direction.
122
+ * @returns {Generator<any>}
124
123
  */
125
- function kWayMerge(streams, getKey, advance, visit) {
126
- while (streams.length > 0) {
127
- let minIdx = 0;
128
- for (let i = 1; i < streams.length; i++) {
129
- if (getKey(streams[i]) < getKey(streams[minIdx])) {
130
- minIdx = i;
131
- }
124
+ function* iterate(entries, forwards) {
125
+ if (forwards) {
126
+ yield* entries;
127
+ return;
128
+ }
129
+
130
+ for (let i = entries.length - 1; i >= 0; i--) {
131
+ yield entries[i];
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Perform a k-way merge over multiple iterables in sort-key order.
137
+ *
138
+ * Each iterable is primed by calling `.next()` once at startup. On each merge step the iterable
139
+ * with the best current value is advanced and its value is yielded (after passing through `visit`).
140
+ * An iterable is dropped once its iterator reports `done`.
141
+ *
142
+ * @param {Iterable[]|Iterator[]} iterables Iterables or bare iterators to merge.
143
+ * @param {function(*): number} getSortKey Extracts the numeric sort key from an iterable's current value.
144
+ * @param {boolean} [ascending=true] When true, yields items in ascending key order (min-merge).
145
+ * When false, yields in descending key order (max-merge).
146
+ * @param {function(*): *} [visit] Optional extractor for the yielded value. Defaults to identity.
147
+ * @returns {Generator<*>} Merged sequence in key order.
148
+ */
149
+ function *kWayMerge(iterables, getSortKey, ascending = true, visit = v => v) {
150
+ const states = [];
151
+ for (const iterable of iterables) {
152
+ const iterator = typeof iterable[Symbol.iterator] === 'function' ? iterable[Symbol.iterator]() : iterable;
153
+ const { value, done } = iterator.next();
154
+ if (!done) {
155
+ states.push({ iterator, current: value });
156
+ }
157
+ }
158
+
159
+ while (states.length > 0) {
160
+ let bestIdx = 0;
161
+ for (let i = 1; i < states.length; i++) {
162
+ const better = ascending
163
+ ? getSortKey(states[i].current) < getSortKey(states[bestIdx].current)
164
+ : getSortKey(states[i].current) > getSortKey(states[bestIdx].current);
165
+ if (better) bestIdx = i;
132
166
  }
133
- visit(streams[minIdx]);
134
- if (!advance(streams[minIdx])) {
135
- streams.splice(minIdx, 1);
167
+ yield visit(states[bestIdx].current);
168
+ const { value, done } = states[bestIdx].iterator.next();
169
+ if (done) {
170
+ states.splice(bestIdx, 1);
171
+ } else {
172
+ states[bestIdx].current = value;
136
173
  }
137
174
  }
138
175
  }
@@ -141,9 +178,9 @@ function kWayMerge(streams, getKey, advance, visit) {
141
178
  * Read a scalar value at a dot-notation path from an object.
142
179
  * Returns `undefined` if any path segment is absent or an intermediate value is not an object.
143
180
  *
144
- * @param {object} obj
181
+ * @param {object} obj Source object.
145
182
  * @param {string} dotPath Dot-separated property path, e.g. `'payload.type'`.
146
- * @returns {*}
183
+ * @returns {*} Value at the path or `undefined`.
147
184
  */
148
185
  function getPropertyAtPath(obj, dotPath) {
149
186
  let current = obj;
@@ -160,6 +197,7 @@ export {
160
197
  assertEqual,
161
198
  hash,
162
199
  wrapAndCheck,
200
+ iterate,
163
201
  binarySearch,
164
202
  alignTo,
165
203
  kWayMerge,