@incoqnito.io/bajadab 1.2.2 → 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.
@@ -1,913 +1,1059 @@
1
- import logger from "winston";
2
-
3
- import { uuidv7, } from "uuidv7";
4
- import { runMultiReentrantProtected, runReentrantProtected } from "@incoqnito.io/ts-iq-core";
5
-
6
- import type { IODatabase } from "./fileio.js";
7
-
8
- import {
9
- EBArrayDeletionStrategy,
10
- EBAutoFlushStrategy,
11
- EBAutoIDStrategy,
12
- EBDirtyUnloadStrategy,
13
- EBDuplicateIDStrategy,
14
- EBHandlerMode,
15
- EBMemStorageStrategy,
16
- EBModificationType,
17
- IBCollection,
18
- IBCollectionConfig,
19
- IBDatabase,
20
- IBDatabaseConfig,
21
- IBFunctionRegistry,
22
- IBSnapshotOptions,
23
- IBTransaction,
24
- IBTransactionCollection,
25
- IHandlerRegistration,
26
- TAddHandler,
27
- TDeleteHandler,
28
- TGetIDFunction,
29
- TIndexKeyFunction,
30
- TSetIDFunction,
31
- TUpdateHandler,
32
- } from "./base.js";
33
- import { canonicalName } from "./utils.js";
34
-
35
- /** Default {@link TGetIDFunction}: reads `v.id`, `undefined` if absent. */
36
- export const DEFAULT_GET_ID: TGetIDFunction = (v: any) => ("id" in v) ? v.id : undefined;
37
- /** Default {@link TSetIDFunction}: assigns to `v.id`. */
38
- export const DEFAULT_SET_ID: TSetIDFunction = async (v: any, id: string) => { v.id = id; };
39
-
40
- const enum MutexKeys {
41
- // because of the spaces this will never be a collection name and as such is safe to use as a mutex key
42
- DATABASE = " database ",
43
- }
44
-
45
- /** {@link IBCollectionConfig} used when a collection is created without an explicit one. */
46
- export const DEFAULT_COLLECTION_CONFIG: IBCollectionConfig = {
47
- arrayDeletionStrategy: EBArrayDeletionStrategy.IN_PLACE,
48
- autoIDStrategy: EBAutoIDStrategy.AUTO_ID_UUID,
49
- duplicateIDStrategy: EBDuplicateIDStrategy.OVERWRITE,
50
- memStorageStrategy: EBMemStorageStrategy.MAP,
51
- dirtyUnloadStrategy: EBDirtyUnloadStrategy.FLUSH,
52
- autoFlushStrategy: EBAutoFlushStrategy.ALWAYS_AUTO_FLUSH,
53
- };
54
-
55
- /** {@link IBDatabaseConfig} used when `getDatabase()` creates a fresh database. */
56
- export const DEFAULT_DATABASE_CONFIG: IBDatabaseConfig = {
57
- defaultCollectionConfig: DEFAULT_COLLECTION_CONFIG,
58
- };
59
-
60
- export class BDatabase implements IBDatabase {
61
- public readonly config: IBDatabaseConfig;
62
- private collections: Map<string, BCollection> = new Map();
63
- readonly io: IODatabase;
64
- readonly functionRegistry?: IBFunctionRegistry;
65
-
66
- public constructor(io: IODatabase, config?: IBDatabaseConfig, functionRegistry?: IBFunctionRegistry) {
67
- this.config = config ?? DEFAULT_DATABASE_CONFIG;
68
- this.io = io;
69
- this.functionRegistry = functionRegistry;
70
- }
71
-
72
- public addCollection(collection: BCollection): void {
73
- this.collections.set(collection.collectionName, collection);
74
- }
75
-
76
- public async getCollection<T>(collectionName: string, collectionConfig?: IBCollectionConfig): Promise<IBCollection<T>> {
77
- return runReentrantProtected(MutexKeys.DATABASE, async () => {
78
- const cname = canonicalName(collectionName);
79
- let ret: BCollection<T> | undefined = this.collections.get(cname);
80
- if (!ret) {
81
- ret = await this.createCollection(collectionName, collectionConfig);
82
- }
83
- return ret;
84
- });
85
- }
86
-
87
- public async createCollection<T = any>(collectionName: string, collectionConfig?: IBCollectionConfig): Promise<BCollection<T>> {
88
- return runReentrantProtected(MutexKeys.DATABASE, async () => {
89
- const cname = canonicalName(collectionName);
90
- if (this.collections.has(cname)) {
91
- throw new Error(`cannot create already existing collection ${collectionName}`);
92
- }
93
- const id = uuidv7();
94
- const ret: BCollection<T> = new BCollection<T>(this, id, collectionName, collectionConfig ?? this.config.defaultCollectionConfig);
95
- this.addCollection(ret);
96
- return this.io.createCollection<T>(ret);
97
- });
98
- }
99
-
100
- public async hasCollection(collectionName: string): Promise<boolean> {
101
- return runReentrantProtected(MutexKeys.DATABASE, async () => {
102
- return this.collections.has(canonicalName(collectionName));
103
- });
104
- }
105
-
106
- public async dropCollection(collectionName: string): Promise<void> {
107
- const cname = canonicalName(collectionName);
108
- return runMultiReentrantProtected([MutexKeys.DATABASE, cname], async () => {
109
- const coll: BCollection = this.collections.get(cname) as BCollection;
110
- if (coll) {
111
- coll.markDropped();
112
- this.collections.delete(cname);
113
- return this.io.deleteCollection(cname);
114
- }
115
- else {
116
- throw new Error(`no such collection ${collectionName}`);
117
- }
118
- });
119
- }
120
-
121
- public async snapshot(targetDir: string, options?: IBSnapshotOptions): Promise<string> {
122
- const collectionNames = Array.from(this.collections.keys());
123
- return runMultiReentrantProtected([MutexKeys.DATABASE, ...collectionNames], async () => {
124
- for (const collection of this.collections.values()) {
125
- await collection.flush();
126
- }
127
- return this.io.snapshot(targetDir, options);
128
- });
129
- }
130
-
131
- public async transaction(collectionNames: string[], fn: (tx: IBTransaction) => Promise<void>): Promise<void> {
132
- const canonicalNames = Array.from(new Set(collectionNames.map(canonicalName)));
133
- return runMultiReentrantProtected(canonicalNames, async () => {
134
- const originals = new Map<string, BCollection>();
135
- const clones = new Map<string, BCollection>();
136
- for (const name of canonicalNames) {
137
- const original = await this.getCollection(name) as BCollection;
138
- originals.set(name, original);
139
- clones.set(name, await original.cloneForTransaction());
140
- }
141
- const tx: IBTransaction = {
142
- getCollection: async <T>(collectionName: string): Promise<IBTransactionCollection<T>> => {
143
- const clone = clones.get(canonicalName(collectionName));
144
- if (!clone) {
145
- throw new Error(`collection "${collectionName}" was not declared to this transaction`);
146
- }
147
- return clone as IBTransactionCollection<T>;
148
- },
149
- };
150
- await fn(tx);
151
- for (const name of canonicalNames) {
152
- await originals.get(name)!.commitTransactionClone(clones.get(name)!);
153
- }
154
- });
155
- }
156
-
157
- }
158
-
159
- export class BCollection<T = any> implements IBCollection<T> {
160
- readonly database: BDatabase;
161
- readonly collectionConfig: IBCollectionConfig;
162
- readonly collectionName: string;
163
- readonly collectionID: string;
164
-
165
- private mapData: Map<string, T> = new Map();
166
- private arrayData: Array<T> = [];
167
- private loaded: boolean = false;
168
- private dirty: boolean = false;
169
- private dropped: boolean = false;
170
-
171
- private addHandlers: Array<{ handler: TAddHandler<T>; mode: EBHandlerMode }> = [];
172
- private updateHandlers: Array<{ handler: TUpdateHandler<T>; mode: EBHandlerMode }> = [];
173
- private deleteHandlers: Array<{ handler: TDeleteHandler<T>; mode: EBHandlerMode }> = [];
174
-
175
- private indexDefinitions: Map<string, TIndexKeyFunction<T>> = new Map();
176
- private indexRuntime: Map<string, Map<string, T[]>> = new Map();
177
-
178
- public constructor(database: BDatabase, collectionID: string, collectionName: string, collectionConfig: IBCollectionConfig) {
179
- this.database = database;
180
- this.collectionID = collectionID;
181
- this.collectionName = canonicalName(collectionName);
182
- this.collectionConfig = collectionConfig;
183
- switch (collectionConfig.memStorageStrategy) {
184
- case EBMemStorageStrategy.MAP:
185
- // if (!collectionConfig.getID || !collectionConfig.setID) {
186
- // throw new Error("need id accessors configured when using map mem storage");
187
- // }
188
- break;
189
- default:
190
- case EBMemStorageStrategy.ARRAY:
191
- break;
192
- }
193
- }
194
-
195
- private async checkLoaded(): Promise<void> {
196
- if (!this.loaded) {
197
- await this.reload();
198
- }
199
- }
200
-
201
- private async runExclusive<R>(fn: () => Promise<R>): Promise<R> {
202
- return runReentrantProtected(this.collectionName, fn);
203
- }
204
-
205
- private async runProtected<R>(fn: () => Promise<R>): Promise<R> {
206
- return this.runExclusive(async () => {
207
- if (this.dropped) {
208
- throw new Error(`collection "${this.collectionName}" has been dropped`);
209
- }
210
- return fn();
211
- });
212
- }
213
-
214
- /**
215
- * call this only within the protecting mutex
216
- */
217
- public markDropped(): void {
218
- this.dropped = true;
219
- }
220
-
221
- public async get(predicate?: (v: T) => boolean): Promise<T[]> {
222
- return this.runProtected(async () => {
223
- await this.checkLoaded();
224
- switch (this.collectionConfig.memStorageStrategy) {
225
- case EBMemStorageStrategy.MAP:
226
- return !predicate ?
227
- this.mapData.values().toArray() :
228
- this.mapData.values().filter(predicate).toArray();
229
- default:
230
- case EBMemStorageStrategy.ARRAY:
231
- return !predicate ?
232
- this.arrayData.slice() :
233
- this.arrayData.filter(predicate);
234
- }
235
- });
236
- }
237
-
238
- public async count(predicate?: (v: T) => boolean): Promise<number> {
239
- return this.runProtected(async () => {
240
- await this.checkLoaded();
241
- switch (this.collectionConfig.memStorageStrategy) {
242
- case EBMemStorageStrategy.MAP:
243
- return !predicate ?
244
- this.mapData.size :
245
- this.mapData.values().filter(predicate).reduce((n) => n + 1, 0);
246
- default:
247
- case EBMemStorageStrategy.ARRAY:
248
- return !predicate ?
249
- this.arrayData.length :
250
- this.arrayData.filter(predicate).length;
251
- }
252
- });
253
- }
254
-
255
- public async getByIndex(indexKey: string, key: string): Promise<T[]> {
256
- return this.runProtected(async () => {
257
- await this.checkLoaded();
258
- const byKey = this.indexRuntime.get(indexKey);
259
- if (!byKey) {
260
- throw new Error(`index "${indexKey}" is not configured`);
261
- }
262
- return byKey.get(key)?.slice() ?? [];
263
- });
264
- }
265
-
266
- private async invokeHandler(fn: () => void | Promise<void>, modificationType: EBModificationType): Promise<void> {
267
- try {
268
- await fn();
269
- }
270
- catch (error) {
271
- logger.error(`bajadab: collection "${this.collectionName}" ${modificationType} handler failed`, error);
272
- }
273
- }
274
-
275
- private async fireAdd(added: T[]): Promise<void> {
276
- for (const v of added) {
277
- for (const { handler, mode } of this.addHandlers) {
278
- const call = this.invokeHandler(() => handler(v), EBModificationType.ADD);
279
- if (mode === EBHandlerMode.SYNC) {
280
- await call;
281
- }
282
- }
283
- }
284
- }
285
-
286
- private async fireUpdate(pairs: Array<[T, T]>): Promise<void> {
287
- for (const [previous, updated] of pairs) {
288
- for (const { handler, mode } of this.updateHandlers) {
289
- const call = this.invokeHandler(() => handler(previous, updated), EBModificationType.UPDATE);
290
- if (mode === EBHandlerMode.SYNC) {
291
- await call;
292
- }
293
- }
294
- }
295
- }
296
-
297
- private async fireDelete(deleted: T[]): Promise<void> {
298
- for (const v of deleted) {
299
- for (const { handler, mode } of this.deleteHandlers) {
300
- const call = this.invokeHandler(() => handler(v), EBModificationType.DELETE);
301
- if (mode === EBHandlerMode.SYNC) {
302
- await call;
303
- }
304
- }
305
- }
306
- }
307
-
308
- public async on(modificationType: EBModificationType.ADD, handler: TAddHandler<T>, mode?: EBHandlerMode): Promise<IHandlerRegistration>;
309
- public async on(modificationType: EBModificationType.UPDATE, handler: TUpdateHandler<T>, mode?: EBHandlerMode): Promise<IHandlerRegistration>;
310
- public async on(modificationType: EBModificationType.DELETE, handler: TDeleteHandler<T>, mode?: EBHandlerMode): Promise<IHandlerRegistration>;
311
- public async on(modificationType: EBModificationType, handler: TAddHandler<T> | TUpdateHandler<T> | TDeleteHandler<T>, mode: EBHandlerMode = EBHandlerMode.SYNC): Promise<IHandlerRegistration> {
312
- return this.runProtected(async () => {
313
- switch (modificationType) {
314
- case EBModificationType.ADD: {
315
- const h = handler as TAddHandler<T>;
316
- this.addHandlers.push({ handler: h, mode });
317
- return { cancel: async () => this.runExclusive(async () => { this.addHandlers = this.addHandlers.filter(x => x.handler !== h); }) };
318
- }
319
- case EBModificationType.UPDATE: {
320
- const h = handler as TUpdateHandler<T>;
321
- this.updateHandlers.push({ handler: h, mode });
322
- return { cancel: async () => this.runExclusive(async () => { this.updateHandlers = this.updateHandlers.filter(x => x.handler !== h); }) };
323
- }
324
- case EBModificationType.DELETE: {
325
- const h = handler as TDeleteHandler<T>;
326
- this.deleteHandlers.push({ handler: h, mode });
327
- return { cancel: async () => this.runExclusive(async () => { this.deleteHandlers = this.deleteHandlers.filter(x => x.handler !== h); }) };
328
- }
329
- default:
330
- throw new Error(`unknown modification type ${modificationType}`);
331
- }
332
- });
333
- }
334
-
335
- public async delete(predicate: (v: T) => boolean): Promise<number> {
336
- return this.runProtected(async () => {
337
- await this.checkLoaded();
338
- let ret = 0;
339
- const trackDeletes = this.deleteHandlers.length > 0;
340
- const indexDeletes = this.indexDefinitions.size > 0;
341
- const deleted: T[] = [];
342
- switch (this.collectionConfig.memStorageStrategy) {
343
- case EBMemStorageStrategy.MAP:
344
- for (const [key, value] of this.mapData) {
345
- if (predicate(value)) {
346
- ret++;
347
- if (indexDeletes) {
348
- await this.removeFromIndexes(value);
349
- }
350
- this.mapData.delete(key);
351
- if (trackDeletes) {
352
- deleted.push(value);
353
- }
354
- }
355
- }
356
- break;
357
- default:
358
- case EBMemStorageStrategy.ARRAY:
359
- switch (this.collectionConfig.arrayDeletionStrategy) {
360
- case EBArrayDeletionStrategy.IN_PLACE:
361
- let writeIndex = 0;
362
- for (let readIndex = 0; readIndex < this.arrayData.length; readIndex++) {
363
- const item = this.arrayData[readIndex];
364
- if (!predicate(item!)) {
365
- this.arrayData[writeIndex] = item!;
366
- writeIndex++;
367
- }
368
- else {
369
- ret++;
370
- if (indexDeletes) {
371
- await this.removeFromIndexes(item!);
372
- }
373
- if (trackDeletes) {
374
- deleted.push(item!);
375
- }
376
- }
377
- }
378
- this.arrayData.length = writeIndex;
379
- break;
380
- default:
381
- case EBArrayDeletionStrategy.NEW_ARRAY:
382
- const ol = this.arrayData.length;
383
- if (trackDeletes || indexDeletes) {
384
- const removed = this.arrayData.filter(predicate);
385
- if (trackDeletes) {
386
- deleted.push(...removed);
387
- }
388
- if (indexDeletes) {
389
- for (const v of removed) {
390
- await this.removeFromIndexes(v);
391
- }
392
- }
393
- }
394
- this.arrayData = this.arrayData.filter(v => !predicate(v));
395
- ret += (ol - this.arrayData.length);
396
- break;
397
- }
398
- break;
399
- }
400
- if (ret > 0) {
401
- await this.doOnDirty();
402
- }
403
- if (deleted.length > 0) {
404
- await this.fireDelete(deleted);
405
- }
406
- return ret;
407
- });
408
- }
409
-
410
- public async has(predicate: (v: T) => boolean): Promise<boolean> {
411
- return this.runProtected(async () => {
412
- await this.checkLoaded();
413
- switch (this.collectionConfig.memStorageStrategy) {
414
- case EBMemStorageStrategy.MAP:
415
- return this.mapData.values().find(predicate) !== undefined;
416
- default:
417
- case EBMemStorageStrategy.ARRAY:
418
- return this.arrayData.find(predicate) !== undefined;
419
- }
420
- });
421
- }
422
-
423
- private resolveIndexDefinitions(): void {
424
- this.indexDefinitions = new Map();
425
- if (!this.collectionConfig.indexes) {
426
- return;
427
- }
428
- for (const [indexKey, registryKey] of Object.entries(this.collectionConfig.indexes)) {
429
- const extractor = this.database.functionRegistry?.fetch(registryKey) as TIndexKeyFunction<T> | undefined;
430
- if (!extractor) {
431
- throw new Error(`index key function "${registryKey}" is not registered`);
432
- }
433
- this.indexDefinitions.set(indexKey, extractor);
434
- }
435
- }
436
-
437
- private async indexKeysFor(v: T): Promise<Array<[string, string[]]>> {
438
- const result: Array<[string, string[]]> = [];
439
- for (const [indexKey, extractor] of this.indexDefinitions) {
440
- const extracted = await extractor(v);
441
- if (extracted === undefined) {
442
- continue;
443
- }
444
- result.push([indexKey, Array.isArray(extracted) ? extracted : [extracted]]);
445
- }
446
- return result;
447
- }
448
-
449
- private async addToIndexes(v: T): Promise<void> {
450
- for (const [indexKey, keys] of await this.indexKeysFor(v)) {
451
- const byKey = this.indexRuntime.get(indexKey)!;
452
- for (const key of keys) {
453
- let bucket = byKey.get(key);
454
- if (!bucket) {
455
- bucket = [];
456
- byKey.set(key, bucket);
457
- }
458
- bucket.push(v);
459
- }
460
- }
461
- }
462
-
463
- private async removeFromIndexes(v: T): Promise<void> {
464
- for (const [indexKey, keys] of await this.indexKeysFor(v)) {
465
- const byKey = this.indexRuntime.get(indexKey)!;
466
- for (const key of keys) {
467
- const bucket = byKey.get(key);
468
- if (!bucket) {
469
- continue;
470
- }
471
- const at = bucket.indexOf(v);
472
- if (at !== -1) {
473
- bucket.splice(at, 1);
474
- }
475
- if (bucket.length === 0) {
476
- byKey.delete(key);
477
- }
478
- }
479
- }
480
- }
481
-
482
- /**
483
- * full rebuild from currently loaded data; only needed right after (re)load, since every
484
- * value then is a freshly deserialized object the incremental add/update/delete hooks never saw
485
- */
486
- private async rebuildIndexRuntime(): Promise<void> {
487
- // every configured index gets an entry up front, even if empty, so getByIndex()/
488
- // addToIndexes()/removeFromIndexes() can assume it's there instead of checking twice
489
- this.indexRuntime = new Map(Array.from(this.indexDefinitions.keys(), (indexKey) => [indexKey, new Map()]));
490
- if (this.indexDefinitions.size === 0) {
491
- return;
492
- }
493
- const values = this.collectionConfig.memStorageStrategy === EBMemStorageStrategy.MAP ?
494
- [...this.mapData.values()] : this.arrayData;
495
- for (const v of values) {
496
- await this.addToIndexes(v);
497
- }
498
- }
499
-
500
- private resolveIDGetter(): TGetIDFunction {
501
- if (this.collectionConfig.getID) {
502
- const found = this.database.functionRegistry?.fetch(this.collectionConfig.getID) as TGetIDFunction | undefined;
503
- if (!found) {
504
- throw new Error(`getID function "${this.collectionConfig.getID}" is not registered`);
505
- }
506
- return found;
507
- }
508
- return DEFAULT_GET_ID;
509
- }
510
-
511
- /**
512
- * id -> value for every stored item, resolved via the plain id getter (no auto-id assignment - every
513
- * stored item already has one). Used to diff a transaction's working copy against the pre-transaction
514
- * state in {@link commitTransactionClone}.
515
- */
516
- private async idKeyedSnapshot(): Promise<Map<string, T>> {
517
- switch (this.collectionConfig.memStorageStrategy) {
518
- case EBMemStorageStrategy.MAP:
519
- return new Map(this.mapData);
520
- default:
521
- case EBMemStorageStrategy.ARRAY:
522
- const getter = this.resolveIDGetter();
523
- const ret = new Map<string, T>();
524
- for (const v of this.arrayData) {
525
- const id = await getter(v);
526
- if (id) {
527
- ret.set(id, v);
528
- }
529
- }
530
- return ret;
531
- }
532
- }
533
-
534
- private async getID(v: T): Promise<string> {
535
- let id = await this.resolveIDGetter()(v);
536
- if (!id) {
537
- switch (this.collectionConfig.autoIDStrategy) {
538
- case EBAutoIDStrategy.NO_AUTO_ID:
539
- throw new Error("cannot omit auto-ids and than trying to add a value without an id");
540
- default:
541
- case EBAutoIDStrategy.AUTO_ID_UUID:
542
- id = uuidv7();
543
- let setter: TSetIDFunction;
544
- if (this.collectionConfig.setID) {
545
- const found = this.database.functionRegistry?.fetch(this.collectionConfig.setID) as TSetIDFunction | undefined;
546
- if (!found) {
547
- throw new Error(`setID function "${this.collectionConfig.setID}" is not registered`);
548
- }
549
- setter = found;
550
- }
551
- else {
552
- setter = DEFAULT_SET_ID;
553
- }
554
- await setter(v, id);
555
- break;
556
- }
557
- }
558
- return id;
559
- }
560
-
561
- public async add(...vs: T[]): Promise<number> {
562
- return this.runProtected(async () => {
563
- await this.checkLoaded();
564
- switch (this.collectionConfig.memStorageStrategy) {
565
- case EBMemStorageStrategy.MAP:
566
- let ret = 0;
567
- const trackAddsMap = this.addHandlers.length > 0;
568
- const indexAddsMap = this.indexDefinitions.size > 0;
569
- const addedMap: T[] = [];
570
- for (const v of vs) {
571
- const id = await this.getID(v);
572
- if (this.mapData.has(id)) {
573
- switch (this.collectionConfig.duplicateIDStrategy) {
574
- case EBDuplicateIDStrategy.ERROR:
575
- throw new Error(`duplicate id ${id}`);
576
- case EBDuplicateIDStrategy.IGNORE:
577
- // ignore the new value
578
- break;
579
- default:
580
- case EBDuplicateIDStrategy.OVERWRITE:
581
- // replace the new value
582
- ret++;
583
- if (indexAddsMap) {
584
- await this.removeFromIndexes(this.mapData.get(id)!);
585
- }
586
- this.mapData.set(id, v);
587
- if (indexAddsMap) {
588
- await this.addToIndexes(v);
589
- }
590
- if (trackAddsMap) {
591
- addedMap.push(v);
592
- }
593
- break;
594
- }
595
- }
596
- else {
597
- ret++;
598
- this.mapData.set(id, v);
599
- if (indexAddsMap) {
600
- await this.addToIndexes(v);
601
- }
602
- if (trackAddsMap) {
603
- addedMap.push(v);
604
- }
605
- }
606
- }
607
- if (ret > 0) {
608
- await this.doOnDirty();
609
- }
610
- if (addedMap.length > 0) {
611
- await this.fireAdd(addedMap);
612
- }
613
- return ret;
614
- default:
615
- case EBMemStorageStrategy.ARRAY:
616
- const trackAddsArray = this.addHandlers.length > 0;
617
- const indexAddsArray = this.indexDefinitions.size > 0;
618
- const addedArray: T[] = [];
619
- switch (this.collectionConfig.duplicateIDStrategy) {
620
- case EBDuplicateIDStrategy.ERROR:
621
- const haveIDs = new Set<string>();
622
- for (const v of this.arrayData) {
623
- haveIDs.add(await this.getID(v));
624
- }
625
- for (const v of vs) {
626
- const id = await this.getID(v);
627
- if (haveIDs.has(id)) {
628
- throw new Error(`duplicate id ${id}`);
629
- }
630
- haveIDs.add(id);
631
- this.arrayData.push(v);
632
- if (indexAddsArray) {
633
- await this.addToIndexes(v);
634
- }
635
- if (trackAddsArray) {
636
- addedArray.push(v);
637
- }
638
- }
639
- break;
640
- case EBDuplicateIDStrategy.IGNORE:
641
- const seenIDs = new Set<string>();
642
- for (const v of this.arrayData) {
643
- seenIDs.add(await this.getID(v));
644
- }
645
- for (const v of vs) {
646
- const id = await this.getID(v);
647
- if (!seenIDs.has(id)) {
648
- seenIDs.add(id);
649
- this.arrayData.push(v);
650
- if (indexAddsArray) {
651
- await this.addToIndexes(v);
652
- }
653
- if (trackAddsArray) {
654
- addedArray.push(v);
655
- }
656
- }
657
- }
658
- break;
659
- default:
660
- case EBDuplicateIDStrategy.OVERWRITE:
661
- const indexMap = new Map<string | number, number>();
662
- for (let i = 0; i < this.arrayData.length; i++) {
663
- indexMap.set(await this.getID(this.arrayData[i]!), i);
664
- }
665
- for (const v of vs) {
666
- const id = await this.getID(v);
667
- const existingIndex = indexMap.get(id);
668
-
669
- if (existingIndex !== undefined) {
670
- if (indexAddsArray) {
671
- await this.removeFromIndexes(this.arrayData[existingIndex]!);
672
- }
673
- this.arrayData[existingIndex] = v;
674
- }
675
- else {
676
- // store new index in indexCache in the case this id appears again
677
- indexMap.set(id, this.arrayData.length);
678
- this.arrayData.push(v);
679
- }
680
- if (indexAddsArray) {
681
- await this.addToIndexes(v);
682
- }
683
- if (trackAddsArray) {
684
- addedArray.push(v);
685
- }
686
- }
687
- break;
688
- }
689
- if (vs.length > 0) {
690
- await this.doOnDirty();
691
- }
692
- if (addedArray.length > 0) {
693
- await this.fireAdd(addedArray);
694
- }
695
- return vs.length;
696
- }
697
- });
698
- }
699
-
700
- public async update(predicate: (v: T) => boolean, updater: (v: T) => Promise<T>): Promise<number> {
701
- return this.runProtected(async () => {
702
- await this.checkLoaded();
703
- let ret = 0;
704
- const trackUpdates = this.updateHandlers.length > 0;
705
- const indexUpdates = this.indexDefinitions.size > 0;
706
- const updated: Array<[T, T]> = [];
707
- switch (this.collectionConfig.memStorageStrategy) {
708
- case EBMemStorageStrategy.MAP:
709
- for (const [key, value] of this.mapData) {
710
- if (predicate(value)) {
711
- const replacement = await updater(value);
712
- if (replacement !== undefined) {
713
- ret++;
714
- if (indexUpdates) {
715
- await this.removeFromIndexes(value);
716
- }
717
- this.mapData.set(key, replacement);
718
- if (indexUpdates) {
719
- await this.addToIndexes(replacement);
720
- }
721
- if (trackUpdates) {
722
- updated.push([value, replacement]);
723
- }
724
- }
725
- else {
726
- throw new Error("updater gave undefined");
727
- }
728
- }
729
- }
730
- break;
731
- default:
732
- case EBMemStorageStrategy.ARRAY:
733
- for (let i = 0; i < this.arrayData.length; i++) {
734
- if (predicate(this.arrayData[i]!)) {
735
- const previous = this.arrayData[i]!;
736
- const replacement = await updater(previous);
737
- if (replacement !== undefined) {
738
- ret++;
739
- if (indexUpdates) {
740
- await this.removeFromIndexes(previous);
741
- }
742
- this.arrayData[i] = replacement;
743
- if (indexUpdates) {
744
- await this.addToIndexes(replacement);
745
- }
746
- if (trackUpdates) {
747
- updated.push([previous, replacement]);
748
- }
749
- }
750
- else {
751
- throw new Error("updater gave undefined");
752
- }
753
- }
754
- }
755
- break;
756
- }
757
- if (ret > 0) {
758
- await this.doOnDirty();
759
- }
760
- if (updated.length > 0) {
761
- await this.fireUpdate(updated);
762
- }
763
- return ret;
764
- });
765
- }
766
-
767
- private async doOnDirty(): Promise<void> {
768
- this.dirty = true;
769
- if (this.collectionConfig.autoFlushStrategy == EBAutoFlushStrategy.ALWAYS_AUTO_FLUSH) {
770
- await this.flush();
771
- }
772
- }
773
-
774
- public async flush(): Promise<void> {
775
- return this.runProtected(async () => {
776
- if (this.loaded) {
777
- switch (this.collectionConfig.memStorageStrategy) {
778
- case EBMemStorageStrategy.MAP:
779
- await this.database.io.writeMapData(this.collectionID, this.mapData);
780
- break;
781
- default:
782
- case EBMemStorageStrategy.ARRAY:
783
- await this.database.io.writeArrayData(this.collectionID, this.arrayData);
784
- break;
785
- }
786
- this.dirty = false;
787
- }
788
- });
789
- }
790
-
791
- public async reload(): Promise<void> {
792
- return this.runProtected(async () => {
793
- switch (this.collectionConfig.memStorageStrategy) {
794
- case EBMemStorageStrategy.MAP:
795
- this.mapData = await this.database.io.loadMapData<T>(this.collectionID);
796
- break;
797
- default:
798
- case EBMemStorageStrategy.ARRAY:
799
- this.arrayData = await this.database.io.loadArrayData<T>(this.collectionID);
800
- break;
801
- }
802
- this.loaded = true;
803
- this.dirty = false;
804
- this.resolveIndexDefinitions();
805
- await this.rebuildIndexRuntime();
806
- });
807
- }
808
-
809
- public async unload(): Promise<void> {
810
- return this.runProtected(async () => {
811
- if (this.dirty) {
812
- switch (this.collectionConfig.dirtyUnloadStrategy) {
813
- case EBDirtyUnloadStrategy.FLUSH:
814
- await this.flush();
815
- break;
816
- case EBDirtyUnloadStrategy.ERROR:
817
- throw new Error("cannot unload unwritten collection");
818
- default:
819
- case EBDirtyUnloadStrategy.IGNORE:
820
- // do nothing
821
- break;
822
-
823
- }
824
- }
825
- switch (this.collectionConfig.memStorageStrategy) {
826
- case EBMemStorageStrategy.MAP:
827
- this.mapData = new Map();
828
- break;
829
- default:
830
- case EBMemStorageStrategy.ARRAY:
831
- this.arrayData = [];
832
- break;
833
- }
834
- this.loaded = false;
835
- this.dirty = false;
836
- });
837
- }
838
-
839
- /**
840
- * Creates a transaction-scoped working copy: same identity/config as this collection, but with
841
- * independently mutable in-memory state (auto-flush forced off, regardless of this collection's own
842
- * `autoFlushStrategy`) and no change-event handlers of its own. Call only while already holding this
843
- * collection's mutex for the whole transaction (see {@link BDatabase.transaction}).
844
- */
845
- public async cloneForTransaction(): Promise<BCollection<T>> {
846
- return this.runProtected(async () => {
847
- await this.checkLoaded();
848
- const txConfig: IBCollectionConfig = { ...this.collectionConfig, autoFlushStrategy: EBAutoFlushStrategy.NO_AUTO_FLUSH };
849
- const clone = new BCollection<T>(this.database, this.collectionID, this.collectionName, txConfig);
850
- clone.mapData = new Map(this.mapData);
851
- clone.arrayData = this.arrayData.slice();
852
- clone.loaded = true;
853
- clone.resolveIndexDefinitions();
854
- await clone.rebuildIndexRuntime();
855
- return clone;
856
- });
857
- }
858
-
859
- /**
860
- * Applies a working copy from {@link cloneForTransaction} back onto this collection: replaces the live
861
- * state, flushes once, then fires `ADD`/`UPDATE`/`DELETE` handlers for the net difference between this
862
- * collection's state before the transaction and `clone`'s state - not for each intermediate operation the
863
- * transaction performed. The before/after diff itself (and the id resolution it needs) only runs when at
864
- * least one handler is registered on this collection - a transaction on a collection nobody listens to
865
- * skips it entirely. Call only while already holding this collection's mutex for the whole transaction.
866
- */
867
- public async commitTransactionClone(clone: BCollection<T>): Promise<void> {
868
- return this.runProtected(async () => {
869
- await this.checkLoaded();
870
- const added: T[] = [];
871
- const updated: Array<[T, T]> = [];
872
- const deleted: T[] = [];
873
- // the before/after diff only feeds fireAdd()/fireUpdate()/fireDelete() below - skip it
874
- // entirely (incl. idKeyedSnapshot()'s id-getter calls) when nothing is listening
875
- const trackChanges = this.addHandlers.length > 0 || this.updateHandlers.length > 0 || this.deleteHandlers.length > 0;
876
- if (trackChanges) {
877
- const before = await this.idKeyedSnapshot();
878
- const after = await clone.idKeyedSnapshot();
879
- for (const [id, value] of after) {
880
- if (!before.has(id)) {
881
- added.push(value);
882
- }
883
- else {
884
- const previous = before.get(id) as T;
885
- if (JSON.stringify(previous) !== JSON.stringify(value)) {
886
- updated.push([previous, value]);
887
- }
888
- }
889
- }
890
- for (const [id, value] of before) {
891
- if (!after.has(id)) {
892
- deleted.push(value);
893
- }
894
- }
895
- }
896
- this.mapData = clone.mapData;
897
- this.arrayData = clone.arrayData;
898
- this.indexRuntime = clone.indexRuntime;
899
- this.dirty = true;
900
- await this.flush();
901
- if (added.length > 0) {
902
- await this.fireAdd(added);
903
- }
904
- if (updated.length > 0) {
905
- await this.fireUpdate(updated);
906
- }
907
- if (deleted.length > 0) {
908
- await this.fireDelete(deleted);
909
- }
910
- });
911
- }
912
-
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { uuidv7, } from "uuidv7";
3
+ import { runReentrantProtected } from "@incoqnito.io/ts-iq-core";
4
+
5
+ import type { IODatabase, IPreparedWrite } from "./fileio.js";
6
+
7
+ import {
8
+ EBArrayDeletionStrategy,
9
+ EBAutoFlushStrategy,
10
+ EBAutoIDStrategy,
11
+ EBDirtyUnloadStrategy,
12
+ EBDuplicateIDStrategy,
13
+ EBHandlerMode,
14
+ EBMemStorageStrategy,
15
+ EBModificationType,
16
+ IBCollection,
17
+ IBCollectionConfig,
18
+ IBDatabase,
19
+ IBDatabaseConfig,
20
+ IBFunctionRegistry,
21
+ IBSnapshotOptions,
22
+ IBTransaction,
23
+ IBTransactionCollection,
24
+ IHandlerRegistration,
25
+ TAddHandler,
26
+ TDeleteHandler,
27
+ TGetIDFunction,
28
+ TIndexKeyFunction,
29
+ TSetIDFunction,
30
+ TUpdateHandler,
31
+ } from "./base.js";
32
+ import { canonicalName } from "./utils.js";
33
+
34
+ /** Default {@link TGetIDFunction}: reads `v.id`, `undefined` if absent. */
35
+ export const DEFAULT_GET_ID: TGetIDFunction = (v: any) => (v !== null && typeof v === 'object' && "id" in v) ? v.id : undefined;
36
+ /** Default {@link TSetIDFunction}: assigns to `v.id`. */
37
+ export const DEFAULT_SET_ID: TSetIDFunction = async (v: any, id: string) => { v.id = id; };
38
+
39
+ function normalizeID(id: unknown): string | undefined {
40
+ return id === undefined || id === null || id === "" ? undefined : String(id);
41
+ }
42
+
43
+ export interface IPreparedTransactionCommit {
44
+ readonly write: IPreparedWrite;
45
+ apply(): void;
46
+ fire(): Promise<void>;
47
+ }
48
+
49
+ /** {@link IBCollectionConfig} used when a collection is created without an explicit one. */
50
+ export const DEFAULT_COLLECTION_CONFIG: IBCollectionConfig = {
51
+ arrayDeletionStrategy: EBArrayDeletionStrategy.IN_PLACE,
52
+ autoIDStrategy: EBAutoIDStrategy.AUTO_ID_UUID,
53
+ duplicateIDStrategy: EBDuplicateIDStrategy.OVERWRITE,
54
+ memStorageStrategy: EBMemStorageStrategy.MAP,
55
+ dirtyUnloadStrategy: EBDirtyUnloadStrategy.FLUSH,
56
+ autoFlushStrategy: EBAutoFlushStrategy.ALWAYS_AUTO_FLUSH,
57
+ };
58
+
59
+ /** {@link IBDatabaseConfig} used when `getDatabase()` creates a fresh database. */
60
+ export const DEFAULT_DATABASE_CONFIG: IBDatabaseConfig = {
61
+ defaultCollectionConfig: DEFAULT_COLLECTION_CONFIG,
62
+ };
63
+
64
+ export class BDatabase implements IBDatabase {
65
+ public readonly config: IBDatabaseConfig;
66
+ private collections: Map<string, BCollection> = new Map();
67
+ readonly io: IODatabase;
68
+ readonly functionRegistry?: IBFunctionRegistry;
69
+ readonly iid = uuidv7();
70
+ private readonly transactionScope = new AsyncLocalStorage<ReadonlySet<string>>();
71
+
72
+ public constructor(io: IODatabase, config?: IBDatabaseConfig, functionRegistry?: IBFunctionRegistry) {
73
+ this.config = { ...(config ?? DEFAULT_DATABASE_CONFIG) };
74
+ this.io = io;
75
+ this.functionRegistry = functionRegistry;
76
+ }
77
+
78
+ public async runExclusive<R>(fn: () => Promise<R>): Promise<R> {
79
+ return runReentrantProtected(this.iid, fn);
80
+ }
81
+
82
+ public isInTransaction(collectionName: string): boolean {
83
+ return this.transactionScope.getStore()?.has(collectionName) ?? false;
84
+ }
85
+
86
+ public addCollection(collection: BCollection): void {
87
+ this.collections.set(collection.collectionName, collection);
88
+ }
89
+
90
+ public async getCollection<T>(collectionName: string, collectionConfig?: IBCollectionConfig): Promise<IBCollection<T>> {
91
+ return this.runExclusive(async () => {
92
+ const cname = canonicalName(collectionName);
93
+ let ret: BCollection<T> | undefined = this.collections.get(cname);
94
+ if (!ret) {
95
+ ret = await this.createCollection(collectionName, collectionConfig);
96
+ }
97
+ return ret;
98
+ });
99
+ }
100
+
101
+ public async createCollection<T = any>(collectionName: string, collectionConfig?: IBCollectionConfig): Promise<BCollection<T>> {
102
+ return this.runExclusive(async () => {
103
+ const cname = canonicalName(collectionName);
104
+ if (this.collections.has(cname)) {
105
+ throw new Error(`cannot create already existing collection ${collectionName}`);
106
+ }
107
+ const id = uuidv7();
108
+ const ret: BCollection<T> = new BCollection<T>(this, id, collectionName, { ...(collectionConfig ?? this.config.defaultCollectionConfig) });
109
+ await this.io.createCollection<T>(ret);
110
+ this.addCollection(ret);
111
+ return ret;
112
+ });
113
+ }
114
+
115
+ public async hasCollection(collectionName: string): Promise<boolean> {
116
+ return this.runExclusive(async () => {
117
+ return this.collections.has(canonicalName(collectionName));
118
+ });
119
+ }
120
+
121
+ public async dropCollection(collectionName: string): Promise<void> {
122
+ const cname = canonicalName(collectionName);
123
+ return this.runExclusive(async () => {
124
+ const coll: BCollection = this.collections.get(cname) as BCollection;
125
+ if (coll) {
126
+ coll.markDropped();
127
+ this.collections.delete(cname);
128
+ return this.io.deleteCollection(cname);
129
+ }
130
+ else {
131
+ throw new Error(`no such collection ${collectionName}`);
132
+ }
133
+ });
134
+ }
135
+
136
+ public async snapshot(targetDir: string, options?: IBSnapshotOptions): Promise<string> {
137
+ return this.runExclusive(async () => {
138
+ for (const collection of this.collections.values()) {
139
+ await collection.flush();
140
+ }
141
+ return this.io.snapshot(targetDir, options);
142
+ });
143
+ }
144
+
145
+ public async transaction(collectionNames: string[], fn: (tx: IBTransaction) => Promise<void>): Promise<void> {
146
+ if (this.transactionScope.getStore() !== undefined) {
147
+ throw new Error("nested transactions are not supported");
148
+ }
149
+ const canonicalNames = Array.from(new Set(collectionNames.map(canonicalName)));
150
+ return this.runExclusive(async () => {
151
+ const originals = new Map<string, BCollection>();
152
+ const clones = new Map<string, BCollection>();
153
+ for (const name of canonicalNames) {
154
+ const original = this.collections.get(name);
155
+ if (!original) {
156
+ throw new Error(`no such collection ${name}`);
157
+ }
158
+ originals.set(name, original);
159
+ }
160
+ for (const [name, original] of originals) {
161
+ clones.set(name, await original.cloneForTransaction());
162
+ }
163
+ const tx: IBTransaction = {
164
+ getCollection: async <T>(collectionName: string): Promise<IBTransactionCollection<T>> => {
165
+ const clone = clones.get(canonicalName(collectionName));
166
+ if (!clone) {
167
+ throw new Error(`collection "${collectionName}" was not declared to this transaction`);
168
+ }
169
+ return clone as IBTransactionCollection<T>;
170
+ },
171
+ };
172
+ try {
173
+ await this.transactionScope.run(new Set(canonicalNames), () => fn(tx));
174
+ const prepared: IPreparedTransactionCommit[] = [];
175
+ try {
176
+ for (const name of canonicalNames) {
177
+ prepared.push(await originals.get(name)!.prepareTransactionCommit(clones.get(name)!));
178
+ }
179
+ }
180
+ catch (err) {
181
+ await Promise.all(prepared.map(p => p.write.discard()));
182
+ throw err;
183
+ }
184
+ let renamed = 0;
185
+ try {
186
+ for (const p of prepared) {
187
+ await p.write.commit();
188
+ renamed++;
189
+ }
190
+ }
191
+ finally {
192
+ await Promise.all(prepared.slice(renamed).map(p => p.write.discard()));
193
+ const applied = prepared.slice(0, renamed);
194
+ applied.forEach(p => p.apply());
195
+ for (const p of applied) {
196
+ await p.fire();
197
+ }
198
+ }
199
+ }
200
+ finally {
201
+ clones.forEach(clone => clone.markDropped());
202
+ }
203
+ });
204
+ }
205
+
206
+ }
207
+
208
+ export class BCollection<T = any> implements IBCollection<T> {
209
+ readonly database: BDatabase;
210
+ readonly collectionConfig: IBCollectionConfig;
211
+ readonly collectionName: string;
212
+ readonly collectionID: string;
213
+
214
+ private mapData: Map<string, T> = new Map();
215
+ private arrayData: Array<T> = [];
216
+ private loaded: boolean = false;
217
+ private dirty: boolean = false;
218
+ private dropped: boolean = false;
219
+ private transactionClone: boolean = false;
220
+
221
+ private addHandlers: Array<{ handler: TAddHandler<T>; mode: EBHandlerMode }> = [];
222
+ private updateHandlers: Array<{ handler: TUpdateHandler<T>; mode: EBHandlerMode }> = [];
223
+ private deleteHandlers: Array<{ handler: TDeleteHandler<T>; mode: EBHandlerMode }> = [];
224
+
225
+ private indexDefinitions: Map<string, TIndexKeyFunction<T>> = new Map();
226
+ private indexRuntime: Map<string, Map<string, Set<T>>> = new Map();
227
+ private indexedKeys: Map<T, Array<[string, string[]]>> = new Map();
228
+ private txChanges?: Map<string, EBModificationType>;
229
+
230
+ public constructor(database: BDatabase, collectionID: string, collectionName: string, collectionConfig: IBCollectionConfig) {
231
+ this.database = database;
232
+ this.collectionID = collectionID;
233
+ this.collectionName = canonicalName(collectionName);
234
+ this.collectionConfig = collectionConfig;
235
+ }
236
+
237
+ private async checkLoaded(): Promise<void> {
238
+ if (!this.loaded) {
239
+ await this.reload();
240
+ }
241
+ }
242
+
243
+ private async runExclusive<R>(fn: () => Promise<R>): Promise<R> {
244
+ return this.database.runExclusive(fn);
245
+ }
246
+
247
+ private async runProtected<R>(fn: () => Promise<R>): Promise<R> {
248
+ return this.runExclusive(async () => {
249
+ if (this.dropped) {
250
+ throw new Error(`collection "${this.collectionName}" has been dropped`);
251
+ }
252
+ return fn();
253
+ });
254
+ }
255
+
256
+ private async runMutation<R>(fn: () => Promise<R>): Promise<R> {
257
+ return this.runProtected(async () => {
258
+ if (!this.transactionClone && this.database.isInTransaction(this.collectionName)) {
259
+ throw new Error(`collection "${this.collectionName}" is part of a running transaction; write through tx.getCollection() instead`);
260
+ }
261
+ return fn();
262
+ });
263
+ }
264
+
265
+ /**
266
+ * call this only within the protecting mutex
267
+ */
268
+ public markDropped(): void {
269
+ this.dropped = true;
270
+ }
271
+
272
+ public async get(predicate?: (v: T) => boolean): Promise<T[]> {
273
+ return this.runProtected(async () => {
274
+ await this.checkLoaded();
275
+ switch (this.collectionConfig.memStorageStrategy) {
276
+ case EBMemStorageStrategy.MAP:
277
+ return !predicate ?
278
+ this.mapData.values().toArray() :
279
+ this.mapData.values().filter(predicate).toArray();
280
+ default:
281
+ case EBMemStorageStrategy.ARRAY:
282
+ return !predicate ?
283
+ this.arrayData.slice() :
284
+ this.arrayData.filter(predicate);
285
+ }
286
+ });
287
+ }
288
+
289
+ public async count(predicate?: (v: T) => boolean): Promise<number> {
290
+ return this.runProtected(async () => {
291
+ await this.checkLoaded();
292
+ switch (this.collectionConfig.memStorageStrategy) {
293
+ case EBMemStorageStrategy.MAP:
294
+ return !predicate ?
295
+ this.mapData.size :
296
+ this.mapData.values().filter(predicate).reduce((n) => n + 1, 0);
297
+ default:
298
+ case EBMemStorageStrategy.ARRAY:
299
+ return !predicate ?
300
+ this.arrayData.length :
301
+ this.arrayData.filter(predicate).length;
302
+ }
303
+ });
304
+ }
305
+
306
+ public async getByIndex(indexKey: string, key: string): Promise<T[]> {
307
+ return this.runProtected(async () => {
308
+ await this.checkLoaded();
309
+ const byKey = this.indexRuntime.get(indexKey);
310
+ if (!byKey) {
311
+ throw new Error(`index "${indexKey}" is not configured`);
312
+ }
313
+ return Array.from(byKey.get(key) ?? []);
314
+ });
315
+ }
316
+
317
+ private async invokeHandler(fn: () => void | Promise<void>, modificationType: EBModificationType): Promise<void> {
318
+ try {
319
+ await fn();
320
+ }
321
+ catch (error) {
322
+ // TODO switch logging to ILogging where necessary
323
+ console.error(`bajadab: collection "${this.collectionName}" ${modificationType} handler failed`, error);
324
+ }
325
+ }
326
+
327
+ private async fireAdd(added: T[]): Promise<void> {
328
+ for (const v of added) {
329
+ for (const { handler, mode } of this.addHandlers) {
330
+ const call = this.invokeHandler(() => handler(v), EBModificationType.ADD);
331
+ if (mode === EBHandlerMode.SYNC) {
332
+ await call;
333
+ }
334
+ }
335
+ }
336
+ }
337
+
338
+ private async fireUpdate(pairs: Array<[T, T]>): Promise<void> {
339
+ for (const [previous, updated] of pairs) {
340
+ for (const { handler, mode } of this.updateHandlers) {
341
+ const call = this.invokeHandler(() => handler(previous, updated), EBModificationType.UPDATE);
342
+ if (mode === EBHandlerMode.SYNC) {
343
+ await call;
344
+ }
345
+ }
346
+ }
347
+ }
348
+
349
+ private async fireDelete(deleted: T[]): Promise<void> {
350
+ for (const v of deleted) {
351
+ for (const { handler, mode } of this.deleteHandlers) {
352
+ const call = this.invokeHandler(() => handler(v), EBModificationType.DELETE);
353
+ if (mode === EBHandlerMode.SYNC) {
354
+ await call;
355
+ }
356
+ }
357
+ }
358
+ }
359
+
360
+ public async on(modificationType: EBModificationType.ADD, handler: TAddHandler<T>, mode?: EBHandlerMode): Promise<IHandlerRegistration>;
361
+ public async on(modificationType: EBModificationType.UPDATE, handler: TUpdateHandler<T>, mode?: EBHandlerMode): Promise<IHandlerRegistration>;
362
+ public async on(modificationType: EBModificationType.DELETE, handler: TDeleteHandler<T>, mode?: EBHandlerMode): Promise<IHandlerRegistration>;
363
+ public async on(modificationType: EBModificationType, handler: TAddHandler<T> | TUpdateHandler<T> | TDeleteHandler<T>, mode: EBHandlerMode = EBHandlerMode.SYNC): Promise<IHandlerRegistration> {
364
+ return this.runProtected(async () => {
365
+ switch (modificationType) {
366
+ case EBModificationType.ADD: {
367
+ const h = handler as TAddHandler<T>;
368
+ this.addHandlers.push({ handler: h, mode });
369
+ return { cancel: async () => this.runExclusive(async () => { this.addHandlers = this.addHandlers.filter(x => x.handler !== h); }) };
370
+ }
371
+ case EBModificationType.UPDATE: {
372
+ const h = handler as TUpdateHandler<T>;
373
+ this.updateHandlers.push({ handler: h, mode });
374
+ return { cancel: async () => this.runExclusive(async () => { this.updateHandlers = this.updateHandlers.filter(x => x.handler !== h); }) };
375
+ }
376
+ case EBModificationType.DELETE: {
377
+ const h = handler as TDeleteHandler<T>;
378
+ this.deleteHandlers.push({ handler: h, mode });
379
+ return { cancel: async () => this.runExclusive(async () => { this.deleteHandlers = this.deleteHandlers.filter(x => x.handler !== h); }) };
380
+ }
381
+ default:
382
+ throw new Error(`unknown modification type ${modificationType}`);
383
+ }
384
+ });
385
+ }
386
+
387
+ public async delete(predicate: (v: T) => boolean): Promise<number> {
388
+ return this.runMutation(async () => {
389
+ await this.checkLoaded();
390
+ let ret = 0;
391
+ const trackDeletes = this.deleteHandlers.length > 0;
392
+ const collectDeleted = trackDeletes || this.txChanges !== undefined;
393
+ const indexDeletes = this.indexDefinitions.size > 0;
394
+ const deleted: T[] = [];
395
+ switch (this.collectionConfig.memStorageStrategy) {
396
+ case EBMemStorageStrategy.MAP:
397
+ for (const [key, value] of this.mapData) {
398
+ if (predicate(value)) {
399
+ ret++;
400
+ if (indexDeletes) {
401
+ this.removeFromIndexes(value);
402
+ }
403
+ this.mapData.delete(key);
404
+ if (collectDeleted) {
405
+ deleted.push(value);
406
+ }
407
+ }
408
+ }
409
+ break;
410
+ default:
411
+ case EBMemStorageStrategy.ARRAY:
412
+ switch (this.collectionConfig.arrayDeletionStrategy) {
413
+ case EBArrayDeletionStrategy.IN_PLACE:
414
+ let writeIndex = 0;
415
+ for (let readIndex = 0; readIndex < this.arrayData.length; readIndex++) {
416
+ const item = this.arrayData[readIndex];
417
+ if (!predicate(item!)) {
418
+ this.arrayData[writeIndex] = item!;
419
+ writeIndex++;
420
+ }
421
+ else {
422
+ ret++;
423
+ if (indexDeletes) {
424
+ this.removeFromIndexes(item!);
425
+ }
426
+ if (collectDeleted) {
427
+ deleted.push(item!);
428
+ }
429
+ }
430
+ }
431
+ this.arrayData.length = writeIndex;
432
+ break;
433
+ default:
434
+ case EBArrayDeletionStrategy.NEW_ARRAY:
435
+ if (collectDeleted || indexDeletes) {
436
+ const kept: T[] = [];
437
+ for (const v of this.arrayData) {
438
+ if (predicate(v)) {
439
+ ret++;
440
+ if (indexDeletes) {
441
+ this.removeFromIndexes(v);
442
+ }
443
+ if (collectDeleted) {
444
+ deleted.push(v);
445
+ }
446
+ }
447
+ else {
448
+ kept.push(v);
449
+ }
450
+ }
451
+ this.arrayData = kept;
452
+ }
453
+ else {
454
+ const ol = this.arrayData.length;
455
+ this.arrayData = this.arrayData.filter(v => !predicate(v));
456
+ ret += (ol - this.arrayData.length);
457
+ }
458
+ break;
459
+ }
460
+ break;
461
+ }
462
+ if (ret > 0) {
463
+ await this.doOnDirty();
464
+ }
465
+ await this.recordTxChanges(EBModificationType.DELETE, deleted);
466
+ if (trackDeletes && deleted.length > 0) {
467
+ await this.fireDelete(deleted);
468
+ }
469
+ return ret;
470
+ });
471
+ }
472
+
473
+ public async has(predicate: (v: T) => boolean): Promise<boolean> {
474
+ return this.runProtected(async () => {
475
+ await this.checkLoaded();
476
+ switch (this.collectionConfig.memStorageStrategy) {
477
+ case EBMemStorageStrategy.MAP:
478
+ return this.mapData.values().some(predicate);
479
+ default:
480
+ case EBMemStorageStrategy.ARRAY:
481
+ return this.arrayData.some(predicate);
482
+ }
483
+ });
484
+ }
485
+
486
+ private resolveIndexDefinitions(): void {
487
+ this.indexDefinitions = new Map();
488
+ if (!this.collectionConfig.indexes) {
489
+ return;
490
+ }
491
+ for (const [indexKey, registryKey] of Object.entries(this.collectionConfig.indexes)) {
492
+ const extractor = this.database.functionRegistry?.fetch(registryKey) as TIndexKeyFunction<T> | undefined;
493
+ if (!extractor) {
494
+ throw new Error(`index key function "${registryKey}" is not registered`);
495
+ }
496
+ this.indexDefinitions.set(indexKey, extractor);
497
+ }
498
+ }
499
+
500
+ private async indexKeysFor(v: T): Promise<Array<[string, string[]]>> {
501
+ const result: Array<[string, string[]]> = [];
502
+ for (const [indexKey, extractor] of this.indexDefinitions) {
503
+ const extracted = await extractor(v);
504
+ if (extracted === undefined) {
505
+ continue;
506
+ }
507
+ result.push([indexKey, Array.isArray(extracted) ? extracted : [extracted]]);
508
+ }
509
+ return result;
510
+ }
511
+
512
+ private async addToIndexes(v: T): Promise<void> {
513
+ this.addKeysToIndexes(v, await this.indexKeysFor(v));
514
+ }
515
+
516
+ private async indexKeysForAll(vs: T[]): Promise<Array<Array<[string, string[]]>>> {
517
+ const ret: Array<Array<[string, string[]]>> = [];
518
+ for (const v of vs) {
519
+ ret.push(await this.indexKeysFor(v));
520
+ }
521
+ return ret;
522
+ }
523
+
524
+ private addKeysToIndexes(v: T, indexKeys: Array<[string, string[]]>): void {
525
+ for (const [indexKey, keys] of indexKeys) {
526
+ const byKey = this.indexRuntime.get(indexKey)!;
527
+ for (const key of keys) {
528
+ let bucket = byKey.get(key);
529
+ if (!bucket) {
530
+ bucket = new Set();
531
+ byKey.set(key, bucket);
532
+ }
533
+ bucket.add(v);
534
+ }
535
+ }
536
+ if (indexKeys.length > 0) {
537
+ this.indexedKeys.set(v, indexKeys);
538
+ }
539
+ }
540
+
541
+ private removeFromIndexes(v: T): void {
542
+ const indexKeys = this.indexedKeys.get(v);
543
+ if (!indexKeys) {
544
+ return;
545
+ }
546
+ this.indexedKeys.delete(v);
547
+ for (const [indexKey, keys] of indexKeys) {
548
+ const byKey = this.indexRuntime.get(indexKey)!;
549
+ for (const key of keys) {
550
+ const bucket = byKey.get(key);
551
+ if (!bucket) {
552
+ continue;
553
+ }
554
+ bucket.delete(v);
555
+ if (bucket.size === 0) {
556
+ byKey.delete(key);
557
+ }
558
+ }
559
+ }
560
+ }
561
+
562
+ /**
563
+ * full rebuild from currently loaded data; only needed right after (re)load, since every
564
+ * value then is a freshly deserialized object the incremental add/update/delete hooks never saw
565
+ */
566
+ private async rebuildIndexRuntime(): Promise<void> {
567
+ // every configured index gets an entry up front, even if empty, so getByIndex()/
568
+ // addToIndexes()/removeFromIndexes() can assume it's there instead of checking twice
569
+ this.indexRuntime = new Map(Array.from(this.indexDefinitions.keys(), (indexKey) => [indexKey, new Map()]));
570
+ this.indexedKeys = new Map();
571
+ if (this.indexDefinitions.size === 0) {
572
+ return;
573
+ }
574
+ const values = this.collectionConfig.memStorageStrategy === EBMemStorageStrategy.MAP ?
575
+ [...this.mapData.values()] : this.arrayData;
576
+ for (const v of values) {
577
+ await this.addToIndexes(v);
578
+ }
579
+ }
580
+
581
+ private resolveIDGetter(): TGetIDFunction {
582
+ if (this.collectionConfig.getID) {
583
+ const found = this.database.functionRegistry?.fetch(this.collectionConfig.getID) as TGetIDFunction | undefined;
584
+ if (!found) {
585
+ throw new Error(`getID function "${this.collectionConfig.getID}" is not registered`);
586
+ }
587
+ return found;
588
+ }
589
+ return DEFAULT_GET_ID;
590
+ }
591
+
592
+ /**
593
+ * id -> value for every stored item, resolved via the plain id getter (no auto-id assignment - every
594
+ * stored item already has one). Used to diff a transaction's working copy against the pre-transaction
595
+ * state in {@link prepareTransactionCommit}.
596
+ */
597
+ private async idKeyedSnapshot(): Promise<Map<string, T>> {
598
+ switch (this.collectionConfig.memStorageStrategy) {
599
+ case EBMemStorageStrategy.MAP:
600
+ return this.mapData;
601
+ default:
602
+ case EBMemStorageStrategy.ARRAY:
603
+ const getter = this.resolveIDGetter();
604
+ const ret = new Map<string, T>();
605
+ for (const v of this.arrayData) {
606
+ const id = normalizeID(await getter(v));
607
+ if (id) {
608
+ ret.set(id, v);
609
+ }
610
+ }
611
+ return ret;
612
+ }
613
+ }
614
+
615
+ private resolveIDSetter(): TSetIDFunction {
616
+ if (this.collectionConfig.setID) {
617
+ const found = this.database.functionRegistry?.fetch(this.collectionConfig.setID) as TSetIDFunction | undefined;
618
+ if (!found) {
619
+ throw new Error(`setID function "${this.collectionConfig.setID}" is not registered`);
620
+ }
621
+ return found;
622
+ }
623
+ return DEFAULT_SET_ID;
624
+ }
625
+
626
+ private async resolveIncomingIDs(vs: T[]): Promise<Array<string | undefined>> {
627
+ const getter = this.resolveIDGetter();
628
+ const ids: Array<string | undefined> = [];
629
+ for (const v of vs) {
630
+ const id = normalizeID(await getter(v));
631
+ if (!id && this.collectionConfig.autoIDStrategy === EBAutoIDStrategy.NO_AUTO_ID) {
632
+ throw new Error("cannot omit auto-ids and than trying to add a value without an id");
633
+ }
634
+ ids.push(id);
635
+ }
636
+ return ids;
637
+ }
638
+
639
+ private async assignMissingIDs(vs: T[], ids: Array<string | undefined>): Promise<string[]> {
640
+ let setter: TSetIDFunction | undefined;
641
+ const ret: string[] = [];
642
+ for (let i = 0; i < vs.length; i++) {
643
+ let id = ids[i];
644
+ if (!id) {
645
+ setter ??= this.resolveIDSetter();
646
+ id = uuidv7();
647
+ await setter(vs[i], id);
648
+ }
649
+ ret.push(id);
650
+ }
651
+ return ret;
652
+ }
653
+
654
+ private async storedArrayIDs(): Promise<Array<string | undefined>> {
655
+ const getter = this.resolveIDGetter();
656
+ const ids: Array<string | undefined> = [];
657
+ for (const v of this.arrayData) {
658
+ ids.push(normalizeID(await getter(v)));
659
+ }
660
+ return ids;
661
+ }
662
+
663
+ private async recordTxChanges(modificationType: EBModificationType, values: T[]): Promise<void> {
664
+ if (!this.txChanges || values.length === 0) {
665
+ return;
666
+ }
667
+ const getter = this.resolveIDGetter();
668
+ for (const v of values) {
669
+ const id = normalizeID(await getter(v));
670
+ if (id) {
671
+ this.txChanges.set(id, modificationType);
672
+ }
673
+ }
674
+ }
675
+
676
+ public async add(...vs: T[]): Promise<number> {
677
+ return this.runMutation(async () => {
678
+ await this.checkLoaded();
679
+ const trackAdds = this.addHandlers.length > 0;
680
+ const collectAdded = trackAdds || this.txChanges !== undefined;
681
+ const indexAdds = this.indexDefinitions.size > 0;
682
+ const incomingIDs = await this.resolveIncomingIDs(vs);
683
+ const added: T[] = [];
684
+ let ret = 0;
685
+ switch (this.collectionConfig.memStorageStrategy) {
686
+ case EBMemStorageStrategy.MAP: {
687
+ if (this.collectionConfig.duplicateIDStrategy === EBDuplicateIDStrategy.ERROR) {
688
+ const batchIDs = new Set<string>();
689
+ for (const id of incomingIDs) {
690
+ if (id === undefined) {
691
+ continue;
692
+ }
693
+ if (this.mapData.has(id) || batchIDs.has(id)) {
694
+ throw new Error(`duplicate id ${id}`);
695
+ }
696
+ batchIDs.add(id);
697
+ }
698
+ }
699
+ const ids = await this.assignMissingIDs(vs, incomingIDs);
700
+ const indexKeys = indexAdds ? await this.indexKeysForAll(vs) : [];
701
+ for (let i = 0; i < vs.length; i++) {
702
+ const id = ids[i]!;
703
+ const v = vs[i]!;
704
+ if (this.mapData.has(id)) {
705
+ switch (this.collectionConfig.duplicateIDStrategy) {
706
+ case EBDuplicateIDStrategy.ERROR:
707
+ throw new Error(`duplicate id ${id}`);
708
+ case EBDuplicateIDStrategy.IGNORE:
709
+ // ignore the new value
710
+ break;
711
+ default:
712
+ case EBDuplicateIDStrategy.OVERWRITE:
713
+ // replace the new value
714
+ ret++;
715
+ if (indexAdds) {
716
+ this.removeFromIndexes(this.mapData.get(id)!);
717
+ }
718
+ this.mapData.set(id, v);
719
+ if (indexAdds) {
720
+ this.addKeysToIndexes(v, indexKeys[i]!);
721
+ }
722
+ if (collectAdded) {
723
+ added.push(v);
724
+ }
725
+ break;
726
+ }
727
+ }
728
+ else {
729
+ ret++;
730
+ this.mapData.set(id, v);
731
+ if (indexAdds) {
732
+ this.addKeysToIndexes(v, indexKeys[i]!);
733
+ }
734
+ if (collectAdded) {
735
+ added.push(v);
736
+ }
737
+ }
738
+ }
739
+ break;
740
+ }
741
+ default:
742
+ case EBMemStorageStrategy.ARRAY: {
743
+ const storedIDs = await this.storedArrayIDs();
744
+ switch (this.collectionConfig.duplicateIDStrategy) {
745
+ case EBDuplicateIDStrategy.ERROR: {
746
+ const haveIDs = new Set(storedIDs.filter((id): id is string => id !== undefined));
747
+ for (const id of incomingIDs) {
748
+ if (id === undefined) {
749
+ continue;
750
+ }
751
+ if (haveIDs.has(id)) {
752
+ throw new Error(`duplicate id ${id}`);
753
+ }
754
+ haveIDs.add(id);
755
+ }
756
+ await this.assignMissingIDs(vs, incomingIDs);
757
+ const indexKeys = indexAdds ? await this.indexKeysForAll(vs) : [];
758
+ vs.forEach((v, i) => {
759
+ ret++;
760
+ this.arrayData.push(v);
761
+ if (indexAdds) {
762
+ this.addKeysToIndexes(v, indexKeys[i]!);
763
+ }
764
+ if (collectAdded) {
765
+ added.push(v);
766
+ }
767
+ });
768
+ break;
769
+ }
770
+ case EBDuplicateIDStrategy.IGNORE: {
771
+ const ids = await this.assignMissingIDs(vs, incomingIDs);
772
+ const seenIDs = new Set(storedIDs.filter((id): id is string => id !== undefined));
773
+ const toAdd: T[] = [];
774
+ ids.forEach((id, i) => {
775
+ if (!seenIDs.has(id)) {
776
+ seenIDs.add(id);
777
+ toAdd.push(vs[i]!);
778
+ }
779
+ });
780
+ const indexKeys = indexAdds ? await this.indexKeysForAll(toAdd) : [];
781
+ toAdd.forEach((v, i) => {
782
+ ret++;
783
+ this.arrayData.push(v);
784
+ if (indexAdds) {
785
+ this.addKeysToIndexes(v, indexKeys[i]!);
786
+ }
787
+ if (collectAdded) {
788
+ added.push(v);
789
+ }
790
+ });
791
+ break;
792
+ }
793
+ default:
794
+ case EBDuplicateIDStrategy.OVERWRITE: {
795
+ const ids = await this.assignMissingIDs(vs, incomingIDs);
796
+ const indexKeys = indexAdds ? await this.indexKeysForAll(vs) : [];
797
+ const positions = new Map<string, number>();
798
+ storedIDs.forEach((id, i) => {
799
+ if (id !== undefined) {
800
+ positions.set(id, i);
801
+ }
802
+ });
803
+ vs.forEach((v, i) => {
804
+ const id = ids[i]!;
805
+ const existingIndex = positions.get(id);
806
+ ret++;
807
+ if (existingIndex !== undefined) {
808
+ if (indexAdds) {
809
+ this.removeFromIndexes(this.arrayData[existingIndex]!);
810
+ }
811
+ this.arrayData[existingIndex] = v;
812
+ }
813
+ else {
814
+ positions.set(id, this.arrayData.length);
815
+ this.arrayData.push(v);
816
+ }
817
+ if (indexAdds) {
818
+ this.addKeysToIndexes(v, indexKeys[i]!);
819
+ }
820
+ if (collectAdded) {
821
+ added.push(v);
822
+ }
823
+ });
824
+ break;
825
+ }
826
+ }
827
+ break;
828
+ }
829
+ }
830
+ if (ret > 0) {
831
+ await this.doOnDirty();
832
+ }
833
+ await this.recordTxChanges(EBModificationType.ADD, added);
834
+ if (trackAdds && added.length > 0) {
835
+ await this.fireAdd(added);
836
+ }
837
+ return ret;
838
+ });
839
+ }
840
+
841
+ public async update(predicate: (v: T) => boolean, updater: (v: T) => Promise<T>): Promise<number> {
842
+ return this.runMutation(async () => {
843
+ await this.checkLoaded();
844
+ const trackUpdates = this.updateHandlers.length > 0;
845
+ const indexUpdates = this.indexDefinitions.size > 0;
846
+ const planned: Array<{
847
+ previous: T;
848
+ replacement: T;
849
+ replacementKeys: Array<[string, string[]]>;
850
+ store: (replacement: T) => void;
851
+ }> = [];
852
+ const plan = async (previous: T, store: (replacement: T) => void): Promise<void> => {
853
+ const replacement = await updater(previous);
854
+ if (replacement === undefined) {
855
+ throw new Error("updater gave undefined");
856
+ }
857
+ const replacementKeys = indexUpdates ? await this.indexKeysFor(replacement) : [];
858
+ planned.push({ previous, replacement, replacementKeys, store });
859
+ };
860
+ switch (this.collectionConfig.memStorageStrategy) {
861
+ case EBMemStorageStrategy.MAP:
862
+ for (const [key, value] of this.mapData) {
863
+ if (predicate(value)) {
864
+ await plan(value, (replacement) => { this.mapData.set(key, replacement); });
865
+ }
866
+ }
867
+ break;
868
+ default:
869
+ case EBMemStorageStrategy.ARRAY:
870
+ for (let i = 0; i < this.arrayData.length; i++) {
871
+ const previous = this.arrayData[i]!;
872
+ if (predicate(previous)) {
873
+ await plan(previous, (replacement) => { this.arrayData[i] = replacement; });
874
+ }
875
+ }
876
+ break;
877
+ }
878
+ for (const { previous, replacement, replacementKeys, store } of planned) {
879
+ if (indexUpdates) {
880
+ this.removeFromIndexes(previous);
881
+ }
882
+ store(replacement);
883
+ if (indexUpdates) {
884
+ this.addKeysToIndexes(replacement, replacementKeys);
885
+ }
886
+ }
887
+ if (planned.length > 0) {
888
+ await this.doOnDirty();
889
+ }
890
+ await this.recordTxChanges(EBModificationType.UPDATE, planned.map(({ replacement }) => replacement));
891
+ if (trackUpdates && planned.length > 0) {
892
+ await this.fireUpdate(planned.map(({ previous, replacement }) => [previous, replacement]));
893
+ }
894
+ return planned.length;
895
+ });
896
+ }
897
+
898
+ private async doOnDirty(): Promise<void> {
899
+ this.dirty = true;
900
+ if (this.collectionConfig.autoFlushStrategy == EBAutoFlushStrategy.ALWAYS_AUTO_FLUSH) {
901
+ await this.flush();
902
+ }
903
+ }
904
+
905
+ public async flush(): Promise<void> {
906
+ return this.runProtected(async () => {
907
+ if (this.loaded) {
908
+ switch (this.collectionConfig.memStorageStrategy) {
909
+ case EBMemStorageStrategy.MAP:
910
+ await this.database.io.writeMapData(this.collectionID, this.mapData);
911
+ break;
912
+ default:
913
+ case EBMemStorageStrategy.ARRAY:
914
+ await this.database.io.writeArrayData(this.collectionID, this.arrayData);
915
+ break;
916
+ }
917
+ this.dirty = false;
918
+ }
919
+ });
920
+ }
921
+
922
+ public async reload(): Promise<void> {
923
+ return this.runProtected(async () => {
924
+ switch (this.collectionConfig.memStorageStrategy) {
925
+ case EBMemStorageStrategy.MAP:
926
+ this.mapData = await this.database.io.loadMapData<T>(this.collectionID);
927
+ break;
928
+ default:
929
+ case EBMemStorageStrategy.ARRAY:
930
+ this.arrayData = await this.database.io.loadArrayData<T>(this.collectionID);
931
+ break;
932
+ }
933
+ this.resolveIndexDefinitions();
934
+ await this.rebuildIndexRuntime();
935
+ this.loaded = true;
936
+ this.dirty = false;
937
+ });
938
+ }
939
+
940
+ public async unload(): Promise<void> {
941
+ return this.runProtected(async () => {
942
+ if (this.dirty) {
943
+ switch (this.collectionConfig.dirtyUnloadStrategy) {
944
+ case EBDirtyUnloadStrategy.FLUSH:
945
+ await this.flush();
946
+ break;
947
+ case EBDirtyUnloadStrategy.ERROR:
948
+ throw new Error("cannot unload unwritten collection");
949
+ default:
950
+ case EBDirtyUnloadStrategy.IGNORE:
951
+ // do nothing
952
+ break;
953
+
954
+ }
955
+ }
956
+ switch (this.collectionConfig.memStorageStrategy) {
957
+ case EBMemStorageStrategy.MAP:
958
+ this.mapData = new Map();
959
+ break;
960
+ default:
961
+ case EBMemStorageStrategy.ARRAY:
962
+ this.arrayData = [];
963
+ break;
964
+ }
965
+ this.indexRuntime.clear();
966
+ this.indexedKeys.clear();
967
+ this.loaded = false;
968
+ this.dirty = false;
969
+ });
970
+ }
971
+
972
+ /**
973
+ * Creates a transaction-scoped working copy: same identity/config as this collection, but with
974
+ * independently mutable in-memory state (auto-flush forced off, regardless of this collection's own
975
+ * `autoFlushStrategy`) and no change-event handlers of its own. Call only while already holding this
976
+ * collection's mutex for the whole transaction (see {@link BDatabase.transaction}).
977
+ */
978
+ public async cloneForTransaction(): Promise<BCollection<T>> {
979
+ return this.runProtected(async () => {
980
+ await this.checkLoaded();
981
+ const txConfig: IBCollectionConfig = { ...this.collectionConfig, autoFlushStrategy: EBAutoFlushStrategy.NO_AUTO_FLUSH };
982
+ const clone = new BCollection<T>(this.database, this.collectionID, this.collectionName, txConfig);
983
+ clone.mapData = new Map(this.mapData);
984
+ clone.arrayData = this.arrayData.slice();
985
+ clone.loaded = true;
986
+ clone.transactionClone = true;
987
+ if (this.addHandlers.length > 0 || this.updateHandlers.length > 0 || this.deleteHandlers.length > 0) {
988
+ clone.txChanges = new Map();
989
+ }
990
+ clone.resolveIndexDefinitions();
991
+ await clone.rebuildIndexRuntime();
992
+ return clone;
993
+ });
994
+ }
995
+
996
+ /**
997
+ * Writes `clone`'s state to a temp file (see {@link IPreparedWrite}) and computes the handler events for the
998
+ * net changes against this collection's current state: one per id the transaction touched, typed by the last
999
+ * operation on it (`DELETE` if gone, `ADD` if new or last stored via `add()`, `UPDATE` otherwise). Nothing is
1000
+ * applied until `apply()`; call only while already holding this collection's mutex for the whole transaction.
1001
+ */
1002
+ public async prepareTransactionCommit(clone: BCollection<T>): Promise<IPreparedTransactionCommit> {
1003
+ return this.runProtected(async () => {
1004
+ await this.checkLoaded();
1005
+ const added: T[] = [];
1006
+ const updated: Array<[T, T]> = [];
1007
+ const deleted: T[] = [];
1008
+ if (clone.txChanges && clone.txChanges.size > 0) {
1009
+ const before = await this.idKeyedSnapshot();
1010
+ const after = await clone.idKeyedSnapshot();
1011
+ for (const [id, modificationType] of clone.txChanges) {
1012
+ if (!after.has(id)) {
1013
+ if (before.has(id)) {
1014
+ deleted.push(before.get(id)!);
1015
+ }
1016
+ }
1017
+ else if (!before.has(id) || modificationType === EBModificationType.ADD) {
1018
+ added.push(after.get(id)!);
1019
+ }
1020
+ else {
1021
+ updated.push([before.get(id)!, after.get(id)!]);
1022
+ }
1023
+ }
1024
+ }
1025
+ let write: IPreparedWrite;
1026
+ switch (this.collectionConfig.memStorageStrategy) {
1027
+ case EBMemStorageStrategy.MAP:
1028
+ write = await this.database.io.prepareMapData(this.collectionID, clone.mapData);
1029
+ break;
1030
+ default:
1031
+ case EBMemStorageStrategy.ARRAY:
1032
+ write = await this.database.io.prepareArrayData(this.collectionID, clone.arrayData);
1033
+ break;
1034
+ }
1035
+ return {
1036
+ write,
1037
+ apply: () => {
1038
+ this.mapData = clone.mapData;
1039
+ this.arrayData = clone.arrayData;
1040
+ this.indexRuntime = clone.indexRuntime;
1041
+ this.indexedKeys = clone.indexedKeys;
1042
+ this.dirty = false;
1043
+ },
1044
+ fire: () => this.runProtected(async () => {
1045
+ if (added.length > 0) {
1046
+ await this.fireAdd(added);
1047
+ }
1048
+ if (updated.length > 0) {
1049
+ await this.fireUpdate(updated);
1050
+ }
1051
+ if (deleted.length > 0) {
1052
+ await this.fireDelete(deleted);
1053
+ }
1054
+ }),
1055
+ };
1056
+ });
1057
+ }
1058
+
913
1059
  }