@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.
package/dist/basics.js CHANGED
@@ -1,17 +1,15 @@
1
- import logger from "winston";
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import { uuidv7, } from "uuidv7";
3
- import { runMultiReentrantProtected, runReentrantProtected } from "@incoqnito.io/ts-iq-core";
3
+ import { runReentrantProtected } from "@incoqnito.io/ts-iq-core";
4
4
  import { EBArrayDeletionStrategy, EBAutoFlushStrategy, EBAutoIDStrategy, EBDirtyUnloadStrategy, EBDuplicateIDStrategy, EBHandlerMode, EBMemStorageStrategy, EBModificationType, } from "./base.js";
5
5
  import { canonicalName } from "./utils.js";
6
6
  /** Default {@link TGetIDFunction}: reads `v.id`, `undefined` if absent. */
7
- export const DEFAULT_GET_ID = (v) => ("id" in v) ? v.id : undefined;
7
+ export const DEFAULT_GET_ID = (v) => (v !== null && typeof v === 'object' && "id" in v) ? v.id : undefined;
8
8
  /** Default {@link TSetIDFunction}: assigns to `v.id`. */
9
9
  export const DEFAULT_SET_ID = async (v, id) => { v.id = id; };
10
- var MutexKeys;
11
- (function (MutexKeys) {
12
- // because of the spaces this will never be a collection name and as such is safe to use as a mutex key
13
- MutexKeys["DATABASE"] = " database ";
14
- })(MutexKeys || (MutexKeys = {}));
10
+ function normalizeID(id) {
11
+ return id === undefined || id === null || id === "" ? undefined : String(id);
12
+ }
15
13
  /** {@link IBCollectionConfig} used when a collection is created without an explicit one. */
