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