event-storage 1.0.0 → 1.2.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.
@@ -1,5 +1,8 @@
1
1
  import stream from 'stream';
2
- import { assert } from './util.js';
2
+ import { assert } from './utils/util.js';
3
+ import { buildRawBufferMatcher, matches } from './utils/metadataUtil.js';
4
+
5
+ const NDJSON_NEWLINE = Buffer.from('\n');
3
6
 
4
7
  /**
5
8
  * Calculate the actual version number from a possibly relative (negative) version number.
@@ -33,22 +36,31 @@ class EventStream extends stream.Readable {
33
36
  * @param {EventStore} eventStore The event store to get the stream from.
34
37
  * @param {number} [minRevision] The minimum revision to include in the events (inclusive).
35
38
  * @param {number} [maxRevision] The maximum revision to include in the events (inclusive).
39
+ * @param {function|object|null} [predicate] Optional matcher:
40
+ * - object mode: function `(payload, metadata) => boolean` or object matcher against `{ stream, payload, metadata }`
41
+ * - raw mode: function `(buffer) => boolean` or object matcher against compact NDJSON bytes.
42
+ * @param {boolean} [raw=false] If true, emit NDJSON Buffers instead of event payload objects.
36
43
  */
37
- constructor(name, eventStore, minRevision = 1, maxRevision = -1) {
38
- super({ objectMode: true });
44
+ constructor(name, eventStore, minRevision = 1, maxRevision = -1, predicate = null, raw = false) {
45
+ if (typeof predicate === 'boolean' && raw === false) {
46
+ raw = predicate;
47
+ predicate = null;
48
+ }
49
+ super({ objectMode: !raw });
39
50
  assert(typeof name === 'string' && name !== '', 'Need to specify a stream name.');
40
51
  assert(typeof eventStore === 'object' && eventStore !== null, `Need to provide EventStore instance to create EventStream ${name}.`);
41
52
 
42
53
  this.name = name;
54
+ this.raw = raw;
55
+ this.predicate = predicate || null;
56
+ this.rawMatcher = null;
43
57
  if (eventStore.streams[name]) {
44
58
  this.streamIndex = eventStore.streams[name].index;
45
59
  this.minRevision = normalizeVersion(minRevision, this.streamIndex.length);
46
60
  this.maxRevision = normalizeVersion(maxRevision, this.streamIndex.length);
47
61
  this.version = minVersion(this.streamIndex.length, maxRevision);
48
62
  this._iterator = null;
49
- this.fetch = function() {
50
- return eventStore.storage.readRange(this.minRevision, this.maxRevision, this.streamIndex);
51
- }
63
+ this.fetch = () => eventStore.storage.readRange(this.minRevision, this.maxRevision, this.streamIndex, raw);
52
64
  } else {
53
65
  this.streamIndex = { length: 0 };
54
66
  this.version = -1;
@@ -230,10 +242,18 @@ class EventStream extends stream.Readable {
230
242
  *[Symbol.iterator]() {
231
243
  let next;
232
244
  while ((next = this.next()) !== false) {
233
- yield next.payload;
245
+ yield this.raw ? this.toRawBuffer(next) : next.payload;
234
246
  }
235
247
  }
236
248
 
249
+ /**
250
+ * @param {{ buffer: Buffer }} entry
251
+ * @returns {Buffer}
252
+ */
253
+ toRawBuffer(entry) {
254
+ return Buffer.concat([entry.buffer, NDJSON_NEWLINE]);
255
+ }
256
+
237
257
  /**
238
258
  * Reset this stream to the start so it can be iterated again.
239
259
  * @returns {EventStream}
@@ -244,6 +264,44 @@ class EventStream extends stream.Readable {
244
264
  return this;
245
265
  }
246
266
 
267
+ /**
268
+ * Apply a filter predicate to this stream. Only events for which `predicate(payload, metadata)`
269
+ * returns a truthy value will be yielded. The predicate is stored as a first-class property
270
+ * of the stream and applied in {@link EventStream#next}.
271
+ *
272
+ * @api
273
+ * @param {function(object, object): boolean} predicate A function receiving `(payload, metadata)`.
274
+ * Events for which the predicate returns falsy are skipped.
275
+ * @returns {EventStream} `this`
276
+ */
277
+ filter(predicate) {
278
+ this.predicate = predicate || null;
279
+ this.rawMatcher = null;
280
+ this._iterator = null;
281
+ this._events = null;
282
+ return this;
283
+ }
284
+
285
+ matchesPredicate(entry) {
286
+ if (!this.predicate) {
287
+ return true;
288
+ }
289
+ if (this.raw) {
290
+ if (typeof this.predicate === 'function') {
291
+ return this.predicate(entry.buffer);
292
+ }
293
+ if (!this.rawMatcher) {
294
+ this.rawMatcher = buildRawBufferMatcher(this.predicate);
295
+ }
296
+ return this.rawMatcher(entry.buffer);
297
+ }
298
+
299
+ if (typeof this.predicate === 'function') {
300
+ return this.predicate(entry.payload, entry.metadata);
301
+ }
302
+ return matches(entry, this.predicate);
303
+ }
304
+
247
305
  /**
248
306
  * @returns {object|boolean} The next event or false if no more events in the stream.
249
307
  */
@@ -251,13 +309,17 @@ class EventStream extends stream.Readable {
251
309
  if (!this._iterator) {
252
310
  this._iterator = this.fetch();
253
311
  }
254
- let next;
255
312
  try {
256
- next = this._iterator.next();
313
+ while (true) {
314
+ const result = this._iterator.next();
315
+ if (result.done) return false;
316
+ if (this.matchesPredicate(result.value)) {
317
+ return result.value;
318
+ }
319
+ }
257
320
  } catch(e) {
258
321
  return false;
259
322
  }
260
- return next.done ? false : next.value;
261
323
  }
262
324
 
263
325
  // noinspection JSUnusedGlobalSymbols
@@ -267,7 +329,7 @@ class EventStream extends stream.Readable {
267
329
  */
268
330
  _read() {
269
331
  const next = this.next();
270
- this.push(next ? next.payload : null);
332
+ this.push(next ? (this.raw ? this.toRawBuffer(next) : next.payload) : null);
271
333
  }
272
334
 
273
335
  }
@@ -2,7 +2,7 @@ import fs from 'fs';
2
2
  import path from 'path';
3
3
  import events from 'events';
4
4
  import Entry, { assertValidEntryClass } from '../IndexEntry.js';
5
- import { assert, wrapAndCheck, binarySearch } from '../util.js';
5
+ import { assert, wrapAndCheck, binarySearch } from '../utils/util.js';
6
6
 
7
7
  // node-event-store-index V01
8
8
  const HEADER_MAGIC = "nesidx01";
@@ -77,6 +77,7 @@ class ReadableIndex extends events.EventEmitter {
77
77
  if (options.metadata) {
78
78
  this.metadata = Object.assign({entryClass: options.EntryClass.name, entrySize: options.EntryClass.size}, options.metadata);
79
79
  }
80
+ this.headerSize = 0;
80
81
  }
81
82
 
82
83
  /**
@@ -208,12 +209,13 @@ class ReadableIndex extends events.EventEmitter {
208
209
  * @throws {Error} if the metadata size in the header is invalid.
209
210
  */
210
211
  readMetadata() {
212
+ if (this.headerSize > 0) return this.headerSize;
211
213
  const headerBuffer = Buffer.allocUnsafe(8 + 4);
212
214
  fs.readSync(this.fd, headerBuffer, 0, 8 + 4, 0);
213
215
  const headerMagic = headerBuffer.toString('utf8', 0, 8);
214
216
 
215
- assert(headerMagic.substr(0, 6) === HEADER_MAGIC.substr(0, 6), 'Invalid file header.');
216
- assert(headerMagic === HEADER_MAGIC, `Invalid file version. The index ${this.fileName} was created with a different library version (${headerMagic.substr(6)}).`);
217
+ assert(headerMagic.substring(0, 6) === HEADER_MAGIC.substring(0, 6), 'Invalid file header.');
218
+ assert(headerMagic === HEADER_MAGIC, `Invalid file version. The index ${this.fileName} was created with a different library version (${headerMagic.substring(6)}).`);
217
219
 
218
220
  const metadataSize = headerBuffer.readUInt32BE(8);
219
221
  assert(metadataSize >= 3, 'Invalid metadata size.');
@@ -353,7 +355,7 @@ class ReadableIndex extends events.EventEmitter {
353
355
  *
354
356
  * @api
355
357
  * @param {number} from The 1-based index position from where to get entries from (inclusive). If < 0 will start at that position from end.
356
- * @param {number} [until] The 1-based index position until where to get entries to (inclusive). If < 0 will get until that position from the end. Defaults to this.length.
358
+ * @param {number} [until=-1] The 1-based index position until where to get entries to (inclusive). If < 0 will get until that position from the end. Defaults to this.length.
357
359
  * @returns {Array<Entry>|boolean} An array of entries for the given range or false on error.
358
360
  */
359
361
  range(from, until = -1) {
@@ -387,7 +389,7 @@ class ReadableIndex extends events.EventEmitter {
387
389
  *
388
390
  * @api
389
391
  * @param {number} number The sequence number to search for.
390
- * @param {boolean} [min] If set to true, will return the first entry that has a sequence number greater than or equal to `number`.
392
+ * @param {boolean} [min=false] If set to true, will return the first entry that has a sequence number greater than or equal to `number`.
391
393
  * @returns {number} The last index entry position that is lower than or equal to the `number`. Returns 0 if no index matches.
392
394
  */
393
395
  find(number, min = false) {
@@ -1,6 +1,8 @@
1
1
  import fs from 'fs';
2
2
  import ReadableIndex, { Entry, CorruptedIndexError, HEADER_MAGIC } from './ReadableIndex.js';
3
- import { assertEqual, buildMetadataHeader, ensureDirectory } from '../util.js';
3
+ import { assert, assertEqual } from '../utils/util.js';
4
+ import { buildMetadataHeader } from '../utils/metadataUtil.js';
5
+ import { ensureDirectory } from '../utils/fsUtil.js';
4
6
 
5
7
  /**
6
8
  * An index is a simple append-only file that stores an ordered list of entry elements pointing to the actual file position
@@ -189,9 +191,7 @@ class WritableIndex extends ReadableIndex {
189
191
  assertEqual(entry.constructor.size, this.EntryClass.size, `Invalid entry size.`);
190
192
 
191
193
  const dataLen = this.data.length;
192
- if (dataLen > 0 && this.data[dataLen - 1].number >= entry.number) {
193
- throw new Error('Consistency error. Tried to add an index that should come before existing last entry.');
194
- }
194
+ assert(dataLen === 0 || this.data.at(-1).number < entry.number, 'Consistency error. Tried to add an index that should come before existing last entry.');
195
195
 
196
196
  if (this.readUntil === dataLen - 1) {
197
197
  this.readUntil++;
@@ -0,0 +1,205 @@
1
+ import { getPropertyAtPath } from './utils/util.js';
2
+ import { matches } from './utils/metadataUtil.js';
3
+
4
+ /**
5
+ * @typedef {object|function(object):boolean} Matcher
6
+ */
7
+
8
+ /**
9
+ * Classifies secondary-index matchers into a fast lookup table keyed on a
10
+ * configurable ordered list of "discriminant" property paths. This enables O(1)
11
+ * candidate resolution on write instead of evaluating every registered matcher.
12
+ *
13
+ * Object matchers that contain at least one of the discriminant property paths
14
+ * (in priority order) are stored in a nested Map keyed first by property path and
15
+ * then by the scalar value found at that path in the matcher. Function matchers
16
+ * and object matchers whose discriminant properties all resolve to undefined/object
17
+ * are kept in separate fallback sets and are always evaluated in full.
18
+ *
19
+ * When the discriminant property list is empty, `forEachMatch()` falls back to a
20
+ * full O(N) scan over all registered indexes.
21
+ */
22
+ class IndexMatcher {
23
+
24
+ /**
25
+ * @param {string[]} [properties] Ordered list of document property paths (dot-notation)
26
+ * used as discriminant keys. The first path that resolves to a non-null scalar inside
27
+ * a given object matcher is used as the key; remaining paths are ignored for that matcher.
28
+ * Pass an empty array (the default) to disable the fast path entirely.
29
+ */
30
+ constructor(properties = []) {
31
+ this.properties = properties;
32
+ /** Map<indexName, Matcher> — stores every registered matcher for full match verification. */
33
+ this.matchers = new Map();
34
+ /**
35
+ * Nested lookup table: Map<propPath, Map<discriminantValue, Set<indexName>>>.
36
+ * Populated only for object matchers that contain at least one discriminant property.
37
+ */
38
+ this.table = new Map();
39
+ /** Set of index names whose matchers are functions (always evaluated in full). */
40
+ this.functionMatchers = new Set();
41
+ /**
42
+ * Set of index names whose object matchers contain none of the configured
43
+ * discriminant properties (evaluated in full against every incoming document).
44
+ */
45
+ this.unclassifiedMatchers = new Set();
46
+ }
47
+
48
+ /**
49
+ * Register an index name and its matcher in the lookup structures.
50
+ *
51
+ * @param {string} indexName
52
+ * @param {Matcher} matcher
53
+ */
54
+ add(indexName, matcher) {
55
+ this.matchers.set(indexName, matcher);
56
+ if (typeof matcher === 'function') {
57
+ this.functionMatchers.add(indexName);
58
+ return;
59
+ }
60
+ if (!matcher || typeof matcher !== 'object') {
61
+ this.unclassifiedMatchers.add(indexName);
62
+ return;
63
+ }
64
+
65
+ const discriminant = this.findDiscriminant(matcher);
66
+ if (!discriminant) {
67
+ this.unclassifiedMatchers.add(indexName);
68
+ return;
69
+ }
70
+
71
+ let propMap = this.table.get(discriminant.propPath);
72
+ if (!propMap) {
73
+ propMap = new Map();
74
+ this.table.set(discriminant.propPath, propMap);
75
+ }
76
+ for (const v of discriminant.values) {
77
+ let indexSet = propMap.get(v);
78
+ if (!indexSet) {
79
+ indexSet = new Set();
80
+ propMap.set(v, indexSet);
81
+ }
82
+ indexSet.add(indexName);
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Remove an index name from the lookup structures.
88
+ * The matcher is retrieved from the internal registry, so only the index name
89
+ * is required.
90
+ *
91
+ * @param {string} indexName
92
+ */
93
+ remove(indexName) {
94
+ if (!this.matchers.has(indexName)) {
95
+ return;
96
+ }
97
+ const matcher = this.matchers.get(indexName);
98
+ this.matchers.delete(indexName);
99
+ if (typeof matcher === 'function') {
100
+ this.functionMatchers.delete(indexName);
101
+ return;
102
+ }
103
+ if (!matcher || typeof matcher !== 'object') {
104
+ this.unclassifiedMatchers.delete(indexName);
105
+ return;
106
+ }
107
+
108
+ const discriminant = this.findDiscriminant(matcher);
109
+ if (!discriminant) {
110
+ this.unclassifiedMatchers.delete(indexName);
111
+ return;
112
+ }
113
+
114
+ for (const v of discriminant.values) {
115
+ this.table.get(discriminant.propPath)?.get(v)?.delete(indexName);
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Iterate over every registered index whose matcher matches `document`, calling
121
+ * `iterationHandler` with the index name for each match.
122
+ *
123
+ * When `this.properties` is non-empty, an O(1) discriminant lookup narrows the
124
+ * candidate set before the full `matches()` check is applied. Each candidate's
125
+ * matcher is still verified with `matches()` to handle multi-property matchers
126
+ * where only the first property was used as the discriminant.
127
+ *
128
+ * When `this.properties` is empty the method falls back to a full O(N) scan.
129
+ *
130
+ * @param {object} document
131
+ * @param {function(string): void} iterationHandler Called with the index name for each match.
132
+ */
133
+ forEachMatch(document, iterationHandler) {
134
+ if (this.properties.length === 0) {
135
+ // Fast path disabled: full O(N) scan.
136
+ for (const [indexName, matcher] of this.matchers) {
137
+ if (matches(document, matcher)) {
138
+ iterationHandler(indexName);
139
+ }
140
+ }
141
+ return;
142
+ }
143
+
144
+ for (const propPath of this.properties) {
145
+ const docValue = getPropertyAtPath(document, propPath);
146
+ if (docValue === undefined || docValue === null || typeof docValue === 'object') {
147
+ continue;
148
+ }
149
+
150
+ const indexSet = this.table.get(propPath)?.get(String(docValue));
151
+ if (!indexSet) {
152
+ continue;
153
+ }
154
+
155
+ for (const indexName of indexSet) {
156
+ if (matches(document, this.matchers.get(indexName))) {
157
+ iterationHandler(indexName);
158
+ }
159
+ }
160
+ }
161
+
162
+ for (const indexName of this.unclassifiedMatchers) {
163
+ if (matches(document, this.matchers.get(indexName))) {
164
+ iterationHandler(indexName);
165
+ }
166
+ }
167
+
168
+ for (const indexName of this.functionMatchers) {
169
+ if (matches(document, this.matchers.get(indexName))) {
170
+ iterationHandler(indexName);
171
+ }
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Find the first usable discriminant for an object matcher.
177
+ * Returns `{ propPath, values }` for the first entry in `this.properties` that
178
+ * resolves to a non-null, non-object scalar (or a non-empty array of scalars) inside
179
+ * `matcher`, or `null` if none.
180
+ *
181
+ * For a scalar discriminant `values` contains exactly one element.
182
+ * For an array-valued discriminant `values` contains all elements of the array.
183
+ *
184
+ * @param {object} matcher
185
+ * @returns {{ propPath: string, values: string[] }|null}
186
+ */
187
+ findDiscriminant(matcher) {
188
+ for (const propPath of this.properties) {
189
+ const value = getPropertyAtPath(matcher, propPath);
190
+ if (value !== undefined && value !== null) {
191
+ if (typeof value !== 'object') {
192
+ return { propPath, values: [String(value)] };
193
+ }
194
+ if (Array.isArray(value) && value.length > 0 &&
195
+ value.every(v => v !== null && v !== undefined && typeof v !== 'object')) {
196
+ return { propPath, values: value.map(String) };
197
+ }
198
+ }
199
+ }
200
+ return null;
201
+ }
202
+
203
+ }
204
+
205
+ export default IndexMatcher;
@@ -1,5 +1,8 @@
1
1
  import EventStream from './EventStream.js';
2
- import { wrapAndCheck } from './util.js';
2
+ import { assert, kWayMerge } from './utils/util.js';
3
+
4
+ /** Reusable sentinel used for missing or empty per-stream iterators. */
5
+ const emptyIterator = Object.freeze({ next() { return { done: true }; } });
3
6
 
4
7
  /**
5
8
  * Calculate the actual version number from a possibly relative (negative) version number.
@@ -24,79 +27,74 @@ class JoinEventStream extends EventStream {
24
27
  * @param {EventStore} eventStore The event store to get the stream from.
25
28
  * @param {number} [minRevision] The 1-based minimum revision to include in the events (inclusive).
26
29
  * @param {number} [maxRevision] The 1-based maximum revision to include in the events (inclusive).
30
+ * @param {function|object|null} [predicate] Optional matcher (see {@link EventStream}).
31
+ * @param {boolean} [raw=false] If true, emit NDJSON Buffers.
27
32
  */
28
- constructor(name, streams, eventStore, minRevision = 1, maxRevision = -1) {
29
- super(name, eventStore, minRevision, maxRevision);
30
- if (!(streams instanceof Array) || streams.length === 0) {
31
- throw new Error(`Invalid list of streams supplied to JoinStream ${name}.`);
32
- }
33
+ constructor(name, streams, eventStore, minRevision = 1, maxRevision = -1, predicate = null, raw = false) {
34
+ super(name, eventStore, minRevision, maxRevision, predicate, raw);
35
+ assert(streams instanceof Array && streams.length > 0, `Invalid list of streams supplied to JoinStream ${name}.`);
33
36
 
34
37
  this.streamIndex = eventStore.storage.index;
35
38
  // Translate revisions to index numbers (1-based) and wrap around negatives
36
39
  this.minRevision = normalizeVersion(minRevision, eventStore.length);
37
40
  this.maxRevision = normalizeVersion(maxRevision, eventStore.length);
38
41
  this.fetch = function() {
39
- this._next = new Array(streams.length).fill(undefined);
40
42
  return streams.map(streamName => {
41
- if (!eventStore.streams[streamName]) {
42
- return { next() { return { done: true }; } };
43
+ const streamIndex = eventStore.streams[streamName]?.index;
44
+ if (!streamIndex || streamIndex.length === 0) {
45
+ return emptyIterator;
43
46
  }
44
- const streamIndex = eventStore.streams[streamName].index;
45
47
  const from = streamIndex.find(this.minRevision, this.minRevision <= this.maxRevision);
46
48
  const until = streamIndex.find(this.maxRevision, this.minRevision > this.maxRevision);
47
- return eventStore.storage.readRange(from, until, streamIndex);
49
+ if (from === 0 || until === 0) {
50
+ // find() returns 0 when the requested revision is outside the stream's range
51
+ // (e.g. minRevision > all entries, or maxRevision < all entries).
52
+ return emptyIterator;
53
+ }
54
+ // Raw mode: get { buffer, time64, sequenceNumber } for binary-header ordering.
55
+ // Object mode: storage deserializes for us and we order by metadata.commitId.
56
+ return eventStore.storage.readRange(from, until, streamIndex, this.raw);
48
57
  });
49
58
  }
50
59
  this._iterator = null;
51
60
  }
52
61
 
53
62
  /**
54
- * Returns the value of the iterator at position `index`
55
- * @param {number} index The iterator position for which to return the next value
56
- * @returns {*}
57
- */
58
- getValue(index) {
59
- const next = this._iterator[index].next();
60
- return next.done ? false : next.value;
61
- }
62
-
63
- /**
64
- * @private
65
- * @param {number} first
66
- * @param {number} second
67
- * @returns {boolean} If the first item follows after the second in the given read order determined by this.minRevision and this.maxRevision.
63
+ * @returns {Generator<object>}
68
64
  */
69
- follows(first, second) {
70
- return (this.minRevision > this.maxRevision ? first < second : first > second);
65
+ createMergedIterator() {
66
+ const ascending = this.minRevision <= this.maxRevision;
67
+ const raw = this.raw;
68
+ return kWayMerge(
69
+ this.fetch(),
70
+ entry => raw ? entry.sequenceNumber : entry.metadata.commitId,
71
+ ascending
72
+ );
71
73
  }
72
74
 
73
75
  /**
74
- * @private
75
- * @returns {object|boolean} The next event or false if no more events in the stream.
76
+ * Returns the next event in merge order.
77
+ *
78
+ * In raw mode: returns `{ buffer, time64, sequenceNumber }` from the binary header — no JSON
79
+ * deserialization. In object mode: returns a deserialized `{ stream, payload, metadata }` document
80
+ * produced by the storage layer.
81
+ * @returns {object|false} The next event, or `false` when the stream is exhausted.
76
82
  */
77
83
  next() {
78
84
  if (!this._iterator) {
79
- this._iterator = this.fetch();
85
+ this._iterator = this.createMergedIterator();
80
86
  }
81
- let nextIndex = -1;
82
- this._next.forEach((value, index) => {
83
- if (typeof value === 'undefined') {
84
- value = this._next[index] = this.getValue(index);
85
- }
86
- if (value === false) {
87
- return;
87
+ while (true) {
88
+ const step = this._iterator.next();
89
+ if (step.done) {
90
+ return false;
88
91
  }
89
- if (nextIndex === -1 || this.follows(this._next[nextIndex].metadata.commitId, value.metadata.commitId)) {
90
- nextIndex = index;
91
- }
92
- });
92
+ const next = step.value;
93
93
 
94
- if (nextIndex === -1) {
95
- return false;
94
+ if (this.matchesPredicate(next)) {
95
+ return next;
96
+ }
96
97
  }
97
- const next = this._next[nextIndex];
98
- this._next[nextIndex] = undefined;
99
- return next;
100
98
  }
101
99
 
102
100
  }