16
14
  export const DEFAULT_COLLECTION_CONFIG = {
17
15
  arrayDeletionStrategy: EBArrayDeletionStrategy.IN_PLACE,
@@ -30,16 +28,24 @@ export class BDatabase {
30
28
  collections = new Map();
31
29
  io;
32
30
  functionRegistry;
31
+ iid = uuidv7();
32
+ transactionScope = new AsyncLocalStorage();
33
33
  constructor(io, config, functionRegistry) {
34
- this.config = config ?? DEFAULT_DATABASE_CONFIG;
34
+ this.config = { ...(config ?? DEFAULT_DATABASE_CONFIG) };
35
35
  this.io = io;
36
36
  this.functionRegistry = functionRegistry;
37
37
  }
38
+ async runExclusive(fn) {
39
+ return runReentrantProtected(this.iid, fn);
40
+ }
41
+ isInTransaction(collectionName) {
42
+ return this.transactionScope.getStore()?.has(collectionName) ?? false;
43
+ }
38
44
  addCollection(collection) {
39
45
  this.collections.set(collection.collectionName, collection);
40
46
  }
41
47
  async getCollection(collectionName, collectionConfig) {
42
- return runReentrantProtected(MutexKeys.DATABASE, async () => {
48
+ return this.runExclusive(async () => {
43
49
  const cname = canonicalName(collectionName);
44
50
  let ret = this.collections.get(cname);
45
51
  if (!ret) {
@@ -49,25 +55,26 @@ export class BDatabase {
49
55
  });
50
56
  }
51
57
  async createCollection(collectionName, collectionConfig) {
52
- return runReentrantProtected(MutexKeys.DATABASE, async () => {
58
+ return this.runExclusive(async () => {
53
59
  const cname = canonicalName(collectionName);
54
60
  if (this.collections.has(cname)) {
55
61
  throw new Error(`cannot create already existing collection ${collectionName}`);
56
62
  }
57
63
  const id = uuidv7();
58
- const ret = new BCollection(this, id, collectionName, collectionConfig ?? this.config.defaultCollectionConfig);
64
+ const ret = new BCollection(this, id, collectionName, { ...(collectionConfig ?? this.config.defaultCollectionConfig) });
65
+ await this.io.createCollection(ret);
59
66
  this.addCollection(ret);
60
- return this.io.createCollection(ret);
67
+ return ret;
61
68
  });
62
69
  }
63
70
  async hasCollection(collectionName) {
64
- return runReentrantProtected(MutexKeys.DATABASE, async () => {
71
+ return this.runExclusive(async () => {
65
72
  return this.collections.has(canonicalName(collectionName));
66
73
  });
67
74
  }
68
75
  async dropCollection(collectionName) {
69
76
  const cname = canonicalName(collectionName);
70
- return runMultiReentrantProtected([MutexKeys.DATABASE, cname], async () => {
77
+ return this.runExclusive(async () => {
71
78
  const coll = this.collections.get(cname);
72
79
  if (coll) {
73
80
  coll.markDropped();
@@ -80,8 +87,7 @@ export class BDatabase {
80
87
  });
81
88
  }
82
89
  async snapshot(targetDir, options) {
83
- const collectionNames = Array.from(this.collections.keys());
84
- return runMultiReentrantProtected([MutexKeys.DATABASE, ...collectionNames], async () => {
90
+ return this.runExclusive(async () => {
85
91
  for (const collection of this.collections.values()) {
86
92
  await collection.flush();
87
93
  }
@@ -89,13 +95,21 @@ export class BDatabase {
89
95
  });
90
96
  }
91
97
  async transaction(collectionNames, fn) {
98
+ if (this.transactionScope.getStore() !== undefined) {
99
+ throw new Error("nested transactions are not supported");
100
+ }
92
101
  const canonicalNames = Array.from(new Set(collectionNames.map(canonicalName)));
93
- return runMultiReentrantProtected(canonicalNames, async () => {
102
+ return this.runExclusive(async () => {
94
103
  const originals = new Map();
95
104
  const clones = new Map();
96
105
  for (const name of canonicalNames) {
97
- const original = await this.getCollection(name);
106
+ const original = this.collections.get(name);
107
+ if (!original) {
108
+ throw new Error(`no such collection ${name}`);
109
+ }
98
110
  originals.set(name, original);
111
+ }
112
+ for (const [name, original] of originals) {
99
113
  clones.set(name, await original.cloneForTransaction());
100
114
  }
101
115
  const tx = {
@@ -107,9 +121,36 @@ export class BDatabase {
107
121
  return clone;
108
122
  },
109
123
  };
110
- await fn(tx);
111
- for (const name of canonicalNames) {
112
- await originals.get(name).commitTransactionClone(clones.get(name));
124
+ try {
125
+ await this.transactionScope.run(new Set(canonicalNames), () => fn(tx));
126
+ const prepared = [];
127
+ try {
128
+ for (const name of canonicalNames) {
129
+ prepared.push(await originals.get(name).prepareTransactionCommit(clones.get(name)));
130
+ }
131
+ }
132
+ catch (err) {
133
+ await Promise.all(prepared.map(p => p.write.discard()));
134
+ throw err;
135
+ }
136
+ let renamed = 0;
137
+ try {
138
+ for (const p of prepared) {
139
+ await p.write.commit();
140
+ renamed++;
141
+ }
142
+ }
143
+ finally {
144
+ await Promise.all(prepared.slice(renamed).map(p => p.write.discard()));
145
+ const applied = prepared.slice(0, renamed);
146
+ applied.forEach(p => p.apply());
147
+ for (const p of applied) {
148
+ await p.fire();
149
+ }
150
+ }
151
+ }
152
+ finally {
153
+ clones.forEach(clone => clone.markDropped());
113
154
  }
114
155
  });
115
156
  }
@@ -124,26 +165,19 @@ export class BCollection {
124
165
  loaded = false;
125
166
  dirty = false;
126
167
  dropped = false;
168
+ transactionClone = false;
127
169
  addHandlers = [];
128
170
  updateHandlers = [];
129
171
  deleteHandlers = [];
130
172
  indexDefinitions = new Map();
131
173
  indexRuntime = new Map();
174
+ indexedKeys = new Map();
175
+ txChanges;
132
176
  constructor(database, collectionID, collectionName, collectionConfig) {
133
177
  this.database = database;
134
178
  this.collectionID = collectionID;
135
179
  this.collectionName = canonicalName(collectionName);
136
180
  this.collectionConfig = collectionConfig;
137
- switch (collectionConfig.memStorageStrategy) {
138
- case EBMemStorageStrategy.MAP:
139
- // if (!collectionConfig.getID || !collectionConfig.setID) {
140
- // throw new Error("need id accessors configured when using map mem storage");
141
- // }
142
- break;
143
- default:
144
- case EBMemStorageStrategy.ARRAY:
145
- break;
146
- }
147
181
  }
148
182
  async checkLoaded() {
149
183
  if (!this.loaded) {
@@ -151,7 +185,7 @@ export class BCollection {
151
185
  }
152
186
  }
153
187
  async runExclusive(fn) {
154
- return runReentrantProtected(this.collectionName, fn);
188
+ return this.database.runExclusive(fn);
155
189
  }
156
190
  async runProtected(fn) {
157
191
  return this.runExclusive(async () => {
@@ -161,6 +195,14 @@ export class BCollection {
161
195
  return fn();
162
196
  });
163
197
  }
198
+ async runMutation(fn) {
199
+ return this.runProtected(async () => {
200
+ if (!this.transactionClone && this.database.isInTransaction(this.collectionName)) {
201
+ throw new Error(`collection "${this.collectionName}" is part of a running transaction; write through tx.getCollection() instead`);
202
+ }
203
+ return fn();
204
+ });
205
+ }
164
206
  /**
165
207
  * call this only within the protecting mutex
166
208
  */
@@ -206,7 +248,7 @@ export class BCollection {
206
248
  if (!byKey) {
207
249
  throw new Error(`index "${indexKey}" is not configured`);
208
250
  }
209
- return byKey.get(key)?.slice() ?? [];
251
+ return Array.from(byKey.get(key) ?? []);
210
252
  });
211
253
  }
212
254
  async invokeHandler(fn, modificationType) {
@@ -214,7 +256,8 @@ export class BCollection {
214
256
  await fn();
215
257
  }
216
258
  catch (error) {
217
- logger.error(`bajadab: collection "${this.collectionName}" ${modificationType} handler failed`, error);
259
+ // TODO switch logging to ILogging where necessary
260
+ console.error(`bajadab: collection "${this.collectionName}" ${modificationType} handler failed`, error);
218
261
  }
219
262
  }
220
263
  async fireAdd(added) {
@@ -271,10 +314,11 @@ export class BCollection {
271
314
  });
272
315
  }
273
316
  async delete(predicate) {
274
- return this.runProtected(async () => {
317
+ return this.runMutation(async () => {
275
318
  await this.checkLoaded();
276
319
  let ret = 0;
277
320
  const trackDeletes = this.deleteHandlers.length > 0;
321
+ const collectDeleted = trackDeletes || this.txChanges !== undefined;
278
322
  const indexDeletes = this.indexDefinitions.size > 0;
279
323
  const deleted = [];
280
324
  switch (this.collectionConfig.memStorageStrategy) {
@@ -283,10 +327,10 @@ export class BCollection {
283
327
  if (predicate(value)) {
284
328
  ret++;
285
329
  if (indexDeletes) {
286
- await this.removeFromIndexes(value);
330
+ this.removeFromIndexes(value);
287
331
  }
288
332
  this.mapData.delete(key);
289
- if (trackDeletes) {
333
+ if (collectDeleted) {
290
334
  deleted.push(value);
291
335
  }
292
336
  }
@@ -306,9 +350,9 @@ export class BCollection {
306
350
  else {
307
351
  ret++;
308
352
  if (indexDeletes) {
309
- await this.removeFromIndexes(item);
353
+ this.removeFromIndexes(item);
310
354
  }
311
- if (trackDeletes) {
355
+ if (collectDeleted) {
312
356
  deleted.push(item);
313
357
  }
314
358
  }
@@ -317,20 +361,29 @@ export class BCollection {
317
361
  break;
318
362
  default:
319
363
  case EBArrayDeletionStrategy.NEW_ARRAY:
320
- const ol = this.arrayData.length;
321
- if (trackDeletes || indexDeletes) {
322
- const removed = this.arrayData.filter(predicate);
323
- if (trackDeletes) {
324
- deleted.push(...removed);
325
- }
326
- if (indexDeletes) {
327
- for (const v of removed) {
328
- await this.removeFromIndexes(v);
364
+ if (collectDeleted || indexDeletes) {
365
+ const kept = [];
366
+ for (const v of this.arrayData) {
367
+ if (predicate(v)) {
368
+ ret++;
369
+ if (indexDeletes) {
370
+ this.removeFromIndexes(v);
371
+ }
372
+ if (collectDeleted) {
373
+ deleted.push(v);
374
+ }
375
+ }
376
+ else {
377
+ kept.push(v);
329
378
  }
330
379
  }
380
+ this.arrayData = kept;
381
+ }
382
+ else {
383
+ const ol = this.arrayData.length;
384
+ this.arrayData = this.arrayData.filter(v => !predicate(v));
385
+ ret += (ol - this.arrayData.length);
331
386
  }
332
- this.arrayData = this.arrayData.filter(v => !predicate(v));
333
- ret += (ol - this.arrayData.length);
334
387
  break;
335
388
  }
336
389
  break;
@@ -338,7 +391,8 @@ export class BCollection {
338
391
  if (ret > 0) {
339
392
  await this.doOnDirty();
340
393
  }
341
- if (deleted.length > 0) {
394
+ await this.recordTxChanges(EBModificationType.DELETE, deleted);
395
+ if (trackDeletes && deleted.length > 0) {
342
396
  await this.fireDelete(deleted);
343
397
  }
344
398
  return ret;
@@ -349,10 +403,10 @@ export class BCollection {
349
403
  await this.checkLoaded();
350
404
  switch (this.collectionConfig.memStorageStrategy) {
351
405
  case EBMemStorageStrategy.MAP:
352
- return this.mapData.values().find(predicate) !== undefined;
406
+ return this.mapData.values().some(predicate);
353
407
  default:
354
408
  case EBMemStorageStrategy.ARRAY:
355
- return this.arrayData.find(predicate) !== undefined;
409
+ return this.arrayData.some(predicate);
356
410
  }
357
411
  });
358
412
  }
@@ -381,31 +435,46 @@ export class BCollection {
381
435
  return result;
382
436
  }
383
437
  async addToIndexes(v) {
384
- for (const [indexKey, keys] of await this.indexKeysFor(v)) {
438
+ this.addKeysToIndexes(v, await this.indexKeysFor(v));
439
+ }
440
+ async indexKeysForAll(vs) {
441
+ const ret = [];
442
+ for (const v of vs) {
443
+ ret.push(await this.indexKeysFor(v));
444
+ }
445
+ return ret;
446
+ }
447
+ addKeysToIndexes(v, indexKeys) {
448
+ for (const [indexKey, keys] of indexKeys) {
385
449
  const byKey = this.indexRuntime.get(indexKey);
386
450
  for (const key of keys) {
387
451
  let bucket = byKey.get(key);
388
452
  if (!bucket) {
389
- bucket = [];
453
+ bucket = new Set();
390
454
  byKey.set(key, bucket);
391
455
  }
392
- bucket.push(v);
456
+ bucket.add(v);
393
457
  }
394
458
  }
459
+ if (indexKeys.length > 0) {
460
+ this.indexedKeys.set(v, indexKeys);
461
+ }
395
462
  }
396
- async removeFromIndexes(v) {
397
- for (const [indexKey, keys] of await this.indexKeysFor(v)) {
463
+ removeFromIndexes(v) {
464
+ const indexKeys = this.indexedKeys.get(v);
465
+ if (!indexKeys) {
466
+ return;
467
+ }
468
+ this.indexedKeys.delete(v);
469
+ for (const [indexKey, keys] of indexKeys) {
398
470
  const byKey = this.indexRuntime.get(indexKey);
399
471
  for (const key of keys) {
400
472
  const bucket = byKey.get(key);
401
473
  if (!bucket) {
402
474
  continue;
403
475
  }
404
- const at = bucket.indexOf(v);
405
- if (at !== -1) {
406
- bucket.splice(at, 1);
407
- }
408
- if (bucket.length === 0) {
476
+ bucket.delete(v);
477
+ if (bucket.size === 0) {
409
478
  byKey.delete(key);
410
479
  }
411
480
  }
@@ -419,6 +488,7 @@ export class BCollection {
419
488
  // every configured index gets an entry up front, even if empty, so getByIndex()/
420
489
  // addToIndexes()/removeFromIndexes() can assume it's there instead of checking twice
421
490
  this.indexRuntime = new Map(Array.from(this.indexDefinitions.keys(), (indexKey) => [indexKey, new Map()]));
491
+ this.indexedKeys = new Map();
422
492
  if (this.indexDefinitions.size === 0) {
423
493
  return;
424
494
  }
@@ -441,18 +511,18 @@ export class BCollection {
441
511
  /**
442
512
  * id -> value for every stored item, resolved via the plain id getter (no auto-id assignment - every
443
513
  * stored item already has one). Used to diff a transaction's working copy against the pre-transaction
444
- * state in {@link commitTransactionClone}.
514
+ * state in {@link prepareTransactionCommit}.
445
515
  */
446
516
  async idKeyedSnapshot() {
447
517
  switch (this.collectionConfig.memStorageStrategy) {
448
518
  case EBMemStorageStrategy.MAP:
449
- return new Map(this.mapData);
519
+ return this.mapData;
450
520
  default:
451
521
  case EBMemStorageStrategy.ARRAY:
452
522
  const getter = this.resolveIDGetter();
453
523
  const ret = new Map();
454
524
  for (const v of this.arrayData) {
455
- const id = await getter(v);
525
+ const id = normalizeID(await getter(v));
456
526
  if (id) {
457
527
  ret.set(id, v);
458
528
  }
@@ -460,43 +530,90 @@ export class BCollection {
460
530
  return ret;
461
531
  }
462
532
  }
463
- async getID(v) {
464
- let id = await this.resolveIDGetter()(v);
465
- if (!id) {
466
- switch (this.collectionConfig.autoIDStrategy) {
467
- case EBAutoIDStrategy.NO_AUTO_ID:
468
- throw new Error("cannot omit auto-ids and than trying to add a value without an id");
469
- default:
470
- case EBAutoIDStrategy.AUTO_ID_UUID:
471
- id = uuidv7();
472
- let setter;
473
- if (this.collectionConfig.setID) {
474
- const found = this.database.functionRegistry?.fetch(this.collectionConfig.setID);
475
- if (!found) {
476
- throw new Error(`setID function "${this.collectionConfig.setID}" is not registered`);
477
- }
478
- setter = found;
479
- }
480
- else {
481
- setter = DEFAULT_SET_ID;
482
- }
483
- await setter(v, id);
484
- break;
533
+ resolveIDSetter() {
534
+ if (this.collectionConfig.setID) {
535
+ const found = this.database.functionRegistry?.fetch(this.collectionConfig.setID);
536
+ if (!found) {
537
+ throw new Error(`setID function "${this.collectionConfig.setID}" is not registered`);
538
+ }
539
+ return found;
540
+ }
541
+ return DEFAULT_SET_ID;
542
+ }
543
+ async resolveIncomingIDs(vs) {
544
+ const getter = this.resolveIDGetter();
545
+ const ids = [];
546
+ for (const v of vs) {
547
+ const id = normalizeID(await getter(v));
548
+ if (!id && this.collectionConfig.autoIDStrategy === EBAutoIDStrategy.NO_AUTO_ID) {
549
+ throw new Error("cannot omit auto-ids and than trying to add a value without an id");
550
+ }
551
+ ids.push(id);
552
+ }
553
+ return ids;
554
+ }
555
+ async assignMissingIDs(vs, ids) {
556
+ let setter;
557
+ const ret = [];
558
+ for (let i = 0; i < vs.length; i++) {
559
+ let id = ids[i];
560
+ if (!id) {
561
+ setter ??= this.resolveIDSetter();
562
+ id = uuidv7();
563
+ await setter(vs[i], id);
564
+ }
565
+ ret.push(id);
566
+ }
567
+ return ret;
568
+ }
569
+ async storedArrayIDs() {
570
+ const getter = this.resolveIDGetter();
571
+ const ids = [];
572
+ for (const v of this.arrayData) {
573
+ ids.push(normalizeID(await getter(v)));
574
+ }
575
+ return ids;
576
+ }
577
+ async recordTxChanges(modificationType, values) {
578
+ if (!this.txChanges || values.length === 0) {
579
+ return;
580
+ }
581
+ const getter = this.resolveIDGetter();
582
+ for (const v of values) {
583
+ const id = normalizeID(await getter(v));
584
+ if (id) {
585
+ this.txChanges.set(id, modificationType);
485
586
  }
486
587
  }
487
- return id;
488
588
  }
489
589
  async add(...vs) {
490
- return this.runProtected(async () => {
590
+ return this.runMutation(async () => {
491
591
  await this.checkLoaded();
592
+ const trackAdds = this.addHandlers.length > 0;
593
+ const collectAdded = trackAdds || this.txChanges !== undefined;
594
+ const indexAdds = this.indexDefinitions.size > 0;
595
+ const incomingIDs = await this.resolveIncomingIDs(vs);
596
+ const added = [];
597
+ let ret = 0;
492
598
  switch (this.collectionConfig.memStorageStrategy) {
493
- case EBMemStorageStrategy.MAP:
494
- let ret = 0;
495
- const trackAddsMap = this.addHandlers.length > 0;
496
- const indexAddsMap = this.indexDefinitions.size > 0;
497
- const addedMap = [];
498
- for (const v of vs) {
499
- const id = await this.getID(v);
599
+ case EBMemStorageStrategy.MAP: {
600
+ if (this.collectionConfig.duplicateIDStrategy === EBDuplicateIDStrategy.ERROR) {
601
+ const batchIDs = new Set();
602
+ for (const id of incomingIDs) {
603
+ if (id === undefined) {
604
+ continue;
605
+ }
606
+ if (this.mapData.has(id) || batchIDs.has(id)) {
607
+ throw new Error(`duplicate id ${id}`);
608
+ }
609
+ batchIDs.add(id);
610
+ }
611
+ }
612
+ const ids = await this.assignMissingIDs(vs, incomingIDs);
613
+ const indexKeys = indexAdds ? await this.indexKeysForAll(vs) : [];
614
+ for (let i = 0; i < vs.length; i++) {
615
+ const id = ids[i];
616
+ const v = vs[i];
500
617
  if (this.mapData.has(id)) {
501
618
  switch (this.collectionConfig.duplicateIDStrategy) {
502
619
  case EBDuplicateIDStrategy.ERROR:
@@ -508,15 +625,15 @@ export class BCollection {
508
625
  case EBDuplicateIDStrategy.OVERWRITE:
509
626
  // replace the new value
510
627
  ret++;
511
- if (indexAddsMap) {
512
- await this.removeFromIndexes(this.mapData.get(id));
628
+ if (indexAdds) {
629
+ this.removeFromIndexes(this.mapData.get(id));
513
630
  }
514
631
  this.mapData.set(id, v);
515
- if (indexAddsMap) {
516
- await this.addToIndexes(v);
632
+ if (indexAdds) {
633
+ this.addKeysToIndexes(v, indexKeys[i]);
517
634
  }
518
- if (trackAddsMap) {
519
- addedMap.push(v);
635
+ if (collectAdded) {
636
+ added.push(v);
520
637
  }
521
638
  break;
522
639
  }
@@ -524,169 +641,164 @@ export class BCollection {
524
641
  else {
525
642
  ret++;
526
643
  this.mapData.set(id, v);
527
- if (indexAddsMap) {
528
- await this.addToIndexes(v);
644
+ if (indexAdds) {
645
+ this.addKeysToIndexes(v, indexKeys[i]);
529
646
  }
530
- if (trackAddsMap) {
531
- addedMap.push(v);
647
+ if (collectAdded) {
648
+ added.push(v);
532
649
  }
533
650
  }
534
651
  }
535
- if (ret > 0) {
536
- await this.doOnDirty();
537
- }
538
- if (addedMap.length > 0) {
539
- await this.fireAdd(addedMap);
540
- }
541
- return ret;
652
+ break;
653
+ }
542
654
  default:
543
- case EBMemStorageStrategy.ARRAY:
544
- const trackAddsArray = this.addHandlers.length > 0;
545
- const indexAddsArray = this.indexDefinitions.size > 0;
546
- const addedArray = [];
655
+ case EBMemStorageStrategy.ARRAY: {
656
+ const storedIDs = await this.storedArrayIDs();
547
657
  switch (this.collectionConfig.duplicateIDStrategy) {
548
- case EBDuplicateIDStrategy.ERROR:
549
- const haveIDs = new Set();
550
- for (const v of this.arrayData) {
551
- haveIDs.add(await this.getID(v));
552
- }
553
- for (const v of vs) {
554
- const id = await this.getID(v);
658
+ case EBDuplicateIDStrategy.ERROR: {
659
+ const haveIDs = new Set(storedIDs.filter((id) => id !== undefined));
660
+ for (const id of incomingIDs) {
661
+ if (id === undefined) {
662
+ continue;
663
+ }
555
664
  if (haveIDs.has(id)) {
556
665
  throw new Error(`duplicate id ${id}`);
557
666
  }
558
667
  haveIDs.add(id);
668
+ }
669
+ await this.assignMissingIDs(vs, incomingIDs);
670
+ const indexKeys = indexAdds ? await this.indexKeysForAll(vs) : [];
671
+ vs.forEach((v, i) => {
672
+ ret++;
559
673
  this.arrayData.push(v);
560
- if (indexAddsArray) {
561
- await this.addToIndexes(v);
674
+ if (indexAdds) {
675
+ this.addKeysToIndexes(v, indexKeys[i]);
562
676
  }
563
- if (trackAddsArray) {
564
- addedArray.push(v);
677
+ if (collectAdded) {
678
+ added.push(v);
565
679
  }
566
- }
680
+ });
567
681
  break;
568
- case EBDuplicateIDStrategy.IGNORE:
569
- const seenIDs = new Set();
570
- for (const v of this.arrayData) {
571
- seenIDs.add(await this.getID(v));
572
- }
573
- for (const v of vs) {
574
- const id = await this.getID(v);
682
+ }
683
+ case EBDuplicateIDStrategy.IGNORE: {
684
+ const ids = await this.assignMissingIDs(vs, incomingIDs);
685
+ const seenIDs = new Set(storedIDs.filter((id) => id !== undefined));
686
+ const toAdd = [];
687
+ ids.forEach((id, i) => {
575
688
  if (!seenIDs.has(id)) {
576
689
  seenIDs.add(id);
577
- this.arrayData.push(v);
578
- if (indexAddsArray) {
579
- await this.addToIndexes(v);
580
- }
581
- if (trackAddsArray) {
582
- addedArray.push(v);
583
- }
690
+ toAdd.push(vs[i]);
584
691
  }
585
- }
692
+ });
693
+ const indexKeys = indexAdds ? await this.indexKeysForAll(toAdd) : [];
694
+ toAdd.forEach((v, i) => {
695
+ ret++;
696
+ this.arrayData.push(v);
697
+ if (indexAdds) {
698
+ this.addKeysToIndexes(v, indexKeys[i]);
699
+ }
700
+ if (collectAdded) {
701
+ added.push(v);
702
+ }
703
+ });
586
704
  break;
705
+ }
587
706
  default:
588
- case EBDuplicateIDStrategy.OVERWRITE:
589
- const indexMap = new Map();
590
- for (let i = 0; i < this.arrayData.length; i++) {
591
- indexMap.set(await this.getID(this.arrayData[i]), i);
592
- }
593
- for (const v of vs) {
594
- const id = await this.getID(v);
595
- const existingIndex = indexMap.get(id);
707
+ case EBDuplicateIDStrategy.OVERWRITE: {
708
+ const ids = await this.assignMissingIDs(vs, incomingIDs);
709
+ const indexKeys = indexAdds ? await this.indexKeysForAll(vs) : [];
710
+ const positions = new Map();
711
+ storedIDs.forEach((id, i) => {
712
+ if (id !== undefined) {
713
+ positions.set(id, i);
714
+ }
715
+ });
716
+ vs.forEach((v, i) => {
717
+ const id = ids[i];
718
+ const existingIndex = positions.get(id);
719
+ ret++;
596
720
  if (existingIndex !== undefined) {
597
- if (indexAddsArray) {
598
- await this.removeFromIndexes(this.arrayData[existingIndex]);
721
+ if (indexAdds) {
722
+ this.removeFromIndexes(this.arrayData[existingIndex]);
599
723
  }
600
724
  this.arrayData[existingIndex] = v;
601
725
  }
602
726
  else {
603
- // store new index in indexCache in the case this id appears again
604
- indexMap.set(id, this.arrayData.length);
727
+ positions.set(id, this.arrayData.length);
605
728
  this.arrayData.push(v);
606
729
  }
607
- if (indexAddsArray) {
608
- await this.addToIndexes(v);
730
+ if (indexAdds) {
731
+ this.addKeysToIndexes(v, indexKeys[i]);
609
732
  }
610
- if (trackAddsArray) {
611
- addedArray.push(v);
733
+ if (collectAdded) {
734
+ added.push(v);
612
735
  }
613
- }
736
+ });
614
737
  break;
738
+ }
615
739
  }
616
- if (vs.length > 0) {
617
- await this.doOnDirty();
618
- }
619
- if (addedArray.length > 0) {
620
- await this.fireAdd(addedArray);
621
- }
622
- return vs.length;
740
+ break;
741
+ }
742
+ }
743
+ if (ret > 0) {
744
+ await this.doOnDirty();
623
745
  }
746
+ await this.recordTxChanges(EBModificationType.ADD, added);
747
+ if (trackAdds && added.length > 0) {
748
+ await this.fireAdd(added);
749
+ }
750
+ return ret;
624
751
  });
625
752
  }
626
753
  async update(predicate, updater) {
627
- return this.runProtected(async () => {
754
+ return this.runMutation(async () => {
628
755
  await this.checkLoaded();
629
- let ret = 0;
630
756
  const trackUpdates = this.updateHandlers.length > 0;
631
757
  const indexUpdates = this.indexDefinitions.size > 0;
632
- const updated = [];
758
+ const planned = [];
759
+ const plan = async (previous, store) => {
760
+ const replacement = await updater(previous);
761
+ if (replacement === undefined) {
762
+ throw new Error("updater gave undefined");
763
+ }
764
+ const replacementKeys = indexUpdates ? await this.indexKeysFor(replacement) : [];
765
+ planned.push({ previous, replacement, replacementKeys, store });
766
+ };
633
767
  switch (this.collectionConfig.memStorageStrategy) {
634
768
  case EBMemStorageStrategy.MAP:
635
769
  for (const [key, value] of this.mapData) {
636
770
  if (predicate(value)) {
637
- const replacement = await updater(value);
638
- if (replacement !== undefined) {
639
- ret++;
640
- if (indexUpdates) {
641
- await this.removeFromIndexes(value);
642
- }
643
- this.mapData.set(key, replacement);
644
- if (indexUpdates) {
645
- await this.addToIndexes(replacement);
646
- }
647
- if (trackUpdates) {
648
- updated.push([value, replacement]);
649
- }
650
- }
651
- else {
652
- throw new Error("updater gave undefined");
653
- }
771
+ await plan(value, (replacement) => { this.mapData.set(key, replacement); });
654
772
  }
655
773
  }
656
774
  break;
657
775
  default:
658
776
  case EBMemStorageStrategy.ARRAY:
659
777
  for (let i = 0; i < this.arrayData.length; i++) {
660
- if (predicate(this.arrayData[i])) {
661
- const previous = this.arrayData[i];
662
- const replacement = await updater(previous);
663
- if (replacement !== undefined) {
664
- ret++;
665
- if (indexUpdates) {
666
- await this.removeFromIndexes(previous);
667
- }
668
- this.arrayData[i] = replacement;
669
- if (indexUpdates) {
670
- await this.addToIndexes(replacement);
671
- }
672
- if (trackUpdates) {
673
- updated.push([previous, replacement]);
674
- }
675
- }
676
- else {
677
- throw new Error("updater gave undefined");
678
- }
778
+ const previous = this.arrayData[i];
779
+ if (predicate(previous)) {
780
+ await plan(previous, (replacement) => { this.arrayData[i] = replacement; });
679
781
  }
680
782
  }
681
783
  break;
682
784
  }
683
- if (ret > 0) {
785
+ for (const { previous, replacement, replacementKeys, store } of planned) {
786
+ if (indexUpdates) {
787
+ this.removeFromIndexes(previous);
788
+ }
789
+ store(replacement);
790
+ if (indexUpdates) {
791
+ this.addKeysToIndexes(replacement, replacementKeys);
792
+ }
793
+ }
794
+ if (planned.length > 0) {
684
795
  await this.doOnDirty();
685
796
  }
686
- if (updated.length > 0) {
687
- await this.fireUpdate(updated);
797
+ await this.recordTxChanges(EBModificationType.UPDATE, planned.map(({ replacement }) => replacement));
798
+ if (trackUpdates && planned.length > 0) {
799
+ await this.fireUpdate(planned.map(({ previous, replacement }) => [previous, replacement]));
688
800
  }
689
- return ret;
801
+ return planned.length;
690
802
  });
691
803
  }
692
804
  async doOnDirty() {
@@ -722,10 +834,10 @@ export class BCollection {
722
834
  this.arrayData = await this.database.io.loadArrayData(this.collectionID);
723
835
  break;
724
836
  }
725
- this.loaded = true;
726
- this.dirty = false;
727
837
  this.resolveIndexDefinitions();
728
838
  await this.rebuildIndexRuntime();
839
+ this.loaded = true;
840
+ this.dirty = false;
729
841
  });
730
842
  }
731
843
  async unload() {
@@ -752,6 +864,8 @@ export class BCollection {
752
864
  this.arrayData = [];
753
865
  break;
754
866
  }
867
+ this.indexRuntime.clear();
868
+ this.indexedKeys.clear();
755
869
  this.loaded = false;
756
870
  this.dirty = false;
757
871
  });
@@ -770,62 +884,75 @@ export class BCollection {
770
884
  clone.mapData = new Map(this.mapData);
771
885
  clone.arrayData = this.arrayData.slice();
772
886
  clone.loaded = true;
887
+ clone.transactionClone = true;
888
+ if (this.addHandlers.length > 0 || this.updateHandlers.length > 0 || this.deleteHandlers.length > 0) {
889
+ clone.txChanges = new Map();
890
+ }
773
891
  clone.resolveIndexDefinitions();
774
892
  await clone.rebuildIndexRuntime();
775
893
  return clone;
776
894
  });
777
895
  }
778
896
  /**
779
- * Applies a working copy from {@link cloneForTransaction} back onto this collection: replaces the live
780
- * state, flushes once, then fires `ADD`/`UPDATE`/`DELETE` handlers for the net difference between this
781
- * collection's state before the transaction and `clone`'s state - not for each intermediate operation the
782
- * transaction performed. The before/after diff itself (and the id resolution it needs) only runs when at
783
- * least one handler is registered on this collection - a transaction on a collection nobody listens to
784
- * skips it entirely. Call only while already holding this collection's mutex for the whole transaction.
897
+ * Writes `clone`'s state to a temp file (see {@link IPreparedWrite}) and computes the handler events for the
898
+ * net changes against this collection's current state: one per id the transaction touched, typed by the last
899
+ * operation on it (`DELETE` if gone, `ADD` if new or last stored via `add()`, `UPDATE` otherwise). Nothing is
900
+ * applied until `apply()`; call only while already holding this collection's mutex for the whole transaction.
785
901
  */
786
- async commitTransactionClone(clone) {
902
+ async prepareTransactionCommit(clone) {
787
903
  return this.runProtected(async () => {
788
904
  await this.checkLoaded();
789
905
  const added = [];
790
906
  const updated = [];
791
907
  const deleted = [];
792
- // the before/after diff only feeds fireAdd()/fireUpdate()/fireDelete() below - skip it
793
- // entirely (incl. idKeyedSnapshot()'s id-getter calls) when nothing is listening
794
- const trackChanges = this.addHandlers.length > 0 || this.updateHandlers.length > 0 || this.deleteHandlers.length > 0;
795
- if (trackChanges) {
908
+ if (clone.txChanges && clone.txChanges.size > 0) {
796
909
  const before = await this.idKeyedSnapshot();
797
910
  const after = await clone.idKeyedSnapshot();
798
- for (const [id, value] of after) {
799
- if (!before.has(id)) {
800
- added.push(value);
801
- }
802
- else {
803
- const previous = before.get(id);
804
- if (JSON.stringify(previous) !== JSON.stringify(value)) {
805
- updated.push([previous, value]);
911
+ for (const [id, modificationType] of clone.txChanges) {
912
+ if (!after.has(id)) {
913
+ if (before.has(id)) {
914
+ deleted.push(before.get(id));
806
915
  }
807
916
  }
808
- }
809
- for (const [id, value] of before) {
810
- if (!after.has(id)) {
811
- deleted.push(value);
917
+ else if (!before.has(id) || modificationType === EBModificationType.ADD) {
918
+ added.push(after.get(id));
919
+ }
920
+ else {
921
+ updated.push([before.get(id), after.get(id)]);
812
922
  }
813
923
  }
814
924
  }
815
- this.mapData = clone.mapData;
816
- this.arrayData = clone.arrayData;
817
- this.indexRuntime = clone.indexRuntime;
818
- this.dirty = true;
819
- await this.flush();
820
- if (added.length > 0) {
821
- await this.fireAdd(added);
822
- }
823
- if (updated.length > 0) {
824
- await this.fireUpdate(updated);
825
- }
826
- if (deleted.length > 0) {
827
- await this.fireDelete(deleted);
925
+ let write;
926
+ switch (this.collectionConfig.memStorageStrategy) {
927
+ case EBMemStorageStrategy.MAP:
928
+ write = await this.database.io.prepareMapData(this.collectionID, clone.mapData);
929
+ break;
930
+ default:
931
+ case EBMemStorageStrategy.ARRAY:
932
+ write = await this.database.io.prepareArrayData(this.collectionID, clone.arrayData);
933
+ break;
828
934
  }
935
+ return {
936
+ write,
937
+ apply: () => {
938
+ this.mapData = clone.mapData;
939
+ this.arrayData = clone.arrayData;
940
+ this.indexRuntime = clone.indexRuntime;
941
+ this.indexedKeys = clone.indexedKeys;
942
+ this.dirty = false;
943
+ },
944
+ fire: () => this.runProtected(async () => {
945
+ if (added.length > 0) {
946
+ await this.fireAdd(added);
947
+ }
948
+ if (updated.length > 0) {
949
+ await this.fireUpdate(updated);
950
+ }
951
+ if (deleted.length > 0) {
952
+ await this.fireDelete(deleted);
953
+ }
954
+ }),
955
+ };
829
956
  });
830
957
  }
831
958
  }