Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
sagemathinc
GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/sync/editor/db/doc.ts
5760 views
1
/*
2
* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.
3
* License: MS-RSL – see LICENSE.md for details
4
*/
5
6
import { Document } from "../generic/types";
7
import * as immutable from "immutable";
8
import { isEqual } from "lodash";
9
import { is_array, is_object, copy_without, len } from "@cocalc/util/misc";
10
import {
11
make_patch as string_make_patch,
12
apply_patch as string_apply_patch,
13
} from "../generic/util";
14
import {
15
map_merge_patch,
16
merge_set,
17
nonnull_cols,
18
to_key,
19
to_str,
20
} from "./util";
21
22
type Record = immutable.Map<string, any> | undefined;
23
type Records = immutable.List<Record>;
24
type Index = immutable.Map<string, immutable.Set<number>>;
25
type Indexes = immutable.Map<string, Index>;
26
27
type jsmap = { [field: string]: any };
28
type DBPatch = any // TODO: It's really this, but... [-1 | 1, jsmap];
29
30
export type WhereCondition = { [field: string]: any };
31
export type SetCondition =
32
| immutable.Map<string, any>
33
| { [field: string]: any };
34
35
interface ChangeTracker {
36
changes: immutable.Set<immutable.Map<string, any>>; // primary keys that changed
37
from_db: Document; // DBDocument object where change tracking started.
38
}
39
40
// Immutable DB document
41
export class DBDocument implements Document {
42
private primary_keys: Set<string>;
43
44
// Columns that should be treated as non-atomic strings.
45
// This means simultaneous changes by multiple clients are
46
// merged, instead of last-write-wins. Also, large changes
47
// are propagated at patches, rather than sending
48
// complete string.
49
private string_cols: Set<string>;
50
51
// list of records -- each is assumed to be an immutable.Map.
52
private records: immutable.List<Record>;
53
54
// set of numbers n such that records.get(n) is defined.
55
private everything: immutable.Set<number>;
56
57
// TODO: describe
58
private indexes: Indexes;
59
60
// Change tracking.
61
private change_tracker: ChangeTracker;
62
63
public readonly size: number;
64
65
private to_str_cache?: string;
66
67
constructor(
68
primary_keys: Set<string>,
69
string_cols: Set<string>,
70
records: Records = immutable.List(),
71
everything?: immutable.Set<number>,
72
indexes?: Indexes,
73
change_tracker?: ChangeTracker,
74
) {
75
this.set = this.set.bind(this);
76
this.delete_array = this.delete_array.bind(this);
77
this.primary_key_cols = this.primary_key_cols.bind(this);
78
this.primary_key_part = this.primary_key_part.bind(this);
79
80
this.primary_keys = new Set(primary_keys);
81
if (this.primary_keys.size === 0) {
82
throw Error("there must be at least one primary key");
83
}
84
this.string_cols = new Set(string_cols);
85
this.records = records;
86
this.everything = everything == null ? this.init_everything() : everything;
87
this.size = this.everything.size;
88
this.indexes = indexes == null ? this.init_indexes() : indexes;
89
this.change_tracker =
90
change_tracker == null ? this.init_change_tracker() : change_tracker;
91
}
92
93
// sorted immutable Set of i such that this.records.get(i) is defined.
94
private init_everything(): immutable.Set<number> {
95
const v: number[] = [];
96
for (let n = 0; n < this.records.size; n++) {
97
if (this.records.get(n) != undefined) {
98
v.push(n);
99
}
100
}
101
return immutable.Set(v);
102
}
103
104
private init_indexes(): Indexes {
105
// Build indexes
106
let indexes: Indexes = immutable.Map();
107
for (const field of this.primary_keys) {
108
const index: Index = immutable.Map();
109
indexes = indexes.set(field, index);
110
}
111
this.records.map((record: Record, n: number) => {
112
if (record == null) {
113
// null records are sentinels for deletions.
114
return;
115
}
116
indexes.map((index: Index, field: string) => {
117
const val = record.get(field);
118
if (val != null) {
119
const k: string = to_key(val);
120
let matches = index.get(k);
121
if (matches != null) {
122
matches = matches.add(n);
123
} else {
124
matches = immutable.Set([n]);
125
}
126
indexes = indexes.set(field, index.set(k, matches));
127
}
128
});
129
});
130
return indexes;
131
}
132
133
private init_change_tracker(): ChangeTracker {
134
return { changes: immutable.Set(), from_db: this };
135
}
136
137
public to_str(): string {
138
if (this.to_str_cache != null) {
139
// We can use a cache, since this is an immutable object
140
return this.to_str_cache;
141
}
142
const obj = this.get({}).toJS();
143
return (this.to_str_cache = to_str(obj));
144
}
145
146
public is_equal(other?: DBDocument): boolean {
147
if (other == null) {
148
// Definitely not equal if not defined.
149
return false;
150
}
151
if (this.records === other.records) {
152
return true;
153
}
154
if (this.size !== other.size) {
155
return false;
156
}
157
// We include undefineds in the sets below
158
// since records is a List of Record or undefined, i.e.,
159
// we use undefined as a sentinel in order to
160
// make deletes be efficient.
161
return immutable
162
.Set(this.records)
163
.add(undefined)
164
.equals(immutable.Set(other.records).add(undefined));
165
}
166
167
public apply_patch(patch: DBPatch): DBDocument {
168
let i = 0;
169
let db: DBDocument = this;
170
while (i < patch.length) {
171
if (patch[i] === -1) {
172
db = db.delete(patch[i + 1]);
173
} else if (patch[i] === 1) {
174
db = db.set(patch[i + 1]);
175
}
176
i += 2;
177
}
178
return db;
179
}
180
181
public make_patch(other: DBDocument): DBPatch {
182
if (other.size === 0) {
183
// Special case -- delete everything
184
return [-1, [{}]];
185
}
186
187
let t0 = immutable.Set(this.records);
188
let t1 = immutable.Set(other.records);
189
// Remove the common intersection -- nothing going on there.
190
// Doing this greatly reduces the complexity in the common
191
// case in which little has changed
192
const common = t0.intersect(t1).add(undefined);
193
t0 = t0.subtract(common);
194
t1 = t1.subtract(common);
195
196
// Easy very common special cases
197
if (t0.size === 0) {
198
// Special case: t0 is empty -- insert all the records.
199
return [1, t1.toJS()];
200
}
201
if (t1.size === 0) {
202
// Special case: t1 is empty -- bunch of deletes
203
const v: jsmap[] = [];
204
t0.map((x) => {
205
if (x != null) {
206
v.push(this.primary_key_part(x.toJS()));
207
}
208
});
209
return [-1, v];
210
}
211
212
// compute the key parts of t0 and t1 as sets
213
// means -- set got from t0 by taking only the primary_key columns
214
// (Why the "as"? Typescript makes the output of the map be of type
215
// Iterable<Record, Iterable<string, any>>
216
// But, it's just a set. So cast it.)
217
const k0 = t0.map(this.primary_key_cols) as immutable.Set<Record>;
218
const k1 = t1.map(this.primary_key_cols) as immutable.Set<Record>;
219
220
const add: any[] = [];
221
let remove: any[] | undefined = undefined;
222
223
// Deletes: everything in k0 that is not in k1
224
const deletes = k0.subtract(k1);
225
if (deletes.size > 0) {
226
remove = deletes.toJS();
227
}
228
229
// Inserts: everything in k1 that is not in k0
230
const inserts = k1.subtract(k0);
231
if (inserts.size > 0) {
232
inserts.map((k) => {
233
if (k != null) {
234
const x = other.get_one(k);
235
if (x != null) {
236
add.push(x.toJS());
237
}
238
}
239
});
240
}
241
242
// Everything in k1 that is also in k0 -- these
243
// must have all changed
244
const changed = k1.intersect(k0);
245
if (changed.size > 0) {
246
changed.map((k) => {
247
if (k == null) {
248
return;
249
}
250
const obj = k.toJS();
251
const obj0 = this.primary_key_part(obj);
252
const from0 = this.get_one(obj0);
253
const to0 = other.get_one(obj0);
254
if (from0 == null || to0 == null) {
255
// just to make typescript happy
256
return;
257
}
258
const from = from0.toJS();
259
const to = to0.toJS();
260
// undefined for each key of from not in to
261
for (const key in from) {
262
if (to[key] == null) {
263
obj[key] = null;
264
}
265
}
266
// Explicitly set each key of `to` that is different
267
// than corresponding key of `from`:
268
for (const key in to) {
269
const v = to[key];
270
if (!isEqual(from[key], v)) {
271
if (this.string_cols.has(key) && from[key] != null && v != null) {
272
if (typeof from[key] == "string" && typeof v == "string") {
273
// A valid string patch, converting one string to another.
274
obj[key] = string_make_patch(from[key], v);
275
} else {
276
/* This should be impossible, due to the type checking that
277
I've added to set in this same commit. However, it can't
278
hurt to put in the above check on types, just in case.
279
https://github.com/sagemathinc/cocalc/issues/3625
280
A string col actually contains something that is not
281
a string. (Maybe it's due to loading something old?)
282
In any case, it's better to "best effort" this, rather
283
than to make the entire document be un-savable and
284
un-usable to the user.
285
We just give up and record no change in this case, so
286
when doc is read in later (or by another user), there
287
will be no weird corruption.
288
This case will probably go away completely when all
289
client code is written with proper typing.
290
*/
291
}
292
} else if (is_object(from[key]) && is_object(v)) {
293
// Changing from one map to another, where they are not
294
// equal -- can use a merge to make this more efficient.
295
// This is an important optimization, to avoid making
296
// patches HUGE.
297
obj[key] = map_merge_patch(from[key], v);
298
} else {
299
obj[key] = v;
300
}
301
}
302
}
303
add.push(obj);
304
});
305
}
306
307
const patch: any[] = [];
308
if (remove != null) {
309
patch.push(-1);
310
patch.push(remove);
311
}
312
if (add.length > 0) {
313
patch.push(1);
314
patch.push(add);
315
}
316
317
return patch;
318
}
319
320
// Given an immutable map f, return its restriction
321
// to the primary keys.
322
private primary_key_cols(
323
f: immutable.Map<string, any>,
324
): immutable.Map<string, any> {
325
return f.filter(
326
(_, k) => k != null && this.primary_keys.has(k),
327
) as immutable.Map<string, any>;
328
}
329
330
private select(where: WhereCondition): immutable.Set<number> {
331
if (immutable.Map.isMap(where)) {
332
// TODO: maybe do not allow?
333
where = where.toJS();
334
}
335
// Return immutable set with defined indexes the elts of @_records that
336
// satisfy the where condition.
337
const n: number = len(where);
338
let result: immutable.Set<number> | undefined = undefined;
339
for (const field in where) {
340
const value = where[field];
341
const index = this.indexes.get(field);
342
if (index == null) {
343
throw Error(`field '${field}' must be a primary key`);
344
}
345
const v: immutable.Set<number> | undefined = index.get(to_key(value));
346
// v may be undefined here
347
if (v == null) {
348
return immutable.Set(); // no matches for this field - done
349
}
350
if (n === 1) {
351
// no need to do further intersection
352
return v;
353
}
354
if (result != null) {
355
// intersect with what we've found so far via indexes.
356
result = result.intersect(v);
357
} else {
358
result = v;
359
}
360
}
361
if (result == null) {
362
// where condition must have been empty -- matches everything
363
return this.everything;
364
} else {
365
return result;
366
}
367
}
368
369
// Used internally for determining the set/where parts of an object.
370
private parse(obj: Map<string, any>): { where: jsmap; set: jsmap } {
371
const where: jsmap = {};
372
const set: jsmap = {};
373
for (const field in obj) {
374
const val = obj[field];
375
if (this.primary_keys.has(field)) {
376
if (val != null) {
377
where[field] = val;
378
}
379
} else {
380
set[field] = val;
381
}
382
}
383
return { where, set };
384
}
385
386
public set(obj: SetCondition | SetCondition[]): DBDocument {
387
// If you ever want to know exactly what set something, uncomment this:
388
// console.trace("set ", obj);
389
if (Array.isArray(obj)) {
390
let z: DBDocument = this;
391
for (const x of obj as SetCondition[]) {
392
z = z.set(x);
393
}
394
return z;
395
}
396
if (immutable.Map.isMap(obj)) {
397
// TODO: maybe do not allow?
398
// it is very clean/convenient to allow this
399
obj = (obj as immutable.Map<string, any>).toJS();
400
}
401
const { set, where } = this.parse(obj as Map<string, any>);
402
const matches = this.select(where);
403
let { changes } = this.change_tracker;
404
const first_match = matches != null ? matches.min() : undefined;
405
if (first_match != null) {
406
// edit the first existing record that matches
407
let record = this.records.get(first_match);
408
if (record == null) {
409
// make typescript happier.
410
throw Error("bug -- record can't be null");
411
}
412
const before = record;
413
for (const field in set) {
414
const value = set[field];
415
if (value === null) {
416
// null = how to delete fields
417
record = record.delete(field);
418
} else {
419
if (this.string_cols.has(field)) {
420
if (is_array(value)) {
421
// special case: a string patch
422
record = record.set(
423
field,
424
string_apply_patch(value, before.get(field, ""))[0],
425
);
426
continue;
427
}
428
if (typeof value != "string") {
429
// Putting this guard in to address
430
// https://github.com/sagemathinc/cocalc/issues/3625
431
// which was caused by some client code setting a string_col
432
// to something that is not a string. We'll next have
433
// to wait for this exception to be triggered someday...
434
throw Error(
435
`'${field}' must be a string, but tried to set to '${value}' of type ${typeof value}`,
436
);
437
}
438
// falls through to actually set it below.
439
}
440
let new_val;
441
const cur = record.get(field);
442
const change = immutable.fromJS(value);
443
if (immutable.Map.isMap(cur) && immutable.Map.isMap(change)) {
444
new_val = merge_set(cur, change);
445
} else {
446
new_val = change;
447
}
448
record = record.set(field, new_val);
449
}
450
}
451
452
if (!before.equals(record)) {
453
// there was an actual change, so update; doesn't change
454
// anything involving indexes.
455
changes = changes.add(this.primary_key_cols(record));
456
return new DBDocument(
457
this.primary_keys,
458
this.string_cols,
459
this.records.set(first_match, record),
460
this.everything,
461
this.indexes,
462
{ changes, from_db: this.change_tracker.from_db },
463
);
464
} else {
465
return this;
466
}
467
} else {
468
// The sparse array matches had nothing in it, so
469
// append a new record.
470
for (const field of this.string_cols) {
471
if (obj[field] != null && is_array(obj[field])) {
472
// It's a patch -- but there is nothing to patch,
473
// so discard this field
474
obj = copy_without(obj, field);
475
}
476
}
477
// remove null columns (null indicates delete)
478
const record = nonnull_cols(immutable.fromJS(obj));
479
changes = changes.add(this.primary_key_cols(record));
480
const records = this.records.push(record);
481
const n = records.size - 1;
482
const everything = this.everything.add(n);
483
// update indexes
484
let indexes = this.indexes;
485
for (const field of this.primary_keys) {
486
const val = obj[field];
487
if (val != null) {
488
let index = indexes.get(field);
489
if (index == null) {
490
index = immutable.Map();
491
}
492
const k = to_key(val);
493
let matches = index.get(k);
494
if (matches != null) {
495
matches = matches.add(n);
496
} else {
497
matches = immutable.Set([n]);
498
}
499
indexes = indexes.set(field, index.set(k, matches));
500
}
501
}
502
return new DBDocument(
503
this.primary_keys,
504
this.string_cols,
505
records,
506
everything,
507
indexes,
508
{ changes, from_db: this.change_tracker.from_db },
509
);
510
}
511
}
512
513
private delete_array(where: WhereCondition[]): DBDocument {
514
let z = this as DBDocument;
515
for (const x of where) {
516
z = z.delete(x);
517
}
518
return z;
519
}
520
521
public delete(where: WhereCondition | WhereCondition[]): DBDocument {
522
// console.log("delete #{JSON.stringify(where)}")
523
if (is_array(where)) {
524
return this.delete_array(where as WhereCondition[]);
525
}
526
// if where undefined, will delete everything
527
if (this.everything.size === 0) {
528
// no-op -- no data so deleting is trivial
529
return this;
530
}
531
let { changes } = this.change_tracker;
532
const remove = this.select(where);
533
if (remove.size === this.everything.size) {
534
// actually deleting everything; easy special cases
535
changes = changes.union(
536
this.records
537
.filter((record) => record != null)
538
.map(this.primary_key_cols),
539
);
540
return new DBDocument(
541
this.primary_keys,
542
this.string_cols,
543
undefined,
544
undefined,
545
undefined,
546
{ changes, from_db: this.change_tracker.from_db },
547
);
548
}
549
550
// remove matches from every index
551
let indexes = this.indexes;
552
for (const field of this.primary_keys) {
553
let index = indexes.get(field);
554
if (index == null) {
555
continue;
556
}
557
remove.forEach((n) => {
558
if (n == null) {
559
return;
560
}
561
const record = this.records.get(n);
562
if (record == null) {
563
return;
564
}
565
const val = record.get(field);
566
if (val == null) {
567
return;
568
}
569
const k = to_key(val);
570
const matches = index?.get(k)?.delete(n);
571
if (matches == null || index == null) return; // shouldn't happen
572
if (matches.size === 0) {
573
index = index.delete(k);
574
} else {
575
index = index.set(k, matches);
576
}
577
indexes = indexes.set(field, index);
578
});
579
}
580
581
// delete corresponding records (actually set to undefined to
582
// preserve index references).
583
let records = this.records;
584
remove.forEach((n) => {
585
if (n == null) {
586
return;
587
}
588
const record = records.get(n);
589
if (record == null) {
590
return;
591
}
592
changes = changes.add(this.primary_key_cols(record));
593
records = records.set(n, undefined);
594
});
595
596
const everything = this.everything.subtract(remove);
597
598
return new DBDocument(
599
this.primary_keys,
600
this.string_cols,
601
records,
602
everything,
603
indexes,
604
{ changes, from_db: this.change_tracker.from_db },
605
);
606
}
607
608
// Returns immutable list of all matches
609
public get(where: WhereCondition): Records {
610
const matches = this.select(where);
611
if (matches == null) {
612
return immutable.List();
613
}
614
// The "as" is because Typescript just makes the result of
615
// filter some more generic type (but it isn't).
616
return immutable.List(
617
this.records.filter((_, n) => n != null && matches.includes(n)),
618
);
619
}
620
621
// Returns the first match, or undefined if there are no matches
622
get_one(where: WhereCondition): Record | undefined {
623
// TODO: couldn't select have a shortcut to return once one
624
// result is found.
625
const matches = this.select(where);
626
if (matches == null) {
627
return;
628
}
629
const min = matches.min();
630
if (min == null) return;
631
return this.records.get(min);
632
}
633
634
// x = javascript object
635
private primary_key_part(x: jsmap): jsmap {
636
const where: jsmap = {};
637
for (const k in x) {
638
const v = x[k];
639
if (this.primary_keys.has(k)) {
640
where[k] = v;
641
}
642
}
643
return where;
644
}
645
646
// Return immutable set of primary key parts of records that
647
// change in going from this to other.
648
public changed_keys(other: DBDocument): immutable.Set<Record> {
649
if (this.records === other.records) {
650
// identical -- obviously, nothing changed.
651
return immutable.Set();
652
}
653
// Get the defined records; there may be undefined ones due
654
// to lazy delete.
655
let t0: immutable.Set<Record> = immutable.Set(
656
immutable.Set(this.records).filter((x) => x != null),
657
);
658
let t1: immutable.Set<Record> = immutable.Set(
659
immutable.Set(other.records).filter((x) => x != null),
660
);
661
662
// Remove the common intersection -- nothing going on there.
663
// Doing this greatly reduces the complexity in the common
664
// case in which little has changed
665
const common = t0.intersect(t1);
666
t0 = t0.subtract(common);
667
t1 = t1.subtract(common);
668
669
// compute the key parts of t0 and t1 as sets
670
const k0 = immutable.Set(t0.map(this.primary_key_cols));
671
const k1 = immutable.Set(t1.map(this.primary_key_cols));
672
673
return immutable.Set(k0.union(k1));
674
}
675
676
public changes(prev?: DBDocument): immutable.Set<Record> {
677
// CRITICAL TODO! Make this efficient using this.change_tracker!!!
678
if (prev == null) {
679
return immutable.Set(
680
immutable
681
.Set(this.records)
682
.filter((x) => x != null)
683
.map(this.primary_key_cols),
684
);
685
}
686
return this.changed_keys(prev);
687
}
688
689
public count(): number {
690
return this.size;
691
}
692
}
693
694
/*
695
The underlying string representation has one JSON object
696
per line. The order doesn't matter.
697
698
WARNING: The primary keys and string cols are NOT stored
699
in the string representation! That is metadata that must
700
somehow be tracked separately. (Maybe this should be changed).
701
702
You can't store null since null is used to signify deleting
703
(in set queries). You can't store undefined or Date objects
704
due to JSON.
705
706
Also, note that the primary_keys and string_cols are string[]
707
rather than Set of strings, which is annoyingly inconsistent
708
with DBDocument above.
709
*/
710
711
export function from_str(
712
s: string,
713
primary_keys: string[],
714
string_cols: string[],
715
): DBDocument {
716
const obj: jsmap[] = [];
717
for (const line of s.split("\n")) {
718
if (line.length > 0) {
719
try {
720
const x = JSON.parse(line);
721
if (typeof x === "object") {
722
obj.push(x);
723
} else {
724
throw Error("each line must be an object");
725
}
726
} catch (e) {
727
console.warn(`CORRUPT db-doc string: ${e} -- skipping '${line}'`);
728
}
729
}
730
}
731
return new DBDocument(
732
new Set(primary_keys),
733
new Set(string_cols),
734
immutable.fromJS(obj) as Records,
735
);
736
}
737
738