localdb-ces6q 0.0.5 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LocalDB.js CHANGED
@@ -1,966 +1,980 @@
1
- import { BTree } from 'bplustree-mq4uj/btree.js';
2
- import { Debounce } from 'util-3gcvv/class/Debounce.js';
3
- import { deepEqual } from 'util-3gcvv/deepEqual.js';
4
- import { objectEmpty, objectKeys } from 'util-3gcvv/object.js';
5
- import { randomString } from 'util-3gcvv/string.js';
6
- import { defaultComparators } from './comparators.js';
7
- import { LocalDBState } from './LocalDBState.js';
8
- // TODO
9
- // composite index
10
- // index filter / sort
11
- // watch filtered/sorted collection
12
- export class LocalDB {
13
- constructor(config, initialData, options) {
14
- this._txChangedFields = {};
15
- this._txChanges = {};
16
- this._txKey = null;
17
- this._txOps = [];
18
- this._txRollbacks = [];
19
- this._txSnapshot = null;
20
- this._txOptions = null;
21
- this._noEvent = false;
22
- this._undoable = false;
23
- this._inUndoOrRedo = false;
24
- this._opQueue = [];
25
- this._commiting = false;
26
- this._listenerId = 0;
27
- this._dbListeners = [];
28
- this._colListeners = [];
29
- this._docListeners = [];
30
- this._fieldListeners = [];
31
- this.state = new LocalDBState({ history: [], historyIndex: 0 }, {});
32
- this.saveDebounced = new Debounce(this.saveToStorage, 300, this);
33
- this._options = options;
34
- this._collectionNames = [];
35
- this._collections = {};
36
- this._config = {};
37
- this._fields = {};
38
- this._saveCols = {};
39
- this._indexes = {};
40
- const colNames = objectKeys(config);
41
- for (let i = 0, il = colNames.length; i < il; i += 1) {
42
- const col = colNames[i];
43
- this.defineCollection(col, config[col], initialData?.[col]);
44
- }
45
- }
46
- destroy() {
47
- this.saveDebounced.destroy();
48
- this._options = undefined;
49
- this._collectionNames = [];
50
- this._collections = {};
51
- this._config = {};
52
- this._fields = {};
53
- for (let col in this._saveCols) {
54
- this._saveCols[col]?.destroy();
55
- }
56
- this._saveCols = {};
57
- for (let col in this._indexes) {
58
- const indexes = this._indexes[col];
59
- for (let name in indexes) {
60
- indexes[name].clear();
61
- }
62
- }
63
- this._indexes = {};
64
- this._txChangedFields = {};
65
- this._txChanges = {};
66
- this._txKey = null;
67
- this._txOps = [];
68
- this._txRollbacks = [];
69
- this._txSnapshot = null;
70
- this._txOptions = null;
71
- this.state.$destroy();
72
- this._dbListeners = [];
73
- this._colListeners = [];
74
- this._docListeners = [];
75
- this._fieldListeners = [];
76
- this._opQueue = [];
77
- this._listenerId = 0;
78
- }
79
- toJSON() {
80
- return this._collections;
81
- }
82
- equals(other) {
83
- return deepEqual(this._collections, other);
84
- }
85
- //
86
- // Config
87
- //
88
- defineCollection(colName, config, initialData) {
89
- const col = colName;
90
- if (col in this._config) {
91
- throw new Error(`Collection, "${col}", is already defined`);
92
- }
93
- // config
94
- this._config[col] = config;
95
- // collection name
96
- this._collectionNames.push(col);
97
- // field names
98
- const fieldNames = objectKeys(config.fields);
99
- this._fields[col] = fieldNames;
100
- // save data to storage
101
- this._saveCols[col] = new Debounce(() => this.saveCollectionToStorage(col), config.localStorageSetWait ?? 300);
102
- // initial data
103
- const { localStorageKey } = config;
104
- let colData = {};
105
- if (initialData) {
106
- colData = initialData;
107
- }
108
- else if (localStorageKey) {
109
- const snapshot = localStorage.getItem(localStorageKey);
110
- if (snapshot) {
111
- colData = JSON.parse(snapshot);
112
- }
113
- }
114
- this._collections = { ...this._collections, [col]: colData };
115
- // indexes
116
- const docs = [];
117
- if (colData) {
118
- for (let id in colData) {
119
- const doc = colData[id];
120
- docs.push(doc);
121
- }
122
- }
123
- const fieldIndexes = {};
124
- for (let fi = 0, fl = fieldNames.length; fi < fl; fi += 1) {
125
- const field = fieldNames[fi];
126
- const { type, compare, index } = config.fields[field];
127
- if (index) {
128
- let comparator;
129
- if (compare) {
130
- comparator = index === 'desc' ? (a, b) => compare(b, a) : compare;
131
- }
132
- else {
133
- comparator = defaultComparators[type]?.[index];
134
- }
135
- if (!comparator) {
136
- throw new Error(`Comparator must be set to index ${col}/${field}`);
137
- }
138
- fieldIndexes[field] = new BTree(docs.map((d) => [d, d]), (a, b) => comparator(a[field], b[field]));
139
- }
140
- }
141
- this._indexes[col] = fieldIndexes;
142
- }
143
- deleteCollection(col) {
144
- if (!(col in this._config)) {
145
- throw new Error(`Collection, "${col}", does not exist`);
146
- }
147
- delete this._config[col];
148
- this._collectionNames = this._collectionNames.filter((x) => x !== col);
149
- delete this._fields[col];
150
- delete this._saveCols[col];
151
- const next = { ...this._collections };
152
- delete next[col];
153
- this._collections = next;
154
- }
155
- existsCollection(col) {
156
- return col in this._config;
157
- }
158
- get collectionNames() {
159
- return this._collectionNames.slice();
160
- }
161
- //
162
- // Transaction
163
- //
164
- beginTx(options = this._options) {
165
- if (this._txKey == null) {
166
- const key = randomString();
167
- this._txChangedFields = {};
168
- this._txChanges = {};
169
- this._txKey = key;
170
- this._txOps = [];
171
- this._txRollbacks = [];
172
- this._txSnapshot = this._collections;
173
- this._txOptions = options || null;
174
- this._noEvent = !!options?.noEvent;
175
- this._undoable = !!options?.undoable;
176
- return key;
177
- }
178
- return null;
179
- }
180
- endTx(txKey) {
181
- if (txKey != null && this._txKey === txKey) {
182
- try {
183
- this._commitChanges();
184
- }
185
- catch (e) {
186
- this._rollback();
187
- throw e;
188
- }
189
- this._pushHistory();
190
- // reset
191
- this._txChangedFields = {};
192
- this._txChanges = {};
193
- this._txKey = null;
194
- this._txOps = [];
195
- this._txRollbacks = [];
196
- this._txSnapshot = null;
197
- this._txOptions = null;
198
- this._noEvent = false;
199
- this._undoable = false;
200
- this._flushQueue();
201
- }
202
- }
203
- _rollback() {
204
- if (this._txSnapshot) {
205
- this._collections = this._txSnapshot;
206
- for (let i = 0, il = this._txRollbacks.length; i < il; i += 1) {
207
- this._txRollbacks[i]();
208
- }
209
- // reset
210
- this._txChangedFields = {};
211
- this._txChanges = {};
212
- this._txKey = null;
213
- this._txOps = [];
214
- this._txRollbacks = [];
215
- this._txSnapshot = null;
216
- this._noEvent = false;
217
- this._undoable = false;
218
- }
219
- }
220
- tx(fn, options) {
221
- const txKey = this.beginTx(options);
222
- if (txKey == null) {
223
- throw new Error('Already in transaction');
224
- }
225
- try {
226
- fn();
227
- }
228
- catch (e) {
229
- this._rollback();
230
- throw e;
231
- }
232
- this.endTx(txKey);
233
- }
234
- undoableTx(fn, options) {
235
- return this.tx(fn, { ...options, undoable: true });
236
- }
237
- //
238
- // Listeners
239
- //
240
- subToDB(handler) {
241
- const lid = this._listenerId++;
242
- this._dbListeners.push({ lid, handler });
243
- return () => this.unsubFromDB(lid);
244
- }
245
- unsubFromDB(lid) {
246
- this._dbListeners = this._dbListeners.filter((x) => x.lid !== lid);
247
- }
248
- subToCol(collection, handler) {
249
- const lid = this._listenerId++;
250
- this._colListeners.push({ lid, collection, handler });
251
- return () => this.unsubFromCol(lid);
252
- }
253
- unsubFromCol(lid) {
254
- this._colListeners = this._colListeners.filter((x) => x.lid !== lid);
255
- }
256
- subToDoc(collection, id, handler) {
257
- const lid = this._listenerId++;
258
- this._docListeners.push({ lid, collection, id, handler });
259
- return () => this.unsubFromDoc(lid);
260
- }
261
- unsubFromDoc(lid) {
262
- this._docListeners = this._docListeners.filter((x) => x.lid !== lid);
263
- }
264
- subToField(collection, id, field, handler) {
265
- const lid = this._listenerId++;
266
- this._fieldListeners.push({ lid, collection, id, field, handler });
267
- return () => this.unsubFromField(lid);
268
- }
269
- unsubFromField(lid) {
270
- this._fieldListeners = this._fieldListeners.filter((x) => x.lid !== lid);
271
- }
272
- //
273
- // Get
274
- //
275
- collection(colName) {
276
- return this._collections[colName];
277
- }
278
- doc(colName, id) {
279
- return this._collections[colName][id];
280
- }
281
- docs(colName, ids) {
282
- const col = this._collections[colName];
283
- return ids.map((id) => col[id]);
284
- }
285
- docsOrderBy(colName, index) {
286
- const col = this._indexes[colName];
287
- if (!(index in col)) {
288
- throw new Error(`index ${colName}/${index} not found`);
289
- }
290
- return col[index].valuesArray();
291
- }
292
- query({ collection, orderBy, limit, startAfter, startAt, endAfter, endAt, }) {
293
- const col = this._indexes[collection];
294
- if (!(orderBy in col)) {
295
- throw new Error(`index ${collection}/${orderBy} not found`);
296
- }
297
- const index = col[orderBy];
298
- // TODO
299
- return index.valuesArray();
300
- }
301
- //
302
- // Set / Update
303
- //
304
- setDoc(colName, doc, options) {
305
- if (this._commiting) {
306
- this._opQueue.push({ op: 'setDoc', args: [colName, doc] });
307
- return;
308
- }
309
- const { id } = doc;
310
- const keys = this._fields[colName];
311
- const collections = this._collections;
312
- const prev = collections[colName][id] || null;
313
- if (prev != null) {
314
- const copy = {};
315
- // delete unset fields
316
- for (let i = 0, il = keys.length; i < il; i += 1) {
317
- const key = keys[i];
318
- if (key in doc) {
319
- copy[key] = doc[key];
320
- }
321
- else {
322
- // undefined -> delete field
323
- copy[key] = undefined;
324
- }
325
- }
326
- return this.updateDoc(colName, id, copy, options);
327
- }
328
- // begin tx
329
- const txKey = this.beginTx(options);
330
- const _txChange = this._txChanges;
331
- const _txChangedFields = this._txChangedFields;
332
- const next = {};
333
- for (let i = 0, il = keys.length; i < il; i += 1) {
334
- const key = keys[i];
335
- if (key in doc && doc[key] !== undefined) {
336
- next[key] = doc[key];
337
- // set changes
338
- const changeMapCol = _txChangedFields[colName] || (_txChangedFields[colName] = {});
339
- changeMapCol[key] = true;
340
- const changeCol = _txChange[colName] || (_txChange[colName] = {});
341
- const changeDoc = changeCol[id] || (changeCol[id] = {});
342
- changeDoc[key] = doc[key];
343
- }
344
- }
345
- // update data
346
- this._collections = {
347
- ...collections,
348
- [colName]: {
349
- ...collections[colName],
350
- [id]: next,
351
- },
352
- };
353
- // update indexes
354
- const indexes = this._indexes[colName];
355
- for (let indexName in indexes) {
356
- indexes[indexName].set(next, next, true);
357
- }
358
- this._txRollbacks.push(() => {
359
- const indexes = this._indexes[colName];
360
- for (let indexName in indexes) {
361
- indexes[indexName].delete(next);
362
- }
363
- });
364
- // update undo history
365
- if (this._undoable && !this._inUndoOrRedo) {
366
- this._txOps.push({
367
- undo: { op: 'deleteDoc', args: [colName, id] },
368
- redo: { op: 'setDoc', args: [colName, doc] },
369
- });
370
- }
371
- this._config[colName].foreignComputes?.forEach((compute) => {
372
- compute.compute(this, [{ next, prev }]);
373
- });
374
- // end tx
375
- this.endTx(txKey);
376
- }
377
- setDocs(colName, docs, options) {
378
- if (!docs.length)
379
- return;
380
- if (this._commiting) {
381
- this._opQueue.push({ op: 'setDocs', args: [colName, docs] });
382
- return;
383
- }
384
- // begin tx
385
- const txKey = this.beginTx(options);
386
- // TODO undo, computes
387
- for (let i = 0, il = docs.length; i < il; i += 1) {
388
- this.setDoc(colName, docs[i], options);
389
- }
390
- // end tx
391
- this.endTx(txKey);
392
- }
393
- _updateDoc(colName, prev, update) {
394
- //
395
- // Normalize Update
396
- //
397
- const config = this._config[colName];
398
- const keys = this._fields[colName];
399
- let next = { ...prev };
400
- let changed = false;
401
- let updateNormalized = {};
402
- for (let ki = 0, kl = keys.length; ki < kl; ki += 1) {
403
- const key = keys[ki];
404
- if (!(key in update))
405
- continue;
406
- let nextVal = update[key];
407
- const { normalize, equals } = config.fields[key];
408
- if (normalize) {
409
- nextVal = normalize(nextVal, next);
410
- }
411
- if (equals ? equals(nextVal, prev[key]) : nextVal === prev[key]) {
412
- // noop
413
- }
414
- else {
415
- changed = true;
416
- updateNormalized[key] = nextVal;
417
- if (nextVal === undefined) {
418
- delete next[key];
419
- }
420
- else {
421
- next[key] = nextVal;
422
- }
423
- }
424
- }
425
- if (!changed)
426
- return null;
427
- //
428
- // Computes
429
- //
430
- const { computes } = config;
431
- if (computes) {
432
- // previous doc state before update before this iteration
433
- let docBeforeUpdate = prev;
434
- // update before this iteration
435
- let updateBeforeIter = updateNormalized;
436
- // docBeforeUpdate + updateBeforeIter
437
- let docBeforeIter = next;
438
- let updateByThisIter = {};
439
- // docBeforeUpdate + updateBeforeIter + updateByThisIter
440
- let docAfterIter = { ...next };
441
- let changedByIter = false;
442
- let count = 0;
443
- while (true) {
444
- for (let ci = 0, cl = computes.length; ci < cl; ci += 1) {
445
- const { compute } = computes[ci];
446
- // check deps has changed
447
- let depsChanged = false;
448
- const deps = computes[ci].deps;
449
- for (let di = 0, dl = deps.length; di < dl; di += 1) {
450
- const dep = deps[di];
451
- if (dep in updateBeforeIter) {
452
- depsChanged = true;
453
- }
454
- }
455
- if (!depsChanged)
456
- continue;
457
- const computedUpdate = compute(docBeforeIter, docBeforeUpdate);
458
- if (!computedUpdate)
459
- continue;
460
- for (let ki = 0, kl = keys.length; ki < kl; ki += 1) {
461
- const key = keys[ki];
462
- if (!(key in computedUpdate))
463
- continue;
464
- let nextVal = computedUpdate[key];
465
- const { normalize, equals } = config.fields[key];
466
- if (normalize) {
467
- nextVal = normalize(nextVal, docAfterIter);
468
- }
469
- if (equals
470
- ? equals(nextVal, docBeforeIter[key])
471
- : nextVal === docBeforeIter[key]) {
472
- // noop
473
- }
474
- else {
475
- changedByIter = true;
476
- updateByThisIter[key] = nextVal;
477
- if (nextVal === undefined) {
478
- delete docAfterIter[key];
479
- }
480
- else {
481
- docAfterIter[key] = nextVal;
482
- }
483
- }
484
- }
485
- }
486
- if (changedByIter) {
487
- docBeforeUpdate = docBeforeIter;
488
- updateBeforeIter = updateByThisIter;
489
- docBeforeIter = docAfterIter;
490
- updateByThisIter = {};
491
- docAfterIter = { ...docAfterIter };
492
- changedByIter = false;
493
- }
494
- else {
495
- next = docAfterIter;
496
- break;
497
- }
498
- if (count++ > 100) {
499
- throw new Error('too many compute loops');
500
- }
501
- }
502
- }
503
- if (!changed)
504
- return null;
505
- const changedKeys = keys.filter((key) => next[key] !== prev[key]);
506
- if (!changedKeys.length)
507
- return null;
508
- return { next, changedKeys };
509
- }
510
- updateDoc(colName, id, updater, options) {
511
- if (this._commiting) {
512
- this._opQueue.push({ op: 'updateDoc', args: [colName, id, updater] });
513
- return;
514
- }
515
- const collections = this._collections;
516
- const prev = collections[colName][id];
517
- if (prev == null) {
518
- if (options?.ignoreNotFound || this._txOptions?.ignoreNotFound)
519
- return;
520
- this._rollback();
521
- throw new Error(`Cannot update non-existing document, ${colName}/${id}`);
522
- }
523
- const update = typeof updater === 'function' ? updater(prev) : updater;
524
- if (prev === update || update == null || objectEmpty(update))
525
- return;
526
- // begin tx
527
- const txKey = this.beginTx(options);
528
- const result = this._updateDoc(colName, prev, update);
529
- if (result) {
530
- const { next, changedKeys } = result;
531
- const _txChange = this._txChanges;
532
- const _txChangedFields = this._txChangedFields;
533
- const prevFields = {};
534
- const nextFields = {};
535
- for (let ki = 0, kl = changedKeys.length; ki < kl; ki += 1) {
536
- const key = changedKeys[ki];
537
- const nextVal = next[key];
538
- const prevVal = prev[key];
539
- // update field
540
- prevFields[key] = prevVal;
541
- nextFields[key] = nextVal;
542
- // set changes
543
- const changeMapCol = _txChangedFields[colName] || (_txChangedFields[colName] = {});
544
- changeMapCol[key] = true;
545
- const changeCol = _txChange[colName] || (_txChange[colName] = {});
546
- const changeDoc = changeCol[id] || (changeCol[id] = {});
547
- changeDoc[key] = nextVal;
548
- }
549
- this._collections = {
550
- ...collections,
551
- [colName]: {
552
- ...collections[colName],
553
- [id]: next,
554
- },
555
- };
556
- // update indexes
557
- const indexes = this._indexes[colName];
558
- for (let indexName in indexes) {
559
- indexes[indexName].delete(prev);
560
- indexes[indexName].set(next, next, true);
561
- }
562
- this._txRollbacks.push(() => {
563
- const indexes = this._indexes[colName];
564
- for (let indexName in indexes) {
565
- indexes[indexName].delete(next);
566
- indexes[indexName].set(prev, prev, true);
567
- }
568
- });
569
- // update undo history
570
- if (this._undoable && !this._inUndoOrRedo) {
571
- this._txOps.push({
572
- undo: { op: 'updateDoc', args: [colName, id, prevFields] },
573
- redo: { op: 'updateDoc', args: [colName, id, nextFields] },
574
- });
575
- }
576
- this._config[colName].foreignComputes?.forEach((compute) => {
577
- compute.compute(this, [{ next, prev }]);
578
- });
579
- }
580
- // end tx
581
- this.endTx(txKey);
582
- }
583
- updateDocs(colName, updater, options) {
584
- if (this._commiting) {
585
- this._opQueue.push({ op: 'updateDocs', args: [colName, updater] });
586
- return;
587
- }
588
- const collections = this._collections;
589
- const prevCol = collections[colName];
590
- const update = typeof updater === 'function' ? updater(prevCol) : updater;
591
- if (prevCol === update || update == null || objectEmpty(update))
592
- return;
593
- const nextCol = { ...prevCol };
594
- // begin tx
595
- const txKey = this.beginTx(options);
596
- const _txChange = this._txChanges;
597
- const _txChangedFields = this._txChangedFields;
598
- const prevUpdateMap = {};
599
- const nextUpdateMap = {};
600
- const prevDocs = [];
601
- const nextDocs = [];
602
- const foreignComputeArgs = [];
603
- let changed = false;
604
- const ignoreNotFound = !!(options?.ignoreNotFound || this._txOptions?.ignoreNotFound);
605
- const ids = objectKeys(update);
606
- for (let i = 0, il = ids.length; i < il; i += 1) {
607
- const id = ids[i];
608
- const prevDoc = prevCol[id];
609
- if (prevDoc == null) {
610
- if (ignoreNotFound)
611
- continue;
612
- this._rollback();
613
- throw new Error(`Cannot update non-existing document, ${colName}/${id}`);
614
- }
615
- const updateDoc = update[id];
616
- if (updateDoc == null || objectEmpty(updateDoc))
617
- continue;
618
- const result = this._updateDoc(colName, prevDoc, updateDoc);
619
- if (!result)
620
- continue;
621
- changed = true;
622
- const { next: nextDoc, changedKeys } = result;
623
- const prevFields = {};
624
- const nextFields = {};
625
- for (let ki = 0, kl = changedKeys.length; ki < kl; ki += 1) {
626
- const key = changedKeys[ki];
627
- const nextVal = nextDoc[key];
628
- const prevVal = prevDoc[key];
629
- // update field
630
- prevFields[key] = prevVal;
631
- nextFields[key] = nextVal;
632
- // set changes
633
- const changeMapCol = _txChangedFields[colName] || (_txChangedFields[colName] = {});
634
- changeMapCol[key] = true;
635
- const changeCol = _txChange[colName] || (_txChange[colName] = {});
636
- const changeDoc = changeCol[id] || (changeCol[id] = {});
637
- changeDoc[key] = nextVal;
638
- }
639
- nextCol[id] = nextDoc;
640
- prevUpdateMap[id] = prevFields;
641
- nextUpdateMap[id] = nextFields;
642
- prevDocs.push(prevDoc);
643
- nextDocs.push(nextDoc);
644
- foreignComputeArgs.push({ next: nextDoc, prev: prevDoc });
645
- }
646
- // update data
647
- if (changed) {
648
- this._collections = {
649
- ...collections,
650
- [colName]: nextCol,
651
- };
652
- // update indexes
653
- const indexes = this._indexes[colName];
654
- for (let indexName in indexes) {
655
- indexes[indexName].deleteKeys(prevDocs);
656
- indexes[indexName].setPairs(nextDocs.map((doc) => [doc, doc]), true);
657
- }
658
- this._txRollbacks.push(() => {
659
- const indexes = this._indexes[colName];
660
- for (let indexName in indexes) {
661
- indexes[indexName].deleteKeys(nextDocs);
662
- indexes[indexName].setPairs(prevDocs.map((doc) => [doc, doc]), true);
663
- }
664
- });
665
- // update undo history
666
- if (this._undoable && !this._inUndoOrRedo) {
667
- this._txOps.push({
668
- undo: { op: 'updateDocs', args: [colName, prevUpdateMap] },
669
- redo: { op: 'updateDocs', args: [colName, nextUpdateMap] },
670
- });
671
- }
672
- this._config[colName].foreignComputes?.forEach((compute) => {
673
- compute.compute(this, foreignComputeArgs);
674
- });
675
- }
676
- // end tx
677
- this.endTx(txKey);
678
- }
679
- deleteDoc(colName, id, options) {
680
- if (this._commiting) {
681
- this._opQueue.push({ op: 'deleteDoc', args: [colName, id] });
682
- return;
683
- }
684
- const collections = this._collections;
685
- const prev = collections[colName][id];
686
- if (prev == null) {
687
- if (options?.idempotent || this._txOptions?.idempotent)
688
- return;
689
- this._rollback();
690
- throw new Error(`Cannot delete non-existing document, ${colName}/${id}`);
691
- }
692
- // begin tx
693
- const txKey = this.beginTx(options);
694
- // changes
695
- const _txChange = this._txChanges;
696
- const _txChangedFields = this._txChangedFields;
697
- const keys = this._fields[colName];
698
- for (let i = 0, il = keys.length; i < il; i += 1) {
699
- const key = keys[i];
700
- if (key in prev && prev[key] !== undefined) {
701
- // set changes
702
- const changeMapCol = _txChangedFields[colName] || (_txChangedFields[colName] = {});
703
- changeMapCol[key] = true;
704
- }
705
- }
706
- const changeCol = _txChange[colName] || (_txChange[colName] = {});
707
- changeCol[id] = null;
708
- // update data
709
- const nextCol = { ...collections[colName] };
710
- delete nextCol[id];
711
- this._collections = {
712
- ...collections,
713
- [colName]: nextCol,
714
- };
715
- // update indexes
716
- const indexes = this._indexes[colName];
717
- for (let indexName in indexes) {
718
- indexes[indexName].delete(prev);
719
- }
720
- this._txRollbacks.push(() => {
721
- const indexes = this._indexes[colName];
722
- for (let indexName in indexes) {
723
- indexes[indexName].set(prev, prev, true);
724
- }
725
- });
726
- // update undo history
727
- if (this._undoable && !this._inUndoOrRedo) {
728
- this._txOps.push({
729
- undo: { op: 'setDoc', args: [colName, prev] },
730
- redo: { op: 'deleteDoc', args: [colName, id] },
731
- });
732
- }
733
- this._config[colName].foreignComputes?.forEach((compute) => {
734
- compute.compute(this, [{ next: null, prev }]);
735
- });
736
- // end tx
737
- this.endTx(txKey);
738
- }
739
- deleteDocs(colName, ids, options) {
740
- if (!ids?.length)
741
- return;
742
- if (this._commiting) {
743
- this._opQueue.push({ op: 'deleteDocs', args: [colName, ids] });
744
- return;
745
- }
746
- // begin tx
747
- const txKey = this.beginTx(options);
748
- const idempotent = !!(options?.idempotent || this._txOptions?.idempotent);
749
- const _txChange = this._txChanges;
750
- const _txChangedFields = this._txChangedFields;
751
- const collections = this._collections;
752
- const prevCol = collections[colName];
753
- const nextCol = { ...prevCol };
754
- const fields = this._fields[colName];
755
- const prevDocs = [];
756
- const foreignComputeArgs = [];
757
- for (let i = 0, il = ids.length; i < il; i += 1) {
758
- const id = ids[i];
759
- const prev = prevCol[id];
760
- if (prev == null) {
761
- if (idempotent)
762
- continue;
763
- this._rollback();
764
- throw new Error(`Cannot delete non-existing document, ${colName}/${id}`);
765
- }
766
- // changes
767
- for (let fi = 0, fl = fields.length; fi < fl; fi += 1) {
768
- const key = fields[fi];
769
- if (key in prev && prev[key] !== undefined) {
770
- // set changes
771
- const changeMapCol = _txChangedFields[colName] || (_txChangedFields[colName] = {});
772
- changeMapCol[key] = true;
773
- }
774
- }
775
- const changeCol = _txChange[colName] || (_txChange[colName] = {});
776
- changeCol[id] = null;
777
- delete nextCol[id];
778
- prevDocs.push(prev);
779
- foreignComputeArgs.push({ next: null, prev });
780
- }
781
- // update data
782
- this._collections = {
783
- ...collections,
784
- [colName]: nextCol,
785
- };
786
- // update indexes
787
- const indexes = this._indexes[colName];
788
- for (let indexName in indexes) {
789
- indexes[indexName].deleteKeys(prevDocs);
790
- }
791
- this._txRollbacks.push(() => {
792
- const indexes = this._indexes[colName];
793
- for (let indexName in indexes) {
794
- indexes[indexName].setPairs(prevDocs.map((d) => [d, d]), true);
795
- }
796
- });
797
- // update undo history
798
- if (this._undoable && !this._inUndoOrRedo) {
799
- this._txOps.push({
800
- undo: { op: 'setDocs', args: [colName, prevDocs] },
801
- redo: { op: 'deleteDocs', args: [colName, ids] },
802
- });
803
- }
804
- this._config[colName].foreignComputes?.forEach((compute) => {
805
- compute.compute(this, foreignComputeArgs);
806
- });
807
- // end tx
808
- this.endTx(txKey);
809
- }
810
- _commitChanges() {
811
- if (this._commiting)
812
- return;
813
- const prev = this._txSnapshot;
814
- const next = this._collections;
815
- if (prev === next || prev == null)
816
- return;
817
- this._commiting = true;
818
- const _txChanges = this._txChanges;
819
- const _txChangedFields = this._txChangedFields;
820
- const txContext = this._txOptions?.context || {};
821
- // listeners
822
- const fieldListeners = this._fieldListeners;
823
- for (let i = 0, il = fieldListeners.length; i < il; i += 1) {
824
- const { collection, id, field, handler } = fieldListeners[i];
825
- const field2 = field;
826
- const nextDoc = next[collection][id];
827
- const prevDoc = prev[collection][id];
828
- if (nextDoc?.[field2] !== prevDoc?.[field2]) {
829
- handler(nextDoc?.[field2], prevDoc?.[field2], nextDoc, prevDoc, txContext);
830
- }
831
- }
832
- const docListeners = this._docListeners;
833
- for (let i = 0, il = docListeners.length; i < il; i += 1) {
834
- const { collection, id, handler } = docListeners[i];
835
- if (next[collection][id] !== prev[collection][id]) {
836
- handler(next[collection][id], prev[collection][id], _txChanges[collection][id], txContext);
837
- }
838
- }
839
- const colListeners = this._colListeners;
840
- for (let i = 0, il = colListeners.length; i < il; i += 1) {
841
- const { collection, handler } = colListeners[i];
842
- if (next[collection] !== prev[collection]) {
843
- handler(next[collection], prev[collection], _txChanges[collection], _txChangedFields[collection], txContext);
844
- }
845
- }
846
- const dbListeners = this._dbListeners;
847
- for (let i = 0, il = dbListeners.length; i < il; i += 1) {
848
- const { handler } = dbListeners[i];
849
- handler(next, prev, _txChanges, _txChangedFields, txContext);
850
- }
851
- // save to storage
852
- const cols = this._collectionNames;
853
- for (let i = 0, il = cols.length; i < il; i += 1) {
854
- const col = cols[i];
855
- if (next[col] !== prev[col]) {
856
- this._saveCols[col].debounced();
857
- }
858
- }
859
- this._commiting = false;
860
- }
861
- //
862
- // Queue
863
- //
864
- _flushQueue() {
865
- let count = 0;
866
- while (this._opQueue.length) {
867
- const { op, args } = this._opQueue.shift();
868
- this[op].apply(this, args);
869
- if (count++ > 100) {
870
- throw new Error('too much');
871
- }
872
- }
873
- }
874
- //
875
- // History
876
- //
877
- _pushHistory() {
878
- if (!this._inUndoOrRedo && this._txOps.length) {
879
- this.state.$update(({ history, historyIndex }) => {
880
- const nextHistory = [...history.slice(0, historyIndex), this._txOps];
881
- return {
882
- history: nextHistory,
883
- historyIndex: nextHistory.length,
884
- };
885
- });
886
- this._txOps = [];
887
- }
888
- }
889
- undo() {
890
- if (this._txKey) {
891
- throw new Error('Cannot undo during transaction');
892
- }
893
- const { undoItem } = this.state;
894
- if (undoItem) {
895
- this._inUndoOrRedo = true;
896
- this.tx(() => {
897
- for (let i = undoItem.length - 1; i >= 0; i -= 1) {
898
- const { undo } = undoItem[i];
899
- this[undo.op].apply(this, undo.args);
900
- }
901
- this.state.historyIndex--;
902
- });
903
- this._inUndoOrRedo = false;
904
- }
905
- }
906
- redo() {
907
- if (this._txKey) {
908
- throw new Error('Cannot redo during transaction');
909
- }
910
- const { redoItem } = this.state;
911
- if (redoItem) {
912
- this._inUndoOrRedo = true;
913
- this.tx(() => {
914
- for (let i = 0, il = redoItem.length; i < il; i += 1) {
915
- const { redo } = redoItem[i];
916
- this[redo.op].apply(this, redo.args);
917
- }
918
- this.state.historyIndex++;
919
- });
920
- this._inUndoOrRedo = false;
921
- }
922
- }
923
- // Local Storage
924
- loadCollectionFromStorage(collection) {
925
- if (collection in this._config) {
926
- const { localStorageKey } = this._config[collection];
927
- if (localStorageKey) {
928
- const snapshot = localStorage.getItem(localStorageKey);
929
- if (snapshot) {
930
- return JSON.parse(snapshot);
931
- }
932
- }
933
- return undefined;
934
- }
935
- }
936
- saveCollectionToStorage(collection) {
937
- if (collection in this._config) {
938
- const { localStorageKey } = this._config[collection];
939
- if (localStorageKey) {
940
- localStorage.setItem(localStorageKey, JSON.stringify(this._collections[collection]));
941
- }
942
- }
943
- }
944
- loadFromStorage() {
945
- const data = this._collections;
946
- const cols = this._collectionNames;
947
- for (let i = 0, il = cols.length; i < il; i += 1) {
948
- const col = cols[i];
949
- const colData = this.loadCollectionFromStorage(col);
950
- if (colData) {
951
- data[col] = colData;
952
- }
953
- }
954
- }
955
- saveToStorage() {
956
- const data = this._collections;
957
- const cols = this._collectionNames;
958
- for (let i = 0, il = cols.length; i < il; i += 1) {
959
- const col = cols[i];
960
- const { localStorageKey } = this._config[col];
961
- if (localStorageKey) {
962
- localStorage.setItem(localStorageKey, JSON.stringify(data[col]));
963
- }
964
- }
965
- }
966
- }
1
+ import { BTree } from 'bplustree-mq4uj/btree.js';
2
+ import { Debounce } from 'util-3gcvv/class/Debounce.js';
3
+ import { deepEqual } from 'util-3gcvv/deepEqual.js';
4
+ import { objectEmpty, objectKeys } from 'util-3gcvv/object.js';
5
+ import { randomString } from 'util-3gcvv/string.js';
6
+ import { defaultComparators } from './comparators.js';
7
+ import { LocalDBState } from './LocalDBState.js';
8
+ // TODO
9
+ // composite index
10
+ // index filter / sort
11
+ // watch filtered/sorted collection
12
+ export class LocalDB {
13
+ constructor(config, initialData, options) {
14
+ this._txChangedFields = {};
15
+ this._txChanges = {};
16
+ this._txKey = null;
17
+ this._txOps = [];
18
+ this._txRollbacks = [];
19
+ this._txSnapshot = null;
20
+ this._txOptions = null;
21
+ this._noEvent = false;
22
+ this._undoable = false;
23
+ this._inUndoOrRedo = false;
24
+ this._opQueue = [];
25
+ this._commiting = false;
26
+ this._listenerId = 0;
27
+ this._dbListeners = [];
28
+ this._colListeners = [];
29
+ this._docListeners = [];
30
+ this._fieldListeners = [];
31
+ this.state = new LocalDBState({ history: [], historyIndex: 0 }, {});
32
+ this.saveDebounced = new Debounce(this.saveToStorage, 300, this);
33
+ this._options = options;
34
+ this._collectionNames = [];
35
+ this._collections = {};
36
+ this._config = {};
37
+ this._fields = {};
38
+ this._saveCols = {};
39
+ this._indexes = {};
40
+ const colNames = objectKeys(config);
41
+ for (let i = 0, il = colNames.length; i < il; i += 1) {
42
+ const col = colNames[i];
43
+ this.defineCollection(col, config[col], initialData?.[col]);
44
+ }
45
+ }
46
+ destroy() {
47
+ this.saveDebounced.destroy();
48
+ this._options = undefined;
49
+ this._collectionNames = [];
50
+ this._collections = {};
51
+ this._config = {};
52
+ this._fields = {};
53
+ for (let col in this._saveCols) {
54
+ this._saveCols[col]?.destroy();
55
+ }
56
+ this._saveCols = {};
57
+ for (let col in this._indexes) {
58
+ const indexes = this._indexes[col];
59
+ for (let name in indexes) {
60
+ indexes[name].clear();
61
+ }
62
+ }
63
+ this._indexes = {};
64
+ this._txChangedFields = {};
65
+ this._txChanges = {};
66
+ this._txKey = null;
67
+ this._txOps = [];
68
+ this._txRollbacks = [];
69
+ this._txSnapshot = null;
70
+ this._txOptions = null;
71
+ this.state.$destroy();
72
+ this._dbListeners = [];
73
+ this._colListeners = [];
74
+ this._docListeners = [];
75
+ this._fieldListeners = [];
76
+ this._opQueue = [];
77
+ this._listenerId = 0;
78
+ }
79
+ toJSON() {
80
+ return this._collections;
81
+ }
82
+ equals(other) {
83
+ return deepEqual(this._collections, other);
84
+ }
85
+ //
86
+ // Config
87
+ //
88
+ defineCollection(colName, config, initialData) {
89
+ const col = colName;
90
+ if (col in this._config) {
91
+ throw new Error(`Collection, "${col}", is already defined`);
92
+ }
93
+ // config
94
+ this._config[col] = config;
95
+ // collection name
96
+ this._collectionNames.push(col);
97
+ // field names
98
+ const fieldNames = objectKeys(config.fields);
99
+ this._fields[col] = fieldNames;
100
+ // save data to storage
101
+ this._saveCols[col] = new Debounce(() => this.saveCollectionToStorage(col), config.localStorageSetWait ?? 300);
102
+ // initial data
103
+ const { localStorageKey } = config;
104
+ let colData = {};
105
+ if (initialData) {
106
+ colData = initialData;
107
+ }
108
+ else if (localStorageKey) {
109
+ const snapshot = localStorage.getItem(localStorageKey);
110
+ if (snapshot) {
111
+ colData = JSON.parse(snapshot);
112
+ }
113
+ }
114
+ this._collections = { ...this._collections, [col]: colData };
115
+ // indexes
116
+ const docs = [];
117
+ if (colData) {
118
+ for (let id in colData) {
119
+ const doc = colData[id];
120
+ docs.push(doc);
121
+ }
122
+ }
123
+ const fieldIndexes = {};
124
+ for (let fi = 0, fl = fieldNames.length; fi < fl; fi += 1) {
125
+ const field = fieldNames[fi];
126
+ const { type, compare, index } = config.fields[field];
127
+ if (index) {
128
+ let comparator;
129
+ if (compare) {
130
+ comparator = index === 'desc' ? (a, b) => compare(b, a) : compare;
131
+ }
132
+ else {
133
+ comparator = defaultComparators[type]?.[index];
134
+ }
135
+ if (!comparator) {
136
+ throw new Error(`Comparator must be set to index ${col}/${field}`);
137
+ }
138
+ fieldIndexes[field] = new BTree(docs.map((d) => [d, d]), (a, b) => comparator(a[field], b[field]));
139
+ }
140
+ }
141
+ const { indexes } = config;
142
+ if (indexes) {
143
+ for (const name in indexes) {
144
+ const { compare } = indexes[name];
145
+ fieldIndexes[name] = new BTree(docs.map((d) => [d, d]), compare);
146
+ }
147
+ }
148
+ this._indexes[col] = fieldIndexes;
149
+ }
150
+ deleteCollection(col) {
151
+ if (!(col in this._config)) {
152
+ throw new Error(`Collection, "${col}", does not exist`);
153
+ }
154
+ delete this._config[col];
155
+ this._collectionNames = this._collectionNames.filter((x) => x !== col);
156
+ delete this._fields[col];
157
+ delete this._saveCols[col];
158
+ const next = { ...this._collections };
159
+ delete next[col];
160
+ this._collections = next;
161
+ }
162
+ existsCollection(col) {
163
+ return col in this._config;
164
+ }
165
+ get collectionNames() {
166
+ return this._collectionNames.slice();
167
+ }
168
+ //
169
+ // Transaction
170
+ //
171
+ beginTx(options = this._options) {
172
+ if (this._txKey == null) {
173
+ const key = randomString();
174
+ this._txChangedFields = {};
175
+ this._txChanges = {};
176
+ this._txKey = key;
177
+ this._txOps = [];
178
+ this._txRollbacks = [];
179
+ this._txSnapshot = this._collections;
180
+ this._txOptions = options || null;
181
+ this._noEvent = !!options?.noEvent;
182
+ this._undoable = !!options?.undoable;
183
+ return key;
184
+ }
185
+ return null;
186
+ }
187
+ endTx(txKey) {
188
+ if (txKey != null && this._txKey === txKey) {
189
+ try {
190
+ this._commitChanges();
191
+ }
192
+ catch (e) {
193
+ this._rollback();
194
+ throw e;
195
+ }
196
+ this._pushHistory();
197
+ // reset
198
+ this._txChangedFields = {};
199
+ this._txChanges = {};
200
+ this._txKey = null;
201
+ this._txOps = [];
202
+ this._txRollbacks = [];
203
+ this._txSnapshot = null;
204
+ this._txOptions = null;
205
+ this._noEvent = false;
206
+ this._undoable = false;
207
+ this._flushQueue();
208
+ }
209
+ }
210
+ _rollback() {
211
+ if (this._txSnapshot) {
212
+ this._collections = this._txSnapshot;
213
+ for (let i = 0, il = this._txRollbacks.length; i < il; i += 1) {
214
+ this._txRollbacks[i]();
215
+ }
216
+ // reset
217
+ this._txChangedFields = {};
218
+ this._txChanges = {};
219
+ this._txKey = null;
220
+ this._txOps = [];
221
+ this._txRollbacks = [];
222
+ this._txSnapshot = null;
223
+ this._noEvent = false;
224
+ this._undoable = false;
225
+ }
226
+ }
227
+ tx(fn, options) {
228
+ const txKey = this.beginTx(options);
229
+ if (txKey == null) {
230
+ throw new Error('Already in transaction');
231
+ }
232
+ try {
233
+ fn();
234
+ }
235
+ catch (e) {
236
+ this._rollback();
237
+ throw e;
238
+ }
239
+ this.endTx(txKey);
240
+ }
241
+ undoableTx(fn, options) {
242
+ return this.tx(fn, { ...options, undoable: true });
243
+ }
244
+ //
245
+ // Listeners
246
+ //
247
+ subToDB(handler) {
248
+ const lid = this._listenerId++;
249
+ this._dbListeners.push({ lid, handler });
250
+ return () => this.unsubFromDB(lid);
251
+ }
252
+ unsubFromDB(lid) {
253
+ this._dbListeners = this._dbListeners.filter((x) => x.lid !== lid);
254
+ }
255
+ subToCol(collection, handler) {
256
+ const lid = this._listenerId++;
257
+ this._colListeners.push({ lid, collection, handler });
258
+ return () => this.unsubFromCol(lid);
259
+ }
260
+ unsubFromCol(lid) {
261
+ this._colListeners = this._colListeners.filter((x) => x.lid !== lid);
262
+ }
263
+ subToDoc(collection, id, handler) {
264
+ const lid = this._listenerId++;
265
+ this._docListeners.push({ lid, collection, id, handler });
266
+ return () => this.unsubFromDoc(lid);
267
+ }
268
+ unsubFromDoc(lid) {
269
+ this._docListeners = this._docListeners.filter((x) => x.lid !== lid);
270
+ }
271
+ subToField(collection, id, field, handler) {
272
+ const lid = this._listenerId++;
273
+ this._fieldListeners.push({ lid, collection, id, field, handler });
274
+ return () => this.unsubFromField(lid);
275
+ }
276
+ unsubFromField(lid) {
277
+ this._fieldListeners = this._fieldListeners.filter((x) => x.lid !== lid);
278
+ }
279
+ //
280
+ // Get
281
+ //
282
+ collection(colName) {
283
+ return this._collections[colName];
284
+ }
285
+ doc(colName, id) {
286
+ return this._collections[colName][id];
287
+ }
288
+ docs(colName, ids) {
289
+ const col = this._collections[colName];
290
+ return ids.map((id) => col[id]);
291
+ }
292
+ getIndex(colName, index) {
293
+ const col = this._indexes[colName];
294
+ if (!(index in col)) {
295
+ throw new Error(`index ${colName}/${index} not found`);
296
+ }
297
+ return col[index];
298
+ }
299
+ docsOrderBy(colName, index) {
300
+ const col = this._indexes[colName];
301
+ if (!(index in col)) {
302
+ throw new Error(`index ${colName}/${index} not found`);
303
+ }
304
+ return col[index].valuesArray();
305
+ }
306
+ query({ collection, orderBy, limit, startAfter, startAt, endAfter, endAt, }) {
307
+ const col = this._indexes[collection];
308
+ if (!(orderBy in col)) {
309
+ throw new Error(`index ${collection}/${orderBy} not found`);
310
+ }
311
+ const index = col[orderBy];
312
+ // TODO
313
+ return index.valuesArray();
314
+ }
315
+ //
316
+ // Set / Update
317
+ //
318
+ setDoc(colName, doc, options) {
319
+ if (this._commiting) {
320
+ this._opQueue.push({ op: 'setDoc', args: [colName, doc] });
321
+ return;
322
+ }
323
+ const { id } = doc;
324
+ const keys = this._fields[colName];
325
+ const collections = this._collections;
326
+ const prev = collections[colName][id] || null;
327
+ if (prev != null) {
328
+ const copy = {};
329
+ // delete unset fields
330
+ for (let i = 0, il = keys.length; i < il; i += 1) {
331
+ const key = keys[i];
332
+ if (key in doc) {
333
+ copy[key] = doc[key];
334
+ }
335
+ else {
336
+ // undefined -> delete field
337
+ copy[key] = undefined;
338
+ }
339
+ }
340
+ return this.updateDoc(colName, id, copy, options);
341
+ }
342
+ // begin tx
343
+ const txKey = this.beginTx(options);
344
+ const _txChange = this._txChanges;
345
+ const _txChangedFields = this._txChangedFields;
346
+ const next = {};
347
+ for (let i = 0, il = keys.length; i < il; i += 1) {
348
+ const key = keys[i];
349
+ if (key in doc && doc[key] !== undefined) {
350
+ next[key] = doc[key];
351
+ // set changes
352
+ const changeMapCol = _txChangedFields[colName] || (_txChangedFields[colName] = {});
353
+ changeMapCol[key] = true;
354
+ const changeCol = _txChange[colName] || (_txChange[colName] = {});
355
+ const changeDoc = changeCol[id] || (changeCol[id] = {});
356
+ changeDoc[key] = doc[key];
357
+ }
358
+ }
359
+ // update data
360
+ this._collections = {
361
+ ...collections,
362
+ [colName]: {
363
+ ...collections[colName],
364
+ [id]: next,
365
+ },
366
+ };
367
+ // update indexes
368
+ const indexes = this._indexes[colName];
369
+ for (let indexName in indexes) {
370
+ indexes[indexName].set(next, next, true);
371
+ }
372
+ this._txRollbacks.push(() => {
373
+ const indexes = this._indexes[colName];
374
+ for (let indexName in indexes) {
375
+ indexes[indexName].delete(next);
376
+ }
377
+ });
378
+ // update undo history
379
+ if (this._undoable && !this._inUndoOrRedo) {
380
+ this._txOps.push({
381
+ undo: { op: 'deleteDoc', args: [colName, id] },
382
+ redo: { op: 'setDoc', args: [colName, doc] },
383
+ });
384
+ }
385
+ this._config[colName].foreignComputes?.forEach((compute) => {
386
+ compute.compute(this, [{ next, prev }]);
387
+ });
388
+ // end tx
389
+ this.endTx(txKey);
390
+ }
391
+ setDocs(colName, docs, options) {
392
+ if (!docs.length)
393
+ return;
394
+ if (this._commiting) {
395
+ this._opQueue.push({ op: 'setDocs', args: [colName, docs] });
396
+ return;
397
+ }
398
+ // begin tx
399
+ const txKey = this.beginTx(options);
400
+ // TODO undo, computes
401
+ for (let i = 0, il = docs.length; i < il; i += 1) {
402
+ this.setDoc(colName, docs[i], options);
403
+ }
404
+ // end tx
405
+ this.endTx(txKey);
406
+ }
407
+ _updateDoc(colName, prev, update) {
408
+ //
409
+ // Normalize Update
410
+ //
411
+ const config = this._config[colName];
412
+ const keys = this._fields[colName];
413
+ let next = { ...prev };
414
+ let changed = false;
415
+ let updateNormalized = {};
416
+ for (let ki = 0, kl = keys.length; ki < kl; ki += 1) {
417
+ const key = keys[ki];
418
+ if (!(key in update))
419
+ continue;
420
+ let nextVal = update[key];
421
+ const { normalize, equals } = config.fields[key];
422
+ if (normalize) {
423
+ nextVal = normalize(nextVal, next);
424
+ }
425
+ if (equals ? equals(nextVal, prev[key]) : nextVal === prev[key]) {
426
+ // noop
427
+ }
428
+ else {
429
+ changed = true;
430
+ updateNormalized[key] = nextVal;
431
+ if (nextVal === undefined) {
432
+ delete next[key];
433
+ }
434
+ else {
435
+ next[key] = nextVal;
436
+ }
437
+ }
438
+ }
439
+ if (!changed)
440
+ return null;
441
+ //
442
+ // Computes
443
+ //
444
+ const { computes } = config;
445
+ if (computes) {
446
+ // previous doc state before update before this iteration
447
+ let docBeforeUpdate = prev;
448
+ // update before this iteration
449
+ let updateBeforeIter = updateNormalized;
450
+ // docBeforeUpdate + updateBeforeIter
451
+ let docBeforeIter = next;
452
+ let updateByThisIter = {};
453
+ // docBeforeUpdate + updateBeforeIter + updateByThisIter
454
+ let docAfterIter = { ...next };
455
+ let changedByIter = false;
456
+ let count = 0;
457
+ while (true) {
458
+ for (let ci = 0, cl = computes.length; ci < cl; ci += 1) {
459
+ const { compute } = computes[ci];
460
+ // check deps has changed
461
+ let depsChanged = false;
462
+ const deps = computes[ci].deps;
463
+ for (let di = 0, dl = deps.length; di < dl; di += 1) {
464
+ const dep = deps[di];
465
+ if (dep in updateBeforeIter) {
466
+ depsChanged = true;
467
+ }
468
+ }
469
+ if (!depsChanged)
470
+ continue;
471
+ const computedUpdate = compute(docBeforeIter, docBeforeUpdate);
472
+ if (!computedUpdate)
473
+ continue;
474
+ for (let ki = 0, kl = keys.length; ki < kl; ki += 1) {
475
+ const key = keys[ki];
476
+ if (!(key in computedUpdate))
477
+ continue;
478
+ let nextVal = computedUpdate[key];
479
+ const { normalize, equals } = config.fields[key];
480
+ if (normalize) {
481
+ nextVal = normalize(nextVal, docAfterIter);
482
+ }
483
+ if (equals
484
+ ? equals(nextVal, docBeforeIter[key])
485
+ : nextVal === docBeforeIter[key]) {
486
+ // noop
487
+ }
488
+ else {
489
+ changedByIter = true;
490
+ updateByThisIter[key] = nextVal;
491
+ if (nextVal === undefined) {
492
+ delete docAfterIter[key];
493
+ }
494
+ else {
495
+ docAfterIter[key] = nextVal;
496
+ }
497
+ }
498
+ }
499
+ }
500
+ if (changedByIter) {
501
+ docBeforeUpdate = docBeforeIter;
502
+ updateBeforeIter = updateByThisIter;
503
+ docBeforeIter = docAfterIter;
504
+ updateByThisIter = {};
505
+ docAfterIter = { ...docAfterIter };
506
+ changedByIter = false;
507
+ }
508
+ else {
509
+ next = docAfterIter;
510
+ break;
511
+ }
512
+ if (count++ > 100) {
513
+ throw new Error('too many compute loops');
514
+ }
515
+ }
516
+ }
517
+ if (!changed)
518
+ return null;
519
+ const changedKeys = keys.filter((key) => next[key] !== prev[key]);
520
+ if (!changedKeys.length)
521
+ return null;
522
+ return { next, changedKeys };
523
+ }
524
+ updateDoc(colName, id, updater, options) {
525
+ if (this._commiting) {
526
+ this._opQueue.push({ op: 'updateDoc', args: [colName, id, updater] });
527
+ return;
528
+ }
529
+ const collections = this._collections;
530
+ const prev = collections[colName][id];
531
+ if (prev == null) {
532
+ if (options?.ignoreNotFound || this._txOptions?.ignoreNotFound)
533
+ return;
534
+ this._rollback();
535
+ throw new Error(`Cannot update non-existing document, ${colName}/${id}`);
536
+ }
537
+ const update = typeof updater === 'function' ? updater(prev) : updater;
538
+ if (prev === update || update == null || objectEmpty(update))
539
+ return;
540
+ // begin tx
541
+ const txKey = this.beginTx(options);
542
+ const result = this._updateDoc(colName, prev, update);
543
+ if (result) {
544
+ const { next, changedKeys } = result;
545
+ const _txChange = this._txChanges;
546
+ const _txChangedFields = this._txChangedFields;
547
+ const prevFields = {};
548
+ const nextFields = {};
549
+ for (let ki = 0, kl = changedKeys.length; ki < kl; ki += 1) {
550
+ const key = changedKeys[ki];
551
+ const nextVal = next[key];
552
+ const prevVal = prev[key];
553
+ // update field
554
+ prevFields[key] = prevVal;
555
+ nextFields[key] = nextVal;
556
+ // set changes
557
+ const changeMapCol = _txChangedFields[colName] || (_txChangedFields[colName] = {});
558
+ changeMapCol[key] = true;
559
+ const changeCol = _txChange[colName] || (_txChange[colName] = {});
560
+ const changeDoc = changeCol[id] || (changeCol[id] = {});
561
+ changeDoc[key] = nextVal;
562
+ }
563
+ this._collections = {
564
+ ...collections,
565
+ [colName]: {
566
+ ...collections[colName],
567
+ [id]: next,
568
+ },
569
+ };
570
+ // update indexes
571
+ const indexes = this._indexes[colName];
572
+ for (let indexName in indexes) {
573
+ indexes[indexName].delete(prev);
574
+ indexes[indexName].set(next, next, true);
575
+ }
576
+ this._txRollbacks.push(() => {
577
+ const indexes = this._indexes[colName];
578
+ for (let indexName in indexes) {
579
+ indexes[indexName].delete(next);
580
+ indexes[indexName].set(prev, prev, true);
581
+ }
582
+ });
583
+ // update undo history
584
+ if (this._undoable && !this._inUndoOrRedo) {
585
+ this._txOps.push({
586
+ undo: { op: 'updateDoc', args: [colName, id, prevFields] },
587
+ redo: { op: 'updateDoc', args: [colName, id, nextFields] },
588
+ });
589
+ }
590
+ this._config[colName].foreignComputes?.forEach((compute) => {
591
+ compute.compute(this, [{ next, prev }]);
592
+ });
593
+ }
594
+ // end tx
595
+ this.endTx(txKey);
596
+ }
597
+ updateDocs(colName, updater, options) {
598
+ if (this._commiting) {
599
+ this._opQueue.push({ op: 'updateDocs', args: [colName, updater] });
600
+ return;
601
+ }
602
+ const collections = this._collections;
603
+ const prevCol = collections[colName];
604
+ const update = typeof updater === 'function' ? updater(prevCol) : updater;
605
+ if (prevCol === update || update == null || objectEmpty(update))
606
+ return;
607
+ const nextCol = { ...prevCol };
608
+ // begin tx
609
+ const txKey = this.beginTx(options);
610
+ const _txChange = this._txChanges;
611
+ const _txChangedFields = this._txChangedFields;
612
+ const prevUpdateMap = {};
613
+ const nextUpdateMap = {};
614
+ const prevDocs = [];
615
+ const nextDocs = [];
616
+ const foreignComputeArgs = [];
617
+ let changed = false;
618
+ const ignoreNotFound = !!(options?.ignoreNotFound || this._txOptions?.ignoreNotFound);
619
+ const ids = objectKeys(update);
620
+ for (let i = 0, il = ids.length; i < il; i += 1) {
621
+ const id = ids[i];
622
+ const prevDoc = prevCol[id];
623
+ if (prevDoc == null) {
624
+ if (ignoreNotFound)
625
+ continue;
626
+ this._rollback();
627
+ throw new Error(`Cannot update non-existing document, ${colName}/${id}`);
628
+ }
629
+ const updateDoc = update[id];
630
+ if (updateDoc == null || objectEmpty(updateDoc))
631
+ continue;
632
+ const result = this._updateDoc(colName, prevDoc, updateDoc);
633
+ if (!result)
634
+ continue;
635
+ changed = true;
636
+ const { next: nextDoc, changedKeys } = result;
637
+ const prevFields = {};
638
+ const nextFields = {};
639
+ for (let ki = 0, kl = changedKeys.length; ki < kl; ki += 1) {
640
+ const key = changedKeys[ki];
641
+ const nextVal = nextDoc[key];
642
+ const prevVal = prevDoc[key];
643
+ // update field
644
+ prevFields[key] = prevVal;
645
+ nextFields[key] = nextVal;
646
+ // set changes
647
+ const changeMapCol = _txChangedFields[colName] || (_txChangedFields[colName] = {});
648
+ changeMapCol[key] = true;
649
+ const changeCol = _txChange[colName] || (_txChange[colName] = {});
650
+ const changeDoc = changeCol[id] || (changeCol[id] = {});
651
+ changeDoc[key] = nextVal;
652
+ }
653
+ nextCol[id] = nextDoc;
654
+ prevUpdateMap[id] = prevFields;
655
+ nextUpdateMap[id] = nextFields;
656
+ prevDocs.push(prevDoc);
657
+ nextDocs.push(nextDoc);
658
+ foreignComputeArgs.push({ next: nextDoc, prev: prevDoc });
659
+ }
660
+ // update data
661
+ if (changed) {
662
+ this._collections = {
663
+ ...collections,
664
+ [colName]: nextCol,
665
+ };
666
+ // update indexes
667
+ const indexes = this._indexes[colName];
668
+ for (let indexName in indexes) {
669
+ indexes[indexName].deleteKeys(prevDocs);
670
+ indexes[indexName].setPairs(nextDocs.map((doc) => [doc, doc]), true);
671
+ }
672
+ this._txRollbacks.push(() => {
673
+ const indexes = this._indexes[colName];
674
+ for (let indexName in indexes) {
675
+ indexes[indexName].deleteKeys(nextDocs);
676
+ indexes[indexName].setPairs(prevDocs.map((doc) => [doc, doc]), true);
677
+ }
678
+ });
679
+ // update undo history
680
+ if (this._undoable && !this._inUndoOrRedo) {
681
+ this._txOps.push({
682
+ undo: { op: 'updateDocs', args: [colName, prevUpdateMap] },
683
+ redo: { op: 'updateDocs', args: [colName, nextUpdateMap] },
684
+ });
685
+ }
686
+ this._config[colName].foreignComputes?.forEach((compute) => {
687
+ compute.compute(this, foreignComputeArgs);
688
+ });
689
+ }
690
+ // end tx
691
+ this.endTx(txKey);
692
+ }
693
+ deleteDoc(colName, id, options) {
694
+ if (this._commiting) {
695
+ this._opQueue.push({ op: 'deleteDoc', args: [colName, id] });
696
+ return;
697
+ }
698
+ const collections = this._collections;
699
+ const prev = collections[colName][id];
700
+ if (prev == null) {
701
+ if (options?.idempotent || this._txOptions?.idempotent)
702
+ return;
703
+ this._rollback();
704
+ throw new Error(`Cannot delete non-existing document, ${colName}/${id}`);
705
+ }
706
+ // begin tx
707
+ const txKey = this.beginTx(options);
708
+ // changes
709
+ const _txChange = this._txChanges;
710
+ const _txChangedFields = this._txChangedFields;
711
+ const keys = this._fields[colName];
712
+ for (let i = 0, il = keys.length; i < il; i += 1) {
713
+ const key = keys[i];
714
+ if (key in prev && prev[key] !== undefined) {
715
+ // set changes
716
+ const changeMapCol = _txChangedFields[colName] || (_txChangedFields[colName] = {});
717
+ changeMapCol[key] = true;
718
+ }
719
+ }
720
+ const changeCol = _txChange[colName] || (_txChange[colName] = {});
721
+ changeCol[id] = null;
722
+ // update data
723
+ const nextCol = { ...collections[colName] };
724
+ delete nextCol[id];
725
+ this._collections = {
726
+ ...collections,
727
+ [colName]: nextCol,
728
+ };
729
+ // update indexes
730
+ const indexes = this._indexes[colName];
731
+ for (let indexName in indexes) {
732
+ indexes[indexName].delete(prev);
733
+ }
734
+ this._txRollbacks.push(() => {
735
+ const indexes = this._indexes[colName];
736
+ for (let indexName in indexes) {
737
+ indexes[indexName].set(prev, prev, true);
738
+ }
739
+ });
740
+ // update undo history
741
+ if (this._undoable && !this._inUndoOrRedo) {
742
+ this._txOps.push({
743
+ undo: { op: 'setDoc', args: [colName, prev] },
744
+ redo: { op: 'deleteDoc', args: [colName, id] },
745
+ });
746
+ }
747
+ this._config[colName].foreignComputes?.forEach((compute) => {
748
+ compute.compute(this, [{ next: null, prev }]);
749
+ });
750
+ // end tx
751
+ this.endTx(txKey);
752
+ }
753
+ deleteDocs(colName, ids, options) {
754
+ if (!ids?.length)
755
+ return;
756
+ if (this._commiting) {
757
+ this._opQueue.push({ op: 'deleteDocs', args: [colName, ids] });
758
+ return;
759
+ }
760
+ // begin tx
761
+ const txKey = this.beginTx(options);
762
+ const idempotent = !!(options?.idempotent || this._txOptions?.idempotent);
763
+ const _txChange = this._txChanges;
764
+ const _txChangedFields = this._txChangedFields;
765
+ const collections = this._collections;
766
+ const prevCol = collections[colName];
767
+ const nextCol = { ...prevCol };
768
+ const fields = this._fields[colName];
769
+ const prevDocs = [];
770
+ const foreignComputeArgs = [];
771
+ for (let i = 0, il = ids.length; i < il; i += 1) {
772
+ const id = ids[i];
773
+ const prev = prevCol[id];
774
+ if (prev == null) {
775
+ if (idempotent)
776
+ continue;
777
+ this._rollback();
778
+ throw new Error(`Cannot delete non-existing document, ${colName}/${id}`);
779
+ }
780
+ // changes
781
+ for (let fi = 0, fl = fields.length; fi < fl; fi += 1) {
782
+ const key = fields[fi];
783
+ if (key in prev && prev[key] !== undefined) {
784
+ // set changes
785
+ const changeMapCol = _txChangedFields[colName] || (_txChangedFields[colName] = {});
786
+ changeMapCol[key] = true;
787
+ }
788
+ }
789
+ const changeCol = _txChange[colName] || (_txChange[colName] = {});
790
+ changeCol[id] = null;
791
+ delete nextCol[id];
792
+ prevDocs.push(prev);
793
+ foreignComputeArgs.push({ next: null, prev });
794
+ }
795
+ // update data
796
+ this._collections = {
797
+ ...collections,
798
+ [colName]: nextCol,
799
+ };
800
+ // update indexes
801
+ const indexes = this._indexes[colName];
802
+ for (let indexName in indexes) {
803
+ indexes[indexName].deleteKeys(prevDocs);
804
+ }
805
+ this._txRollbacks.push(() => {
806
+ const indexes = this._indexes[colName];
807
+ for (let indexName in indexes) {
808
+ indexes[indexName].setPairs(prevDocs.map((d) => [d, d]), true);
809
+ }
810
+ });
811
+ // update undo history
812
+ if (this._undoable && !this._inUndoOrRedo) {
813
+ this._txOps.push({
814
+ undo: { op: 'setDocs', args: [colName, prevDocs] },
815
+ redo: { op: 'deleteDocs', args: [colName, ids] },
816
+ });
817
+ }
818
+ this._config[colName].foreignComputes?.forEach((compute) => {
819
+ compute.compute(this, foreignComputeArgs);
820
+ });
821
+ // end tx
822
+ this.endTx(txKey);
823
+ }
824
+ _commitChanges() {
825
+ if (this._commiting)
826
+ return;
827
+ const prev = this._txSnapshot;
828
+ const next = this._collections;
829
+ if (prev === next || prev == null)
830
+ return;
831
+ this._commiting = true;
832
+ const _txChanges = this._txChanges;
833
+ const _txChangedFields = this._txChangedFields;
834
+ const txContext = this._txOptions?.context || {};
835
+ // listeners
836
+ const fieldListeners = this._fieldListeners;
837
+ for (let i = 0, il = fieldListeners.length; i < il; i += 1) {
838
+ const { collection, id, field, handler } = fieldListeners[i];
839
+ const field2 = field;
840
+ const nextDoc = next[collection][id];
841
+ const prevDoc = prev[collection][id];
842
+ if (nextDoc?.[field2] !== prevDoc?.[field2]) {
843
+ handler(nextDoc?.[field2], prevDoc?.[field2], nextDoc, prevDoc, txContext);
844
+ }
845
+ }
846
+ const docListeners = this._docListeners;
847
+ for (let i = 0, il = docListeners.length; i < il; i += 1) {
848
+ const { collection, id, handler } = docListeners[i];
849
+ if (next[collection][id] !== prev[collection][id]) {
850
+ handler(next[collection][id], prev[collection][id], _txChanges[collection][id], txContext);
851
+ }
852
+ }
853
+ const colListeners = this._colListeners;
854
+ for (let i = 0, il = colListeners.length; i < il; i += 1) {
855
+ const { collection, handler } = colListeners[i];
856
+ if (next[collection] !== prev[collection]) {
857
+ handler(next[collection], prev[collection], _txChanges[collection], _txChangedFields[collection], txContext);
858
+ }
859
+ }
860
+ const dbListeners = this._dbListeners;
861
+ for (let i = 0, il = dbListeners.length; i < il; i += 1) {
862
+ const { handler } = dbListeners[i];
863
+ handler(next, prev, _txChanges, _txChangedFields, txContext);
864
+ }
865
+ // save to storage
866
+ const cols = this._collectionNames;
867
+ for (let i = 0, il = cols.length; i < il; i += 1) {
868
+ const col = cols[i];
869
+ if (next[col] !== prev[col]) {
870
+ this._saveCols[col].debounced();
871
+ }
872
+ }
873
+ this._commiting = false;
874
+ }
875
+ //
876
+ // Queue
877
+ //
878
+ _flushQueue() {
879
+ let count = 0;
880
+ while (this._opQueue.length) {
881
+ const { op, args } = this._opQueue.shift();
882
+ this[op].apply(this, args);
883
+ if (count++ > 100) {
884
+ throw new Error('too much');
885
+ }
886
+ }
887
+ }
888
+ //
889
+ // History
890
+ //
891
+ _pushHistory() {
892
+ if (!this._inUndoOrRedo && this._txOps.length) {
893
+ this.state.$update(({ history, historyIndex }) => {
894
+ const nextHistory = [...history.slice(0, historyIndex), this._txOps];
895
+ return {
896
+ history: nextHistory,
897
+ historyIndex: nextHistory.length,
898
+ };
899
+ });
900
+ this._txOps = [];
901
+ }
902
+ }
903
+ undo() {
904
+ if (this._txKey) {
905
+ throw new Error('Cannot undo during transaction');
906
+ }
907
+ const { undoItem } = this.state;
908
+ if (undoItem) {
909
+ this._inUndoOrRedo = true;
910
+ this.tx(() => {
911
+ for (let i = undoItem.length - 1; i >= 0; i -= 1) {
912
+ const { undo } = undoItem[i];
913
+ this[undo.op].apply(this, undo.args);
914
+ }
915
+ this.state.historyIndex--;
916
+ });
917
+ this._inUndoOrRedo = false;
918
+ }
919
+ }
920
+ redo() {
921
+ if (this._txKey) {
922
+ throw new Error('Cannot redo during transaction');
923
+ }
924
+ const { redoItem } = this.state;
925
+ if (redoItem) {
926
+ this._inUndoOrRedo = true;
927
+ this.tx(() => {
928
+ for (let i = 0, il = redoItem.length; i < il; i += 1) {
929
+ const { redo } = redoItem[i];
930
+ this[redo.op].apply(this, redo.args);
931
+ }
932
+ this.state.historyIndex++;
933
+ });
934
+ this._inUndoOrRedo = false;
935
+ }
936
+ }
937
+ // Local Storage
938
+ loadCollectionFromStorage(collection) {
939
+ if (collection in this._config) {
940
+ const { localStorageKey } = this._config[collection];
941
+ if (localStorageKey) {
942
+ const snapshot = localStorage.getItem(localStorageKey);
943
+ if (snapshot) {
944
+ return JSON.parse(snapshot);
945
+ }
946
+ }
947
+ return undefined;
948
+ }
949
+ }
950
+ saveCollectionToStorage(collection) {
951
+ if (collection in this._config) {
952
+ const { localStorageKey } = this._config[collection];
953
+ if (localStorageKey) {
954
+ localStorage.setItem(localStorageKey, JSON.stringify(this._collections[collection]));
955
+ }
956
+ }
957
+ }
958
+ loadFromStorage() {
959
+ const data = this._collections;
960
+ const cols = this._collectionNames;
961
+ for (let i = 0, il = cols.length; i < il; i += 1) {
962
+ const col = cols[i];
963
+ const colData = this.loadCollectionFromStorage(col);
964
+ if (colData) {
965
+ data[col] = colData;
966
+ }
967
+ }
968
+ }
969
+ saveToStorage() {
970
+ const data = this._collections;
971
+ const cols = this._collectionNames;
972
+ for (let i = 0, il = cols.length; i < il; i += 1) {
973
+ const col = cols[i];
974
+ const { localStorageKey } = this._config[col];
975
+ if (localStorageKey) {
976
+ localStorage.setItem(localStorageKey, JSON.stringify(data[col]));
977
+ }
978
+ }
979
+ }
980
+ }