CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutSign UpSign In
sagemathinc

Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.

GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/sync/editor/generic/sync-doc.ts
Views: 687
1
/*
2
* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.
3
* License: MS-RSL – see LICENSE.md for details
4
*/
5
6
/*
7
SyncDoc -- the core class for editing with a synchronized document.
8
9
This code supports both string-doc and db-doc, for editing both
10
strings and small database tables efficiently, with history,
11
undo, save to disk, etc.
12
13
This code is run *both* in browser clients and under node.js
14
in projects, and behaves slightly differently in each case.
15
16
EVENTS:
17
18
- before-change: fired before merging in changes from upstream
19
- ... TODO
20
*/
21
22
/* OFFLINE_THRESH_S - If the client becomes disconnected from
23
the backend for more than this long then---on reconnect---do
24
extra work to ensure that all snapshots are up to date (in
25
case snapshots were made when we were offline), and mark the
26
sent field of patches that weren't saved. I.e., we rebase
27
all offline changes. */
28
const OFFLINE_THRESH_S = 5 * 60; // 5 minutes.
29
30
/* How often the local hub will autosave this file to disk if
31
it has it open and there are unsaved changes. This is very
32
important since it ensures that a user that edits a file but
33
doesn't click "Save" and closes their browser (right after
34
their edits have gone to the database), still has their
35
file saved to disk soon. This is important, e.g., for homework
36
getting collected and not missing the last few changes. It turns
37
out this is what people expect.
38
Set to 0 to disable. (But don't do that.) */
39
const FILE_SERVER_AUTOSAVE_S = 45;
40
// const FILE_SERVER_AUTOSAVE_S = 5;
41
42
// How big of files we allow users to open using syncstrings.
43
const MAX_FILE_SIZE_MB = 8;
44
45
// How frequently to check if file is or is not read only.
46
// The filesystem watcher is NOT sufficient for this, because
47
// it is NOT triggered on permissions changes. Thus we must
48
// poll for read only status periodically, unfortunately.
49
const READ_ONLY_CHECK_INTERVAL_MS = 7500;
50
51
// This parameter determines throttling when broadcasting cursor position
52
// updates. Make this larger to reduce bandwidth at the expense of making
53
// cursors less responsive.
54
const CURSOR_THROTTLE_MS = 750;
55
56
// Ignore file changes for this long after save to disk.
57
const RECENT_SAVE_TO_DISK_MS = 2000;
58
59
import {
60
COMPUTE_THRESH_MS,
61
COMPUTER_SERVER_CURSOR_TYPE,
62
decodeUUIDtoNum,
63
SYNCDB_PARAMS as COMPUTE_SERVE_MANAGER_SYNCDB_PARAMS,
64
} from "@cocalc/util/compute/manager";
65
66
type XPatch = any;
67
68
import { reuseInFlight } from "@cocalc/util/reuse-in-flight";
69
import { SyncTable } from "@cocalc/sync/table/synctable";
70
import {
71
callback2,
72
cancel_scheduled,
73
once,
74
retry_until_success,
75
reuse_in_flight_methods,
76
} from "@cocalc/util/async-utils";
77
import { wait } from "@cocalc/util/async-wait";
78
import {
79
auxFileToOriginal,
80
ISO_to_Date,
81
assertDefined,
82
close,
83
cmp_Date,
84
endswith,
85
filename_extension,
86
hash_string,
87
is_date,
88
keys,
89
minutes_ago,
90
uuid,
91
} from "@cocalc/util/misc";
92
import * as schema from "@cocalc/util/schema";
93
import { delay } from "awaiting";
94
import { EventEmitter } from "events";
95
import { Map, fromJS } from "immutable";
96
import { debounce, throttle } from "lodash";
97
import { Evaluator } from "./evaluator";
98
import { HistoryEntry, HistoryExportOptions, export_history } from "./export";
99
import { IpywidgetsState } from "./ipywidgets-state";
100
import { SortedPatchList } from "./sorted-patch-list";
101
import {
102
Client,
103
CompressedPatch,
104
DocType,
105
Document,
106
FileWatcher,
107
Patch,
108
} from "./types";
109
import { patch_cmp } from "./util";
110
111
export type State = "init" | "ready" | "closed";
112
export type DataServer = "project" | "database";
113
114
export interface SyncOpts0 {
115
project_id: string;
116
path: string;
117
client: Client;
118
patch_interval?: number;
119
120
// file_use_interval defaults to 60000.
121
// Specify 0 to disable.
122
file_use_interval?: number;
123
124
string_id?: string;
125
cursors?: boolean;
126
change_throttle?: number;
127
128
// persistent backend session in project, so only close
129
// backend when explicitly requested:
130
persistent?: boolean;
131
132
// If true, entire sync-doc is assumed ephemeral, in the
133
// sense that no edit history gets saved via patches to
134
// the database. The one syncstring record for coordinating
135
// users does get created in the database.
136
ephemeral?: boolean;
137
138
// which data/changefeed server to use
139
data_server?: DataServer;
140
}
141
142
export interface SyncOpts extends SyncOpts0 {
143
from_str: (str: string) => Document;
144
doctype: DocType;
145
}
146
147
export interface UndoState {
148
my_times: Date[];
149
pointer: number;
150
without: Date[];
151
final?: CompressedPatch;
152
}
153
154
export class SyncDoc extends EventEmitter {
155
public readonly project_id: string; // project_id that contains the doc
156
public readonly path: string; // path of the file corresponding to the doc
157
private string_id: string;
158
private my_user_id: number;
159
160
// This id is used for equality test and caching.
161
private id: string = uuid();
162
163
private client: Client;
164
private _from_str: (str: string) => Document; // creates a doc from a string.
165
166
// Throttling of incoming upstream patches from project to client.
167
private patch_interval: number = 250;
168
169
// This is what's actually output by setInterval -- it's
170
// not an amount of time.
171
private fileserver_autosave_timer: number = 0;
172
173
private read_only_timer: number = 0;
174
175
// throttling of change events -- e.g., is useful for course
176
// editor where we have hundreds of changes and the UI gets
177
// overloaded unless we throttle and group them.
178
private change_throttle: number = 0;
179
180
// file_use_interval throttle: default is 60s for everything
181
private file_use_interval: number;
182
private throttled_file_use?: Function;
183
184
private cursors: boolean = false; // if true, also provide cursor tracking functionality
185
private cursor_map: Map<string, any> = Map();
186
private cursor_last_time: Date = new Date(0);
187
188
// doctype: object describing document constructor
189
// (used by project to open file)
190
private doctype: DocType;
191
192
private state: State = "init";
193
194
private syncstring_table: SyncTable;
195
private patches_table: SyncTable;
196
private cursors_table: SyncTable;
197
198
public evaluator?: Evaluator;
199
200
public ipywidgets_state?: IpywidgetsState;
201
202
private patch_list?: SortedPatchList;
203
204
private last: Document;
205
private doc: Document;
206
private before_change?: Document;
207
208
private last_user_change: Date = minutes_ago(60);
209
private last_save_to_disk_time: Date = new Date(0);
210
211
private last_snapshot: Date | undefined;
212
private snapshot_interval: number;
213
214
private users: string[];
215
216
private settings: Map<string, any> = Map();
217
218
private syncstring_save_state: string = "";
219
private load_full_history_done: boolean = false;
220
221
// patches that this client made during this editing session.
222
private my_patches: { [time: string]: XPatch } = {};
223
224
private watch_path?: string;
225
private file_watcher?: FileWatcher;
226
227
private handle_patch_update_queue_running: boolean;
228
private patch_update_queue: string[] = [];
229
230
private undo_state: UndoState | undefined;
231
232
private save_patch_prev: Date | undefined;
233
234
private save_to_disk_start_ctime: number | undefined;
235
private save_to_disk_end_ctime: number | undefined;
236
237
private persistent: boolean = false;
238
public readonly data_server: DataServer = "project";
239
240
private last_has_unsaved_changes?: boolean = undefined;
241
242
private ephemeral: boolean = false;
243
244
private sync_is_disabled: boolean = false;
245
private delay_sync_timer: any;
246
247
// static because we want exactly one across all docs!
248
private static computeServerManagerDoc?: SyncDoc;
249
250
constructor(opts: SyncOpts) {
251
super();
252
if (opts.string_id === undefined) {
253
this.string_id = schema.client_db.sha1(opts.project_id, opts.path);
254
} else {
255
this.string_id = opts.string_id;
256
}
257
258
for (const field of [
259
"project_id",
260
"path",
261
"client",
262
"patch_interval",
263
"file_use_interval",
264
"change_throttle",
265
"cursors",
266
"doctype",
267
"from_patch_str",
268
"persistent",
269
"data_server",
270
"ephemeral",
271
]) {
272
if (opts[field] != undefined) {
273
this[field] = opts[field];
274
}
275
}
276
if (this.ephemeral) {
277
// So the doctype written to the database reflects the
278
// ephemeral state. Here ephemeral determines whether
279
// or not patches are written to the database by the
280
// project.
281
this.doctype.opts = { ...this.doctype.opts, ephemeral: true };
282
}
283
if (this.cursors) {
284
// similarly to ephemeral, but for cursors. We track them
285
// on the backend since they can also be very useful, e.g.,
286
// with jupyter they are used for connecting remote compute,
287
// and **should** also be used for broadcasting load and other
288
// status information (TODO).
289
this.doctype.opts = { ...this.doctype.opts, cursors: true };
290
}
291
this._from_str = opts.from_str;
292
293
// Initialize to time when we create the syncstring, so we don't
294
// see our own cursor when we refresh the browser (before we move
295
// to update this).
296
this.cursor_last_time = this.client?.server_time();
297
298
reuse_in_flight_methods(this, [
299
"save",
300
"save_to_disk",
301
"load_from_disk",
302
"handle_patch_update_queue",
303
]);
304
305
if (this.change_throttle) {
306
this.emit_change = throttle(this.emit_change, this.change_throttle);
307
}
308
309
this.setMaxListeners(100);
310
311
this.init();
312
}
313
314
/*
315
Initialize everything.
316
This should be called *exactly* once by the constructor,
317
and no other time. It tries to set everything up. If
318
the browser isn't connected to the network, it'll wait
319
until it is (however long, etc.). If this fails, it closes
320
this SyncDoc.
321
*/
322
private async init(): Promise<void> {
323
this.assert_not_closed("init");
324
const log = this.dbg("init");
325
326
log("initializing all tables...");
327
try {
328
//const t0 = new Date();
329
await this.init_all();
330
//console.log( // TODO remove at some point.
331
// `time to open file ${this.path}: ${Date.now() - t0.valueOf()}`
332
//);
333
} catch (err) {
334
if (this.state == "closed") {
335
return;
336
}
337
log(`WARNING -- error initializing ${err}`);
338
// completely normal that this could happen on frontend - it just means
339
// that we closed the file before finished opening it...
340
if (this.state != ("closed" as State)) {
341
log(
342
"Error -- NOT caused by closing during the init_all, so we report it.",
343
);
344
this.emit("error", err);
345
}
346
await this.close();
347
return;
348
}
349
350
// Success -- everything perfectly initialized with no issues.
351
this.set_state("ready");
352
this.init_watch();
353
this.emit_change(); // from nothing to something.
354
}
355
356
// True if this client is responsible for managing
357
// the state of this document with respect to
358
// the file system. By default, the project is responsible,
359
// but it could be something else (e.g., a compute server!). It's
360
// important that whatever algorithm determines this, it is
361
// a function of state that is eventually consistent.
362
// IMPORTANT: whether or not we are the file server can
363
// change over time, so if you call isFileServer and
364
// set something up (e.g., autosave or a watcher), based
365
// on the result, you need to clear it when the state
366
// changes. See the function handleComputeServerManagerChange.
367
private isFileServer = reuseInFlight(async () => {
368
if (this.client.is_browser()) {
369
// browser is never the file server (yet), and doesn't need to do
370
// anything related to watching for changes in state.
371
// Someday via webassembly or browsers making users files availabl,
372
// etc., we will have this. Not today.
373
return false;
374
}
375
const computeServerManagerDoc = this.getComputeServerManagerDoc();
376
const log = this.dbg("isFileServer");
377
if (computeServerManagerDoc == null) {
378
log("not using compute server manager for this doc");
379
return this.client.is_project();
380
}
381
382
const state = computeServerManagerDoc.get_state();
383
log("compute server manager doc state: ", state);
384
if (state == "closed") {
385
log("compute server manager is closed");
386
// something really messed up
387
return this.client.is_project();
388
}
389
if (state != "ready") {
390
try {
391
log(
392
"waiting for compute server manager doc to be ready; current state=",
393
state,
394
);
395
await once(computeServerManagerDoc, "ready", 15000);
396
log("compute server manager is ready");
397
} catch (err) {
398
log(
399
"WARNING -- failed to initialize computeServerManagerDoc -- err=",
400
err,
401
);
402
return this.client.is_project();
403
}
404
}
405
406
// id of who the user *wants* to be the file server.
407
const path = this.getFileServerPath();
408
const fileServerId =
409
computeServerManagerDoc.get_one({ path })?.get("id") ?? 0;
410
if (this.client.is_project()) {
411
log(
412
"we are project, so we are fileserver if fileServerId=0 and it is ",
413
fileServerId,
414
);
415
return fileServerId == 0;
416
}
417
// at this point we have to be a compute server
418
const computeServerId = decodeUUIDtoNum(this.client.client_id());
419
// this is usually true -- but might not be if we are switching
420
// directly from one compute server to another.
421
log("we are compute server and ", { fileServerId, computeServerId });
422
return fileServerId == computeServerId;
423
});
424
425
private getFileServerPath = () => {
426
if (this.path?.endsWith(".sage-jupyter2")) {
427
// treating jupyter as a weird special case here.
428
return auxFileToOriginal(this.path);
429
}
430
return this.path;
431
};
432
433
private getComputeServerManagerDoc = () => {
434
if (this.path == COMPUTE_SERVE_MANAGER_SYNCDB_PARAMS.path) {
435
// don't want to recursively explode!
436
return null;
437
}
438
if (SyncDoc.computeServerManagerDoc == null) {
439
if (this.client.is_project()) {
440
// @ts-ignore: TODO!
441
SyncDoc.computeServerManagerDoc = this.client.syncdoc({
442
path: COMPUTE_SERVE_MANAGER_SYNCDB_PARAMS.path,
443
});
444
} else {
445
// @ts-ignore: TODO!
446
SyncDoc.computeServerManagerDoc = this.client.sync_client.sync_db({
447
project_id: this.project_id,
448
...COMPUTE_SERVE_MANAGER_SYNCDB_PARAMS,
449
});
450
}
451
if (
452
SyncDoc.computeServerManagerDoc != null &&
453
!this.client.is_browser()
454
) {
455
// start watching for state changes
456
SyncDoc.computeServerManagerDoc.on(
457
"change",
458
this.handleComputeServerManagerChange,
459
);
460
}
461
}
462
return SyncDoc.computeServerManagerDoc;
463
};
464
465
private handleComputeServerManagerChange = async (keys) => {
466
if (SyncDoc.computeServerManagerDoc == null) {
467
return;
468
}
469
let relevant = false;
470
for (const key of keys ?? []) {
471
if (key.get("path") == this.path) {
472
relevant = true;
473
break;
474
}
475
}
476
if (!relevant) {
477
return;
478
}
479
const path = this.getFileServerPath();
480
const fileServerId =
481
SyncDoc.computeServerManagerDoc.get_one({ path })?.get("id") ?? 0;
482
const ourId = this.client.is_project()
483
? 0
484
: decodeUUIDtoNum(this.client.client_id());
485
// we are considering ourself the file server already if we have
486
// either a watcher or autosave on.
487
const thinkWeAreFileServer =
488
this.file_watcher != null || this.fileserver_autosave_timer;
489
const weAreFileServer = fileServerId == ourId;
490
if (thinkWeAreFileServer != weAreFileServer) {
491
// life has changed! Let's adapt.
492
if (thinkWeAreFileServer) {
493
// we were acting as the file server, but now we are not.
494
await this.save_to_disk_filesystem_owner();
495
// Stop doing things we are no longer supposed to do.
496
clearInterval(this.fileserver_autosave_timer as any);
497
this.fileserver_autosave_timer = 0;
498
// stop watching filesystem
499
await this.update_watch_path();
500
} else {
501
// load our state from the disk
502
await this.load_from_disk();
503
// we were not acting as the file server, but now we need. Let's
504
// step up to the plate.
505
// start watching filesystem
506
await this.update_watch_path(this.path);
507
// enable autosave
508
await this.init_file_autosave();
509
}
510
}
511
};
512
513
// Return id of ACTIVE remote compute server, if one is connected and pinging, or 0
514
// if none is connected. This is used by Jupyter to determine who
515
// should evaluate code.
516
// We always take the smallest id of the remote
517
// compute servers, in case there is more than one, so exactly one of them
518
// takes control. Always returns 0 if cursors are not enabled for this
519
// document, since the cursors table is used to coordinate the compute
520
// server.
521
getComputeServerId = (): number => {
522
if (!this.cursors) {
523
return 0;
524
}
525
// This info is in the "cursors" table instead of the document itself
526
// to avoid wasting space in the database longterm. Basically a remote
527
// Jupyter client that can provide compute announces this by reporting it's
528
// cursor to look a certain way.
529
const cursors = this.get_cursors({
530
maxAge: COMPUTE_THRESH_MS,
531
// don't exclude self since getComputeServerId called from the compute
532
// server also to know if it is the chosen one.
533
excludeSelf: "never",
534
});
535
const dbg = this.dbg("getComputeServerId");
536
dbg("num cursors = ", cursors.size);
537
let minId = Infinity;
538
// NOTE: similar code is in frontend/jupyter/cursor-manager.ts
539
for (const [client_id, cursor] of cursors) {
540
if (cursor.getIn(["locs", 0, "type"]) == COMPUTER_SERVER_CURSOR_TYPE) {
541
try {
542
minId = Math.min(minId, decodeUUIDtoNum(client_id));
543
} catch (err) {
544
// this should never happen unless a client were being malicious.
545
dbg(
546
"WARNING -- client_id should encode server id, but is",
547
client_id,
548
);
549
}
550
}
551
}
552
553
return isFinite(minId) ? minId : 0;
554
};
555
556
registerAsComputeServer = () => {
557
this.setCursorLocsNoThrottle([{ type: COMPUTER_SERVER_CURSOR_TYPE }]);
558
};
559
560
/* Set this user's cursors to the given locs. */
561
setCursorLocsNoThrottle = async (
562
// locs is 'any' and not any[] because of a codemirror syntax highlighting bug!
563
locs: any,
564
side_effect: boolean = false,
565
) => {
566
if (this.state != "ready") {
567
return;
568
}
569
if (this.cursors_table == null) {
570
if (!this.cursors) {
571
throw Error("cursors are not enabled");
572
}
573
// table not initialized yet
574
return;
575
}
576
const x: {
577
string_id: string;
578
user_id: number;
579
locs: any[];
580
time?: Date;
581
} = {
582
string_id: this.string_id,
583
user_id: this.my_user_id,
584
locs,
585
};
586
const now = this.client.server_time();
587
if (!side_effect || (x.time ?? now) >= now) {
588
// the now comparison above is in case the cursor time
589
// is in the future (due to clock issues) -- always fix that.
590
x.time = now;
591
}
592
if (x.time != null) {
593
// will actually always be non-null due to above
594
this.cursor_last_time = x.time;
595
}
596
this.cursors_table.set(x, "none");
597
await this.cursors_table.save();
598
};
599
600
set_cursor_locs = throttle(this.setCursorLocsNoThrottle, CURSOR_THROTTLE_MS, {
601
leading: true,
602
trailing: true,
603
});
604
605
private init_file_use_interval(): void {
606
if (this.file_use_interval == null) {
607
this.file_use_interval = 60 * 1000;
608
}
609
610
if (!this.file_use_interval || !this.client.is_browser()) {
611
// file_use_interval has to be nonzero, and we only do
612
// this for browser user.
613
return;
614
}
615
616
const file_use = async () => {
617
await delay(100); // wait a little so my_patches and gets updated.
618
// We ONLY count this and record that the file was
619
// edited if there was an actual change record in the
620
// patches log, by this user, since last time.
621
let user_is_active: boolean = false;
622
for (const tm in this.my_patches) {
623
if (new Date(parseInt(tm)) > this.last_user_change) {
624
user_is_active = true;
625
break;
626
}
627
}
628
if (!user_is_active) {
629
return;
630
}
631
this.last_user_change = new Date();
632
this.client.mark_file({
633
project_id: this.project_id,
634
path: this.path,
635
action: "edit",
636
ttl: this.file_use_interval,
637
});
638
};
639
this.throttled_file_use = throttle(file_use, this.file_use_interval, {
640
leading: true,
641
});
642
643
this.on("user-change", this.throttled_file_use as any);
644
}
645
646
private set_state(state: State): void {
647
this.state = state;
648
this.emit(state);
649
}
650
651
public get_state = (): State => {
652
return this.state;
653
};
654
655
public get_project_id = (): string => {
656
return this.project_id;
657
};
658
659
public get_path = (): string => {
660
return this.path;
661
};
662
663
public get_string_id = (): string => {
664
return this.string_id;
665
};
666
667
public get_my_user_id = (): number => {
668
return this.my_user_id != null ? this.my_user_id : 0;
669
};
670
671
// This gets used by clients that are connected to a backend
672
// with state in the project (e.g., jupyter). Basically this
673
// is a special websocket channel just for this syncdoc, which
674
// uses the cursors table.
675
public sendMessageToProject = async (data) => {
676
const send = this.patches_table?.sendMessageToProject;
677
if (send == null || this.patches_table.channel == null) {
678
throw Error("sending messages to project not available");
679
}
680
if (!this.patches_table.channel.is_connected()) {
681
await once(this.patches_table.channel, "connected");
682
}
683
send(data);
684
};
685
686
private assert_not_closed(desc: string): void {
687
if (this.state === "closed") {
688
//console.trace();
689
throw Error(`must not be closed -- ${desc}`);
690
}
691
}
692
693
public set_doc = (doc: Document, exit_undo_mode: boolean = true): void => {
694
if (doc.is_equal(this.doc)) {
695
// no change.
696
return;
697
}
698
if (exit_undo_mode) this.undo_state = undefined;
699
// console.log(`sync-doc.set_doc("${doc.to_str()}")`);
700
this.doc = doc;
701
702
// debounced, so don't immediately alert, in case there are many
703
// more sets comming in the same loop:
704
this.emit_change_debounced();
705
};
706
707
// Convenience function to avoid having to do
708
// get_doc and set_doc constantly.
709
public set = (x: any): void => {
710
this.set_doc(this.doc.set(x));
711
};
712
713
public delete = (x?: any): void => {
714
this.set_doc(this.doc.delete(x));
715
};
716
717
public get = (x?: any): any => {
718
return this.doc.get(x);
719
};
720
721
public get_one(x?: any): any {
722
return this.doc.get_one(x);
723
}
724
725
// Return underlying document, or undefined if document
726
// hasn't been set yet.
727
public get_doc = (): Document => {
728
if (this.doc == null) {
729
throw Error("doc must be set");
730
}
731
return this.doc;
732
};
733
734
// Set this doc from its string representation.
735
public from_str = (value: string): void => {
736
// console.log(`sync-doc.from_str("${value}")`);
737
this.doc = this._from_str(value);
738
};
739
740
// Return string representation of this doc,
741
// or exception if not yet ready.
742
public to_str = (): string => {
743
if (this.doc == null) {
744
throw Error("doc must be set");
745
}
746
return this.doc.to_str();
747
};
748
749
public count = (): number => {
750
return this.doc.count();
751
};
752
753
// Version of the document at a given point in time; if no
754
// time specified, gives the version right now.
755
// If not fully initialized, will throw exception.
756
public version = (time?: Date): Document => {
757
this.assert_table_is_ready("patches");
758
assertDefined(this.patch_list);
759
return this.patch_list.value(time);
760
};
761
762
/* Compute version of document if the patches at the given times
763
were simply not included. This is a building block that is
764
used for implementing undo functionality for client editors. */
765
public version_without = (times: Date[]): Document => {
766
this.assert_table_is_ready("patches");
767
assertDefined(this.patch_list);
768
return this.patch_list.value(undefined, undefined, times);
769
};
770
771
// Revert document to what it was at the given point in time.
772
// There doesn't have to be a patch at exactly that point in
773
// time -- if there isn't it just uses the patch before that
774
// point in time.
775
public revert = (time: Date): void => {
776
this.set_doc(this.version(time));
777
};
778
779
/* Undo/redo public api.
780
Calling this.undo and this.redo returns the version of
781
the document after the undo or redo operation, and records
782
a commit changing to that.
783
The first time calling this.undo switches into undo
784
state in which additional
785
calls to undo/redo move up and down the stack of changes made
786
by this user during this session.
787
788
Call this.exit_undo_mode() to exit undo/redo mode.
789
790
Undo and redo *only* impact changes made by this user during
791
this session. Other users edits are unaffected, and work by
792
this same user working from another browser tab or session is
793
also unaffected.
794
795
Finally, undo of a past patch by definition means "the state
796
of the document" if that patch was not applied. The impact
797
of undo is NOT that the patch is removed from the patch history.
798
Instead, it records a new patch that is what would have happened
799
had we replayed history with the patches being undone not there.
800
801
Doing any set_doc explicitly exits undo mode automatically.
802
*/
803
public undo = (): Document => {
804
const prev = this._undo();
805
this.set_doc(prev, false);
806
this.commit();
807
return prev;
808
};
809
810
public redo = (): Document => {
811
const next = this._redo();
812
this.set_doc(next, false);
813
this.commit();
814
return next;
815
};
816
817
private _undo(): Document {
818
this.assert_is_ready("_undo");
819
let state = this.undo_state;
820
if (state == null) {
821
// not in undo mode
822
state = this.undo_state = this.init_undo_state();
823
}
824
if (state.pointer === state.my_times.length) {
825
// pointing at live state (e.g., happens on entering undo mode)
826
const value: Document = this.version(); // last saved version
827
const live: Document = this.doc;
828
if (!live.is_equal(value)) {
829
// User had unsaved changes, so last undo is to revert to version without those.
830
state.final = value.make_patch(live); // live redo if needed
831
state.pointer -= 1; // most recent timestamp
832
return value;
833
} else {
834
// User had no unsaved changes, so last undo is version without last saved change.
835
const tm = state.my_times[state.pointer - 1];
836
state.pointer -= 2;
837
if (tm != null) {
838
state.without.push(tm);
839
return this.version_without(state.without);
840
} else {
841
// no undo information during this session
842
return value;
843
}
844
}
845
} else {
846
// pointing at particular timestamp in the past
847
if (state.pointer >= 0) {
848
// there is still more to undo
849
state.without.push(state.my_times[state.pointer]);
850
state.pointer -= 1;
851
}
852
return this.version_without(state.without);
853
}
854
}
855
856
private _redo(): Document {
857
this.assert_is_ready("_redo");
858
const state = this.undo_state;
859
if (state == null) {
860
// nothing to do but return latest live version
861
return this.get_doc();
862
}
863
if (state.pointer === state.my_times.length) {
864
// pointing at live state -- nothing to do
865
return this.get_doc();
866
} else if (state.pointer === state.my_times.length - 1) {
867
// one back from live state, so apply unsaved patch to live version
868
const value = this.version();
869
if (value == null) {
870
// see remark in undo -- do nothing
871
return this.get_doc();
872
}
873
state.pointer += 1;
874
return value.apply_patch(state.final);
875
} else {
876
// at least two back from live state
877
state.without.pop();
878
state.pointer += 1;
879
if (state.final == null && state.pointer === state.my_times.length - 1) {
880
// special case when there wasn't any live change
881
state.pointer += 1;
882
}
883
return this.version_without(state.without);
884
}
885
}
886
887
public in_undo_mode = (): boolean => {
888
return this.undo_state != null;
889
};
890
891
public exit_undo_mode = (): void => {
892
this.undo_state = undefined;
893
};
894
895
private init_undo_state(): UndoState {
896
if (this.undo_state != null) {
897
return this.undo_state;
898
}
899
const my_times = keys(this.my_patches).map((x) => new Date(parseInt(x)));
900
my_times.sort(cmp_Date);
901
return (this.undo_state = {
902
my_times,
903
pointer: my_times.length,
904
without: [],
905
});
906
}
907
908
private save_to_disk_autosave = async (): Promise<void> => {
909
if (this.state !== "ready") {
910
return;
911
}
912
const dbg = this.dbg("save_to_disk_autosave");
913
dbg();
914
try {
915
await this.save_to_disk();
916
} catch (err) {
917
dbg(`failed -- ${err}`);
918
}
919
};
920
921
/* Make it so the local hub project will automatically save
922
the file to disk periodically. */
923
private async init_file_autosave() {
924
// Do not autosave sagews until we resolve
925
// https://github.com/sagemathinc/cocalc/issues/974
926
// Similarly, do not autosave ipynb because of
927
// https://github.com/sagemathinc/cocalc/issues/5216
928
if (
929
!FILE_SERVER_AUTOSAVE_S ||
930
!(await this.isFileServer()) ||
931
this.fileserver_autosave_timer ||
932
endswith(this.path, ".sagews") ||
933
endswith(this.path, ".ipynb.sage-jupyter2")
934
) {
935
return;
936
}
937
938
// Explicit cast due to node vs browser typings.
939
this.fileserver_autosave_timer = <any>(
940
setInterval(this.save_to_disk_autosave, FILE_SERVER_AUTOSAVE_S * 1000)
941
);
942
}
943
944
// account_id of the user who made the edit at
945
// the given point in time.
946
public account_id = (time: Date): string => {
947
this.assert_is_ready("account_id");
948
return this.users[this.user_id(time)];
949
};
950
951
/* Approximate time when patch with given timestamp was
952
actually sent to the server; returns undefined if time
953
sent is approximately the timestamp time. Only defined
954
when there is a significant difference, due to editing
955
when offline! */
956
public time_sent = (time: Date): Date | undefined => {
957
this.assert_table_is_ready("patches");
958
assertDefined(this.patch_list);
959
return this.patch_list.time_sent(time);
960
};
961
962
// Integer index of user who made the edit at given
963
// point in time.
964
public user_id = (time: Date): number => {
965
this.assert_table_is_ready("patches");
966
assertDefined(this.patch_list);
967
return this.patch_list.user_id(time);
968
};
969
970
private syncstring_table_get_one(): Map<string, any> {
971
if (this.syncstring_table == null) {
972
throw Error("syncstring_table must be defined");
973
}
974
const t = this.syncstring_table.get_one();
975
if (t == null) {
976
// project has not initialized it yet.
977
return Map();
978
}
979
return t;
980
}
981
982
/* The project calls set_initialized once it has checked for
983
the file on disk; this way the frontend knows that the
984
syncstring has been initialized in the database, and also
985
if there was an error doing the check.
986
*/
987
private async set_initialized(
988
error: string,
989
read_only: boolean,
990
size: number,
991
): Promise<void> {
992
this.assert_table_is_ready("syncstring");
993
this.dbg("set_initialized")({ error, read_only, size });
994
const init = { time: this.client.server_time(), size, error };
995
await this.set_syncstring_table({
996
init,
997
read_only,
998
last_active: this.client.server_time(),
999
});
1000
}
1001
1002
/* List of timestamps of the versions of this string in the sync
1003
table that we opened to start editing (so starts with what was
1004
the most recent snapshot when we started). The list of timestamps
1005
is sorted from oldest to newest. */
1006
public versions = (): Date[] => {
1007
this.assert_table_is_ready("patches");
1008
const v: Date[] = [];
1009
const s: Map<string, any> | undefined = this.patches_table.get();
1010
if (s == null) {
1011
// shouldn't happen do to assert_is_ready above.
1012
throw Error("patches_table must be initialized");
1013
}
1014
s.map((x, _) => {
1015
v.push(x.get("time"));
1016
});
1017
v.sort(cmp_Date);
1018
return v;
1019
};
1020
1021
/* List of all known timestamps of versions of this string, including
1022
possibly much older versions than returned by this.versions(), in
1023
case the full history has been loaded. The list of timestamps
1024
is sorted from oldest to newest. */
1025
public all_versions = (): Date[] => {
1026
this.assert_table_is_ready("patches");
1027
assertDefined(this.patch_list);
1028
return this.patch_list.versions();
1029
};
1030
1031
public last_changed = (): Date => {
1032
const v = this.versions();
1033
if (v.length > 0) {
1034
return v[v.length - 1];
1035
} else {
1036
return new Date(0);
1037
}
1038
};
1039
1040
private init_table_close_handlers(): void {
1041
for (const x of ["syncstring", "patches", "cursors"]) {
1042
const t = this[x + "_table"];
1043
if (t != null) {
1044
t.on("close", () => this.close());
1045
}
1046
}
1047
}
1048
1049
// Close synchronized editing of this string; this stops listening
1050
// for changes and stops broadcasting changes.
1051
public close = reuseInFlight(async () => {
1052
if (this.state == "closed") {
1053
return;
1054
}
1055
const dbg = this.dbg("close");
1056
dbg("close");
1057
if (this.client.is_browser() && this.state == "ready") {
1058
try {
1059
await this.save_to_disk();
1060
} catch (err) {
1061
// has to be non-fatal since we are closing the document,
1062
// and of couse we need to clear up everything else.
1063
// Do nothing here.
1064
}
1065
}
1066
SyncDoc.computeServerManagerDoc?.removeListener(
1067
"change",
1068
this.handleComputeServerManagerChange,
1069
);
1070
//
1071
// SYNC STUFF
1072
//
1073
1074
// WARNING: that 'closed' is emitted at the beginning of the
1075
// close function (before anything async) for the project is
1076
// assumed in src/packages/project/sync/sync-doc.ts, because
1077
// that ensures that the moment close is called we lock trying
1078
// try create the syncdoc again until closing is finished.
1079
// (This set_state call emits "closed"):
1080
this.set_state("closed");
1081
1082
this.emit("close");
1083
1084
// must be after the emits above, so clients know
1085
// what happened and can respond.
1086
this.removeAllListeners();
1087
1088
if (this.throttled_file_use != null) {
1089
// Cancel any pending file_use calls.
1090
cancel_scheduled(this.throttled_file_use);
1091
(this.throttled_file_use as any).cancel();
1092
}
1093
1094
if (this.emit_change != null) {
1095
// Cancel any pending change emit calls.
1096
cancel_scheduled(this.emit_change);
1097
}
1098
1099
if (this.fileserver_autosave_timer) {
1100
clearInterval(this.fileserver_autosave_timer as any);
1101
this.fileserver_autosave_timer = 0;
1102
}
1103
1104
if (this.read_only_timer) {
1105
clearInterval(this.read_only_timer as any);
1106
this.read_only_timer = 0;
1107
}
1108
1109
this.patch_update_queue = [];
1110
1111
// Stop watching for file changes. It's important to
1112
// do this *before* all the await's below, since
1113
// this syncdoc can't do anything in response to a
1114
// a file change in its current state.
1115
this.update_watch_path(); // no input = closes it, if open
1116
1117
if (this.patch_list != null) {
1118
// not async -- just a data structure in memory
1119
this.patch_list.close();
1120
}
1121
1122
//
1123
// ASYNC STUFF - in particular, these may all
1124
// attempt to do some last attempt to send changes
1125
// to the database.
1126
//
1127
try {
1128
await this.async_close();
1129
dbg("async_close -- successfully saved all data to database");
1130
} catch (err) {
1131
dbg("async_close -- ERROR -- ", err);
1132
}
1133
// this avoids memory leaks:
1134
close(this);
1135
1136
// after doing that close, we need to keep the state (which just got deleted) as 'closed'
1137
this.set_state("closed");
1138
dbg("close done");
1139
});
1140
1141
private async async_close() {
1142
const promises: Promise<any>[] = [];
1143
1144
if (this.syncstring_table != null) {
1145
promises.push(this.syncstring_table.close());
1146
}
1147
1148
if (this.patches_table != null) {
1149
promises.push(this.patches_table.close());
1150
}
1151
1152
if (this.cursors_table != null) {
1153
promises.push(this.cursors_table.close());
1154
}
1155
1156
if (this.evaluator != null) {
1157
promises.push(this.evaluator.close());
1158
}
1159
1160
if (this.ipywidgets_state != null) {
1161
promises.push(this.ipywidgets_state.close());
1162
}
1163
1164
const results = await Promise.allSettled(promises);
1165
1166
results.forEach((result) => {
1167
if (result.status === "rejected") {
1168
throw Error(result.reason);
1169
}
1170
});
1171
}
1172
1173
// TODO: We **have** to do this on the client, since the backend
1174
// **security model** for accessing the patches table only
1175
// knows the string_id, but not the project_id/path. Thus
1176
// there is no way currently to know whether or not the client
1177
// has access to the patches, and hence the patches table
1178
// query fails. This costs significant time -- a roundtrip
1179
// and write to the database -- whenever the user opens a file.
1180
// This fix should be to change the patches schema somehow
1181
// to have the user also provide the project_id and path, thus
1182
// proving they have access to the sha1 hash (string_id), but
1183
// don't actually use the project_id and path as columns in
1184
// the table. This requires some new idea I guess of virtual
1185
// fields....
1186
// Also, this also establishes the correct doctype.
1187
1188
// Since this MUST succeed before doing anything else. This is critical
1189
// because the patches table can't be opened anywhere if the syncstring
1190
// object doesn't exist, due to how our security works, *AND* that the
1191
// patches table uses the string_id, which is a SHA1 hash.
1192
private async ensure_syncstring_exists_in_db(): Promise<void> {
1193
const dbg = this.dbg("ensure_syncstring_exists_in_db");
1194
1195
if (!this.client.is_connected()) {
1196
dbg("wait until connected...", this.client.is_connected());
1197
await once(this.client, "connected");
1198
}
1199
1200
if (this.client.is_browser() && !this.client.is_signed_in()) {
1201
// the browser has to sign in, unlike the project (and compute servers)
1202
await once(this.client, "signed_in");
1203
}
1204
1205
if (this.state == ("closed" as State)) return;
1206
1207
dbg("do syncstring write query...");
1208
1209
await callback2(this.client.query, {
1210
query: {
1211
syncstrings: {
1212
string_id: this.string_id,
1213
project_id: this.project_id,
1214
path: this.path,
1215
doctype: JSON.stringify(this.doctype),
1216
},
1217
},
1218
});
1219
dbg("wrote syncstring to db - done.");
1220
}
1221
1222
private async synctable(
1223
query,
1224
options: any[],
1225
throttle_changes?: undefined | number,
1226
): Promise<SyncTable> {
1227
this.assert_not_closed("synctable");
1228
const dbg = this.dbg("synctable");
1229
if (!this.ephemeral && this.persistent && this.data_server == "project") {
1230
// persistent table in a non-ephemeral syncdoc, so ensure that table is
1231
// persisted to database (not just in memory).
1232
options = options.concat([{ persistent: true }]);
1233
}
1234
if (this.ephemeral && this.data_server == "project") {
1235
options.push({ ephemeral: true });
1236
}
1237
let synctable;
1238
switch (this.data_server) {
1239
case "project":
1240
synctable = await this.client.synctable_project(
1241
this.project_id,
1242
query,
1243
options,
1244
throttle_changes,
1245
this.id,
1246
);
1247
break;
1248
case "database":
1249
synctable = await this.client.synctable_database(
1250
query,
1251
options,
1252
throttle_changes,
1253
);
1254
break;
1255
default:
1256
throw Error(`uknown server ${this.data_server}`);
1257
}
1258
// We listen and log error events. This is useful because in some settings, e.g.,
1259
// in the project, an eventemitter with no listener for errors, which has an error,
1260
// will crash the entire process.
1261
synctable.on("error", (error) => dbg("ERROR", error));
1262
return synctable;
1263
}
1264
1265
private async init_syncstring_table(): Promise<void> {
1266
const query = {
1267
syncstrings: [
1268
{
1269
string_id: this.string_id,
1270
project_id: this.project_id,
1271
path: this.path,
1272
users: null,
1273
last_snapshot: null,
1274
snapshot_interval: null,
1275
save: null,
1276
last_active: null,
1277
init: null,
1278
read_only: null,
1279
last_file_change: null,
1280
doctype: null,
1281
archived: null,
1282
settings: null,
1283
},
1284
],
1285
};
1286
const dbg = this.dbg("init_syncstring_table");
1287
1288
dbg("getting table...");
1289
this.syncstring_table = await this.synctable(query, []);
1290
if (this.ephemeral && this.client.is_project()) {
1291
await this.set_syncstring_table({
1292
doctype: JSON.stringify(this.doctype),
1293
});
1294
} else {
1295
dbg("waiting for, then handling the first update...");
1296
await this.handle_syncstring_update();
1297
}
1298
this.syncstring_table.on(
1299
"change",
1300
this.handle_syncstring_update.bind(this),
1301
);
1302
1303
// Wait until syncstring is not archived -- if we open an
1304
// older syncstring, the patches may be archived,
1305
// and we have to wait until
1306
// after they have been pulled from blob storage before
1307
// we init the patch table, load from disk, etc.
1308
const is_not_archived: () => boolean = () => {
1309
const ss = this.syncstring_table_get_one();
1310
if (ss != null) {
1311
return !ss.get("archived");
1312
} else {
1313
return false;
1314
}
1315
};
1316
dbg("waiting for syncstring to be not archived");
1317
await this.syncstring_table.wait(is_not_archived, 120);
1318
}
1319
1320
// Used for internal debug logging
1321
private dbg = (f: string = ""): Function => {
1322
return this.client?.dbg(`SyncDoc('${this.path}').${f}`);
1323
};
1324
1325
private async init_all(): Promise<void> {
1326
if (this.state !== "init") {
1327
throw Error("connect can only be called in init state");
1328
}
1329
const log = this.dbg("init_all");
1330
1331
log("ensure syncstring exists in database");
1332
this.assert_not_closed("init_all -- before ensuring syncstring exists");
1333
await this.ensure_syncstring_exists_in_db();
1334
1335
log("syncstring_table");
1336
this.assert_not_closed("init_all -- before init_syncstring_table");
1337
await this.init_syncstring_table();
1338
1339
log("patch_list, cursors, evaluator, ipywidgets");
1340
this.assert_not_closed(
1341
"init_all -- before init patch_list, cursors, evaluator, ipywidgets",
1342
);
1343
await Promise.all([
1344
this.init_patch_list(),
1345
this.init_cursors(),
1346
this.init_evaluator(),
1347
this.init_ipywidgets(),
1348
]);
1349
this.assert_not_closed("init_all -- after init patch_list");
1350
1351
this.init_table_close_handlers();
1352
1353
log("file_use_interval");
1354
this.init_file_use_interval();
1355
1356
if (await this.isFileServer()) {
1357
log("load_from_disk");
1358
// This sets initialized, which is needed to be fully ready.
1359
// We keep trying this load from disk until sync-doc is closed
1360
// or it succeeds. It may fail if, e.g., the file is too
1361
// large or is not readable by the user. They are informed to
1362
// fix the problem... and once they do (and wait up to 10s),
1363
// this will finish.
1364
// if (!this.client.is_browser() && !this.client.is_project()) {
1365
// // FAKE DELAY!!! Just to simulate flakiness / slow network!!!!
1366
// await delay(10000);
1367
// }
1368
await retry_until_success({
1369
f: this.init_load_from_disk,
1370
max_delay: 10000,
1371
desc: "syncdoc -- load_from_disk",
1372
});
1373
log("done loading from disk");
1374
this.assert_not_closed("init_all -- load from disk");
1375
}
1376
1377
log("wait_until_fully_ready");
1378
await this.wait_until_fully_ready();
1379
1380
this.assert_not_closed("init_all -- after waiting until fully ready");
1381
1382
if (await this.isFileServer()) {
1383
log("init file autosave");
1384
this.init_file_autosave();
1385
}
1386
this.update_has_unsaved_changes();
1387
log("done");
1388
}
1389
1390
private init_error(): string | undefined {
1391
let x;
1392
try {
1393
x = this.syncstring_table.get_one();
1394
} catch (_err) {
1395
// if the table hasn't been initialized yet,
1396
// it can't be in error state.
1397
return undefined;
1398
}
1399
return x?.get("init")?.get("error");
1400
}
1401
1402
// wait until the syncstring table is ready to be
1403
// used (so extracted from archive, etc.),
1404
private async wait_until_fully_ready(): Promise<void> {
1405
this.assert_not_closed("wait_until_fully_ready");
1406
const dbg = this.dbg("wait_until_fully_ready");
1407
dbg();
1408
1409
if (this.client.is_browser() && this.init_error()) {
1410
// init is set and is in error state. Give the backend a few seconds
1411
// to try to fix this error before giving up. The browser client
1412
// can close and open the file to retry this (as instructed).
1413
try {
1414
await this.syncstring_table.wait(() => !this.init_error(), 5);
1415
} catch (err) {
1416
// fine -- let the code below deal with this problem...
1417
}
1418
}
1419
1420
const is_init_and_not_archived = (t: SyncTable) => {
1421
this.assert_not_closed("is_init_and_not_archived");
1422
const tbl = t.get_one();
1423
if (tbl == null) {
1424
dbg("null");
1425
return false;
1426
}
1427
// init must be set in table and archived must NOT be
1428
// set, so patches are loaded from blob store.
1429
const init = tbl.get("init");
1430
if (init && !tbl.get("archived")) {
1431
dbg("good to go");
1432
return init.toJS();
1433
} else {
1434
dbg("not init yet");
1435
return false;
1436
}
1437
};
1438
dbg("waiting for init...");
1439
const init = await this.syncstring_table.wait(
1440
is_init_and_not_archived.bind(this),
1441
0,
1442
);
1443
dbg("init done");
1444
if (init.error) {
1445
throw Error(init.error);
1446
}
1447
1448
assertDefined(this.patch_list);
1449
if (
1450
!this.client.is_project() &&
1451
this.patch_list.count() === 0 &&
1452
init.size
1453
) {
1454
dbg("waiting for patches for nontrivial file");
1455
// normally this only happens in a later event loop,
1456
// so force it now.
1457
dbg("handling patch update queue since", this.patch_list.count());
1458
await this.handle_patch_update_queue();
1459
assertDefined(this.patch_list);
1460
dbg("done handling, now ", this.patch_list.count());
1461
if (this.patch_list.count() === 0) {
1462
// wait for a change -- i.e., project loading the file from
1463
// disk and making available... Because init.size > 0, we know that
1464
// there must be SOMETHING in the patches table once initialization is done.
1465
// This is the root cause of https://github.com/sagemathinc/cocalc/issues/2382
1466
await once(this.patches_table, "change");
1467
dbg("got patches_table change");
1468
await this.handle_patch_update_queue();
1469
dbg("handled update queue");
1470
}
1471
}
1472
this.emit("init");
1473
}
1474
1475
private assert_table_is_ready(table: string): void {
1476
const t = this[table + "_table"]; // not using string template only because it breaks codemirror!
1477
if (t == null || t.get_state() != "connected") {
1478
throw Error(
1479
`Table ${table} must be connected. string_id=${this.string_id}`,
1480
);
1481
}
1482
}
1483
1484
public assert_is_ready = (desc: string): void => {
1485
if (this.state != "ready") {
1486
throw Error(`must be ready -- ${desc}`);
1487
}
1488
};
1489
1490
public wait_until_ready = async (): Promise<void> => {
1491
this.assert_not_closed("wait_until_ready");
1492
if (this.state !== ("ready" as State)) {
1493
// wait for a state change to ready.
1494
await once(this, "ready");
1495
}
1496
};
1497
1498
/* Calls wait for the corresponding patches SyncTable, if
1499
it has been defined. If it hasn't been defined, it waits
1500
until it is defined, then calls wait. Timeout only starts
1501
when patches_table is already initialized.
1502
*/
1503
public wait = async (until: Function, timeout: number = 30): Promise<any> => {
1504
await this.wait_until_ready();
1505
//console.trace("SYNC WAIT -- start...");
1506
const result = await wait({
1507
obj: this,
1508
until,
1509
timeout,
1510
change_event: "change",
1511
});
1512
//console.trace("SYNC WAIT -- got it!");
1513
return result;
1514
};
1515
1516
/* Delete the synchronized string and **all** patches from the database
1517
-- basically delete the complete history of editing this file.
1518
WARNINGS:
1519
(1) If a project has this string open, then things may be messed
1520
up, unless that project is restarted.
1521
(2) Only available for an **admin** user right now!
1522
1523
To use: from a javascript console in the browser as admin, do:
1524
1525
await smc.client.sync_string({
1526
project_id:'9f2e5869-54b8-4890-8828-9aeba9a64af4',
1527
path:'a.txt'}).delete_from_database()
1528
1529
Then make sure project and clients refresh.
1530
1531
WORRY: Race condition where constructor might write stuff as
1532
it is being deleted?
1533
*/
1534
public delete_from_database = async (): Promise<void> => {
1535
const queries: object[] = this.ephemeral
1536
? []
1537
: [
1538
{
1539
patches_delete: {
1540
id: [this.string_id],
1541
dummy: null,
1542
},
1543
},
1544
];
1545
queries.push({
1546
syncstrings_delete: {
1547
project_id: this.project_id,
1548
path: this.path,
1549
},
1550
});
1551
1552
const v: Promise<any>[] = [];
1553
for (let i = 0; i < queries.length; i++) {
1554
v.push(callback2(this.client.query, { query: queries[i] }));
1555
}
1556
await Promise.all(v);
1557
};
1558
1559
private pathExistsAndIsReadOnly = async (path): Promise<boolean> => {
1560
try {
1561
await callback2(this.client.path_access, {
1562
path,
1563
mode: "w",
1564
});
1565
// clearly exists and is NOT read only:
1566
return false;
1567
} catch (err) {
1568
// either it doesn't exist or it is read only
1569
if (await callback2(this.client.path_exists, { path })) {
1570
// it exists, so is read only and exists
1571
return true;
1572
}
1573
// doesn't exist
1574
return false;
1575
}
1576
};
1577
1578
private file_is_read_only = async (): Promise<boolean> => {
1579
if (await this.pathExistsAndIsReadOnly(this.path)) {
1580
return true;
1581
}
1582
const path = this.getFileServerPath();
1583
if (path != this.path) {
1584
if (await this.pathExistsAndIsReadOnly(path)) {
1585
return true;
1586
}
1587
}
1588
return false;
1589
};
1590
1591
private update_if_file_is_read_only = async (): Promise<void> => {
1592
this.set_read_only(await this.file_is_read_only());
1593
};
1594
1595
private init_load_from_disk = async (): Promise<void> => {
1596
if (this.state == "closed") {
1597
// stop trying, no error -- this is assumed
1598
// in a retry_until_success elsewhere.
1599
return;
1600
}
1601
if (await this.load_from_disk_if_newer()) {
1602
throw Error("failed to load from disk");
1603
}
1604
};
1605
1606
private async load_from_disk_if_newer(): Promise<boolean> {
1607
const last_changed = this.last_changed();
1608
const firstLoad = this.versions().length == 0;
1609
const dbg = this.dbg("load_from_disk_if_newer");
1610
let is_read_only: boolean = false;
1611
let size: number = 0;
1612
let error: string = "";
1613
try {
1614
dbg("check if path exists");
1615
if (await callback2(this.client.path_exists, { path: this.path })) {
1616
// the path exists
1617
dbg("path exists -- stat file");
1618
const stats = await callback2(this.client.path_stat, {
1619
path: this.path,
1620
});
1621
if (firstLoad || stats.ctime > last_changed) {
1622
dbg(
1623
`disk file changed more recently than edits (or first load), so loading, ${stats.ctime} > ${last_changed}; firstLoad=${firstLoad}`,
1624
);
1625
size = await this.load_from_disk();
1626
if (firstLoad) {
1627
dbg("emitting first-load event");
1628
// this event is emited the first time the document is ever loaded from disk.
1629
this.emit("first-load");
1630
}
1631
dbg("loaded");
1632
} else {
1633
dbg("stick with database version");
1634
}
1635
dbg("checking if read only");
1636
is_read_only = await this.file_is_read_only();
1637
dbg("read_only", is_read_only);
1638
}
1639
} catch (err) {
1640
error = `${err.toString()} -- ${err.stack}`;
1641
}
1642
1643
await this.set_initialized(error, is_read_only, size);
1644
dbg("done");
1645
return !!error;
1646
}
1647
1648
private patch_table_query(cutoff?: Date) {
1649
const query = {
1650
string_id: this.string_id,
1651
time: cutoff ? { ">=": cutoff } : null,
1652
// compressed format patch as a JSON *string*
1653
patch: null,
1654
// integer id of user (maps to syncstring table)
1655
user_id: null,
1656
// (optional) a snapshot at this point in time
1657
snapshot: null,
1658
// (optional) when patch actually sent, which may
1659
// be later than when made
1660
sent: null,
1661
// (optional) timestamp of previous patch sent
1662
// from this session
1663
prev: null,
1664
};
1665
if (this.doctype.patch_format != null) {
1666
(query as any).format = this.doctype.patch_format;
1667
}
1668
return query;
1669
}
1670
1671
private async init_patch_list(): Promise<void> {
1672
this.assert_not_closed("init_patch_list - start");
1673
const dbg = this.dbg("init_patch_list");
1674
dbg();
1675
1676
// CRITICAL: note that handle_syncstring_update checks whether
1677
// init_patch_list is done by testing whether this.patch_list is defined!
1678
// That is why we first define "patch_list" below, then set this.patch_list
1679
// to it only after we're done.
1680
delete this.patch_list;
1681
1682
const patch_list = new SortedPatchList(this._from_str);
1683
1684
dbg("opening the table...");
1685
this.patches_table = await this.synctable(
1686
{ patches: [this.patch_table_query(this.last_snapshot)] },
1687
[],
1688
this.patch_interval,
1689
);
1690
this.assert_not_closed("init_patch_list -- after making synctable");
1691
1692
const update_has_unsaved_changes = debounce(
1693
this.update_has_unsaved_changes.bind(this),
1694
500,
1695
{ leading: true, trailing: true },
1696
);
1697
1698
this.patches_table.on("has-uncommitted-changes", (val) => {
1699
this.emit("has-uncommitted-changes", val);
1700
});
1701
1702
this.on("change", () => {
1703
update_has_unsaved_changes();
1704
});
1705
1706
this.syncstring_table.on("change", () => {
1707
update_has_unsaved_changes();
1708
});
1709
1710
dbg("adding patches");
1711
patch_list.add(this.get_patches());
1712
1713
const doc = patch_list.value();
1714
this.last = this.doc = doc;
1715
this.patches_table.on("change", this.handle_patch_update.bind(this));
1716
this.patches_table.on("saved", this.handle_offline.bind(this));
1717
this.patch_list = patch_list;
1718
1719
// this only potentially happens for tables in the project,
1720
// e.g., jupyter and compute servers:
1721
// see packages/project/sync/server.ts
1722
this.patches_table.on("message", (...args) => {
1723
dbg("received message", args);
1724
this.emit("message", ...args);
1725
});
1726
1727
dbg("done");
1728
1729
/*
1730
TODO/CRITICAL: We are temporarily disabling same-user
1731
collision detection, since this seems to be leading to
1732
serious issues involving a feedback loop, which may
1733
be way worse than the 1 in a million issue
1734
that this addresses. This only address the *same*
1735
account being used simultaneously on the same file
1736
by multiple people. which isn't something users should
1737
ever do (but they might do in big public demos?).
1738
1739
this.patch_list.on 'overwrite', (t) =>
1740
* ensure that any outstanding save is done
1741
this.patches_table.save () =>
1742
this.check_for_timestamp_collision(t)
1743
*/
1744
}
1745
1746
/*
1747
_check_for_timestamp_collision: (t) =>
1748
obj = this._my_patches[t]
1749
if not obj?
1750
return
1751
key = this._patches_table.key(obj)
1752
if obj.patch != this._patches_table.get(key)?.get('patch')
1753
*console.log("COLLISION! #{t}, #{obj.patch}, #{this._patches_table.get(key).get('patch')}")
1754
* We fix the collision by finding the nearest time after time that
1755
* is available, and reinserting our patch at that new time.
1756
this._my_patches[t] = 'killed'
1757
new_time = this.patch_list.next_available_time(new Date(t), this._user_id, this._users.length)
1758
this._save_patch(new_time, JSON.parse(obj.patch))
1759
*/
1760
1761
private async init_evaluator(): Promise<void> {
1762
const dbg = this.dbg("init_evaluator");
1763
const ext = filename_extension(this.path);
1764
if (ext !== "sagews") {
1765
dbg("done -- only use init_evaluator for sagews");
1766
return;
1767
}
1768
dbg("creating the evaluator and waiting for init");
1769
this.evaluator = new Evaluator(
1770
this,
1771
this.client,
1772
this.synctable.bind(this),
1773
);
1774
await this.evaluator.init();
1775
dbg("done");
1776
}
1777
1778
private async init_ipywidgets(): Promise<void> {
1779
const dbg = this.dbg("init_evaluator");
1780
const ext = filename_extension(this.path);
1781
if (ext != "sage-jupyter2") {
1782
dbg("done -- only use ipywidgets for jupyter");
1783
return;
1784
}
1785
dbg("creating the ipywidgets state table, and waiting for init");
1786
this.ipywidgets_state = new IpywidgetsState(
1787
this,
1788
this.client,
1789
this.synctable.bind(this),
1790
);
1791
await this.ipywidgets_state.init();
1792
dbg("done");
1793
}
1794
1795
private async init_cursors(): Promise<void> {
1796
const dbg = this.dbg("init_cursors");
1797
if (!this.cursors) {
1798
dbg("done -- do not care about cursors for this syncdoc.");
1799
return;
1800
}
1801
dbg("getting cursors ephemeral table");
1802
const query = {
1803
cursors: [
1804
{
1805
string_id: this.string_id,
1806
user_id: null,
1807
locs: null,
1808
time: null,
1809
},
1810
],
1811
};
1812
// We make cursors an ephemeral table, since there is no
1813
// need to persist it to the database, obviously!
1814
// Also, queue_size:1 makes it so only the last cursor position is
1815
// saved, e.g., in case of disconnect and reconnect.
1816
let options;
1817
if (this.data_server == "project") {
1818
options = [{ ephemeral: true }, { queue_size: 1 }];
1819
} else {
1820
options = [];
1821
}
1822
this.cursors_table = await this.synctable(query, options, 1000);
1823
this.assert_not_closed("init_cursors -- after making synctable");
1824
1825
// cursors now initialized; first initialize the
1826
// local this._cursor_map, which tracks positions
1827
// of cursors by account_id:
1828
dbg("loading initial state");
1829
const s = this.cursors_table.get();
1830
if (s == null) {
1831
throw Error("bug -- get should not return null once table initialized");
1832
}
1833
s.forEach((locs: any, k: string) => {
1834
if (locs == null) {
1835
return;
1836
}
1837
const u = JSON.parse(k);
1838
if (u != null) {
1839
this.cursor_map = this.cursor_map.set(this.users[u[1]], locs);
1840
}
1841
});
1842
this.cursors_table.on("change", this.handle_cursors_change.bind(this));
1843
1844
if (this.cursors_table.setOnDisconnect != null) {
1845
// setOnDisconnect is available, so clear our
1846
// cursor positions when we disconnect for any reason.
1847
this.cursors_table.setOnDisconnect(
1848
{
1849
string_id: this.string_id,
1850
user_id: this.my_user_id,
1851
locs: [],
1852
},
1853
"none",
1854
);
1855
}
1856
1857
dbg("done");
1858
}
1859
1860
private handle_cursors_change(keys): void {
1861
if (this.state === "closed") {
1862
return;
1863
}
1864
for (const k of keys) {
1865
const u = JSON.parse(k);
1866
if (u == null) {
1867
continue;
1868
}
1869
const account_id = this.users[u[1]];
1870
const locs = this.cursors_table.get(k);
1871
if (locs == null && !this.cursor_map.has(account_id)) {
1872
// gone, and already gone.
1873
continue;
1874
}
1875
if (locs != null) {
1876
// changed
1877
this.cursor_map = this.cursor_map.set(account_id, locs);
1878
} else {
1879
// deleted
1880
this.cursor_map = this.cursor_map.delete(account_id);
1881
}
1882
this.emit("cursor_activity", account_id);
1883
}
1884
}
1885
1886
/* Returns *immutable* Map from account_id to list
1887
of cursor positions, if cursors are enabled.
1888
1889
- excludeSelf: do not include our own cursor
1890
- maxAge: only include cursors that have been updated with maxAge ms from now.
1891
*/
1892
get_cursors = ({
1893
maxAge = 60 * 1000,
1894
// excludeSelf:
1895
// 'always' -- *always* exclude self
1896
// 'never' -- never exclude self
1897
// 'heuristic' -- exclude self is older than last set from here, e.g., useful on
1898
// frontend so we don't see our own cursor unless more than one browser.
1899
excludeSelf = "always",
1900
}: {
1901
maxAge?: number;
1902
excludeSelf?: "always" | "never" | "heuristic";
1903
} = {}): Map<string, any> => {
1904
this.assert_not_closed("get_cursors");
1905
if (!this.cursors) {
1906
throw Error("cursors are not enabled");
1907
}
1908
if (this.cursors_table == null) {
1909
return Map(); // not loaded yet -- so no info yet.
1910
}
1911
const account_id: string = this.client_id();
1912
let map = this.cursor_map;
1913
if (map.has(account_id) && excludeSelf != "never") {
1914
if (
1915
excludeSelf == "always" ||
1916
(excludeSelf == "heuristic" &&
1917
this.cursor_last_time >=
1918
(map.getIn([account_id, "time"], new Date(0)) as Date))
1919
) {
1920
map = map.delete(account_id);
1921
}
1922
}
1923
// Remove any old cursors, where "old" is by default more than maxAge old.
1924
const now = Date.now();
1925
for (const [client_id, value] of map as any) {
1926
const time = value.get("time");
1927
if (time == null) {
1928
// this should always be set.
1929
map = map.delete(client_id);
1930
continue;
1931
}
1932
if (maxAge) {
1933
// we use abs to implicitly exclude a bad value that is somehow in the future,
1934
// if that were to happen.
1935
if (Math.abs(now - time.valueOf()) >= maxAge) {
1936
map = map.delete(client_id);
1937
continue;
1938
}
1939
}
1940
if (time >= now + 10 * 1000) {
1941
// We *always* delete any cursors more than 10 seconds in the future, since
1942
// that can only happen if a client inserts invalid data (e.g., clock not
1943
// yet synchronized). See https://github.com/sagemathinc/cocalc/issues/7969
1944
map = map.delete(client_id);
1945
continue;
1946
}
1947
}
1948
return map;
1949
};
1950
1951
/* Set settings map. Used for custom configuration just for
1952
this one file, e.g., overloading the spell checker language.
1953
*/
1954
set_settings = async (obj): Promise<void> => {
1955
this.assert_is_ready("set_settings");
1956
await this.set_syncstring_table({
1957
settings: obj,
1958
});
1959
};
1960
1961
client_id = () => {
1962
return this.client.client_id();
1963
};
1964
1965
// get settings object
1966
public get_settings = (): Map<string, any> => {
1967
this.assert_is_ready("get_settings");
1968
return this.syncstring_table_get_one().get("settings", Map());
1969
};
1970
1971
/*
1972
Commits and saves current live syncdoc to backend.
1973
1974
Function only returns when there is nothing needing
1975
saving.
1976
1977
Save any changes we have as a new patch.
1978
*/
1979
public save = reuseInFlight(async () => {
1980
const dbg = this.dbg("save");
1981
dbg();
1982
if (this.client.is_deleted(this.path, this.project_id)) {
1983
dbg("not saving because deleted");
1984
return;
1985
}
1986
// We just keep trying while syncdoc is ready and there
1987
// are changes that have not been saved (due to this.doc
1988
// changing during the while loop!).
1989
if (this.doc == null || this.last == null) {
1990
dbg("bug -- not ready");
1991
// I'm making this non-fatal. It'll get called later when init is done.
1992
// I was seeing this when automating opening an autogenerated document with
1993
// the ChatGPT jupyter notebook generator.
1994
return;
1995
//throw Error("bug -- cannot save if doc and last are not initialized");
1996
}
1997
if (this.state == "closed") {
1998
// There's nothing to do regarding save if the table is
1999
// already closed. Note that we *do* have to save when
2000
// the table is init stage, since the project has to
2001
// record the newly opened version of the file to the
2002
// database! See
2003
// https://github.com/sagemathinc/cocalc/issues/4986
2004
dbg(`state=${this.state} not ready so not saving`);
2005
return;
2006
}
2007
// Compute any patches.
2008
while (!this.doc.is_equal(this.last)) {
2009
dbg("something to save");
2010
this.emit("user-change");
2011
const doc = this.doc;
2012
// TODO: put in a delay if just saved too recently?
2013
// Or maybe won't matter since not using database?
2014
if (this.handle_patch_update_queue_running) {
2015
dbg("wait until the update queue is done");
2016
await once(this, "handle_patch_update_queue_done");
2017
// but wait until next loop (so as to check that needed
2018
// and state still ready).
2019
continue;
2020
}
2021
dbg("Compute new patch.");
2022
this.sync_remote_and_doc(false);
2023
// Emit event since this syncstring was
2024
// changed locally (or we wouldn't have had
2025
// to save at all).
2026
if (doc.is_equal(this.doc)) {
2027
dbg("no change during loop -- done!");
2028
break;
2029
}
2030
}
2031
if (this.state != "ready") {
2032
// above async waits could have resulted in state change.
2033
return;
2034
}
2035
// Ensure all patches are saved to backend.
2036
// We do this after the above, so that creating the newest patch
2037
// happens immediately on save, which makes it possible for clients
2038
// to save current state without having to wait on an async, which is
2039
// useful to ensure specific undo points (e.g., right before a paste).
2040
await this.patches_table.save();
2041
});
2042
2043
private next_patch_time(): Date {
2044
let time = this.client.server_time();
2045
assertDefined(this.patch_list);
2046
const min_time = this.patch_list.newest_patch_time();
2047
if (min_time != null && min_time >= time) {
2048
time = new Date(min_time.valueOf() + 1);
2049
}
2050
time = this.patch_list.next_available_time(
2051
time,
2052
this.my_user_id,
2053
this.users.length,
2054
);
2055
return time;
2056
}
2057
2058
private commit_patch(time: Date, patch: XPatch): void {
2059
this.assert_not_closed("commit_patch");
2060
const obj: any = {
2061
// version for database
2062
string_id: this.string_id,
2063
time,
2064
patch: JSON.stringify(patch),
2065
user_id: this.my_user_id,
2066
};
2067
2068
this.my_patches[time.valueOf()] = obj;
2069
2070
if (this.doctype.patch_format != null) {
2071
obj.format = this.doctype.patch_format;
2072
}
2073
if (this.save_patch_prev != null) {
2074
// timestamp of last saved patch during this session
2075
obj.prev = this.save_patch_prev;
2076
}
2077
this.save_patch_prev = time;
2078
2079
// If in undo mode put the just-created patch in our
2080
// without timestamp list, so it won't be included
2081
// when doing undo/redo.
2082
if (this.undo_state != null) {
2083
this.undo_state.without.unshift(time);
2084
}
2085
2086
//console.log 'saving patch with time ', time.valueOf()
2087
const x = this.patches_table.set(obj, "none");
2088
const y = this.process_patch(x, undefined, undefined, patch);
2089
if (y != null) {
2090
assertDefined(this.patch_list);
2091
this.patch_list.add([y]);
2092
}
2093
}
2094
2095
/* Create and store in the database a snapshot of the state
2096
of the string at the given point in time. This should
2097
be the time of an existing patch.
2098
*/
2099
private async snapshot(time: Date, force: boolean = false): Promise<void> {
2100
assertDefined(this.patch_list);
2101
const x = this.patch_list.patch(time);
2102
if (x == null) {
2103
throw Error(`no patch at time ${time}`);
2104
}
2105
if (x.snapshot != null && !force) {
2106
// there is already a snapshot at this point in time,
2107
// so nothing further to do.
2108
return;
2109
}
2110
2111
const snapshot: string = this.patch_list.value(time, force).to_str();
2112
// save the snapshot itself in the patches table.
2113
const obj: any = {
2114
string_id: this.string_id,
2115
time,
2116
patch: JSON.stringify(x.patch),
2117
snapshot,
2118
user_id: x.user_id,
2119
};
2120
if (force) {
2121
/* CRITICAL: We are sending the patch/snapshot later, but
2122
it was valid. It's important to make this clear or
2123
this.handle_offline will recompute this snapshot and
2124
try to update sent on it again, which leads to serious
2125
problems!
2126
*/
2127
obj.sent = time;
2128
}
2129
// also set snapshot in the this.patch_list, which
2130
// helps with optimization
2131
x.snapshot = obj.snapshot;
2132
this.patches_table.set(obj, "none");
2133
await this.patches_table.save();
2134
if (this.state != "ready") return;
2135
2136
/* CRITICAL: Only save the snapshot time in the database
2137
after the set in the patches table was definitely saved
2138
-- otherwise if the user refreshes their
2139
browser (or visits later) they lose all their
2140
early work due to trying to apply patches
2141
to a blank snapshot. That would be VERY bad.
2142
*/
2143
if (!this.ephemeral) {
2144
/*
2145
PARANOID: We are extra paranoid and ensure the
2146
snapshot is definitely stored in the database
2147
before we change the syncstrings table's last_snapshot time.
2148
Indeed, we do a query to the database itself
2149
to ensure that the snapshot was really saved
2150
before changing last_snapshot, since the above
2151
patches_table.save only ensures that the snapshot
2152
was (presumably) saved *from the browser to the project*.
2153
We do give this several chances, since it might
2154
take a little while for the project to save it.
2155
*/
2156
let success: boolean = false;
2157
for (let i = 0; i < 6; i++) {
2158
const x = await callback2(this.client.query, {
2159
project_id: this.project_id,
2160
query: {
2161
patches: {
2162
string_id: this.string_id,
2163
time,
2164
snapshot: null,
2165
},
2166
},
2167
});
2168
if (this.state != "ready") return;
2169
if (x.query.patches == null || x.query.patches.snapshot != snapshot) {
2170
await delay((i + 1) * 3000);
2171
} else {
2172
success = true;
2173
break;
2174
}
2175
}
2176
if (!success) {
2177
// We make this non-fatal to not crash the entire project
2178
// throw Error(
2179
// "unable to confirm that snapshot was saved to the database"
2180
// );
2181
2182
// We make this non-fatal, because throwing an exception here WOULD
2183
// DEFINITELY break other things. Everything is saved to a file system
2184
// after all, so there's no major data loss potential at present.
2185
console.warn(
2186
"ERROR: (nonfatal) unable to confirm that snapshot was saved to the database",
2187
);
2188
const dbg = this.dbg("snapshot");
2189
dbg(
2190
"ERROR: (nonfatal) unable to confirm that snapshot was saved to the database",
2191
);
2192
return;
2193
}
2194
}
2195
2196
if (this.state != "ready") return;
2197
await this.set_syncstring_table({
2198
last_snapshot: time,
2199
});
2200
this.last_snapshot = time;
2201
}
2202
2203
// Have a snapshot every this.snapshot_interval patches, except
2204
// for the very last interval.
2205
private async snapshot_if_necessary(): Promise<void> {
2206
if (this.get_state() !== "ready") return;
2207
const dbg = this.dbg("snapshot_if_necessary");
2208
const max_size = Math.floor(1.2 * MAX_FILE_SIZE_MB * 1000000);
2209
const interval = this.snapshot_interval;
2210
dbg("check if we need to make a snapshot:", { interval, max_size });
2211
assertDefined(this.patch_list);
2212
const time = this.patch_list.time_of_unmade_periodic_snapshot(
2213
interval,
2214
max_size,
2215
);
2216
if (time != null) {
2217
dbg("yes, make a snapshot at time", time);
2218
await this.snapshot(time);
2219
} else {
2220
dbg("no need to make a snapshot yet");
2221
}
2222
}
2223
2224
/*- x - patch object
2225
- time0, time1: optional range of times
2226
return undefined if patch not in this range
2227
- patch: if given will be used as an actual patch
2228
instead of x.patch, which is a JSON string.
2229
*/
2230
private process_patch(
2231
x: Map<string, any>,
2232
time0?: Date,
2233
time1?: Date,
2234
patch?: any,
2235
): Patch | undefined {
2236
let t = x.get("time");
2237
if (!is_date(t)) {
2238
// who knows what is in the database...
2239
try {
2240
t = ISO_to_Date(t);
2241
if (isNaN(t)) {
2242
// ignore patches with bad times
2243
return;
2244
}
2245
} catch (err) {
2246
// ignore patches with invalid times
2247
return;
2248
}
2249
}
2250
const time: Date = t;
2251
if ((time0 != null && time < time0) || (time1 != null && time > time1)) {
2252
// out of range
2253
return;
2254
}
2255
2256
const user_id: number = x.get("user_id");
2257
const sent: Date = x.get("sent");
2258
const prev: Date | undefined = x.get("prev");
2259
let size: number;
2260
if (patch == null) {
2261
/* Do **NOT** use misc.from_json, since we definitely
2262
do not want to unpack ISO timestamps as Date,
2263
since patch just contains the raw patches from
2264
user editing. This was done for a while, which
2265
led to horrific bugs in some edge cases...
2266
See https://github.com/sagemathinc/cocalc/issues/1771
2267
*/
2268
if (x.has("patch")) {
2269
const p: string = x.get("patch");
2270
patch = JSON.parse(p);
2271
size = p.length;
2272
} else {
2273
patch = [];
2274
size = 2;
2275
}
2276
} else {
2277
const p = x.get("patch");
2278
// Looking at other code, I think this JSON.stringify (which
2279
// would be a waste of time) never gets called in practice.
2280
size = p != null ? p.length : JSON.stringify(patch).length;
2281
}
2282
2283
const obj: any = {
2284
time,
2285
user_id,
2286
patch,
2287
size,
2288
};
2289
const snapshot: string = x.get("snapshot");
2290
if (sent != null) {
2291
obj.sent = sent;
2292
}
2293
if (prev != null) {
2294
obj.prev = prev;
2295
}
2296
if (snapshot != null) {
2297
obj.snapshot = snapshot;
2298
}
2299
return obj;
2300
}
2301
2302
/* Return all patches with time such that
2303
time0 <= time <= time1;
2304
If time0 undefined then sets time0 equal to time of last_snapshot.
2305
If time1 undefined treated as +oo.
2306
*/
2307
private get_patches(time0?: Date, time1?: Date): Patch[] {
2308
this.assert_table_is_ready("patches");
2309
2310
if (time0 == null) {
2311
time0 = this.last_snapshot;
2312
}
2313
// m below is an immutable map with keys the string that
2314
// is the JSON version of the primary key
2315
// [string_id, timestamp, user_number].
2316
const m: Map<string, any> | undefined = this.patches_table.get();
2317
if (m == null) {
2318
// won't happen because of assert above.
2319
throw Error("patches_table must be initialized");
2320
}
2321
const v: Patch[] = [];
2322
m.forEach((x, _) => {
2323
const p = this.process_patch(x, time0, time1);
2324
if (p != null) {
2325
return v.push(p);
2326
}
2327
});
2328
v.sort(patch_cmp);
2329
return v;
2330
}
2331
2332
public has_full_history = (): boolean => {
2333
return !this.last_snapshot || this.load_full_history_done;
2334
};
2335
2336
public load_full_history = async (): Promise<void> => {
2337
if (this.has_full_history() || this.ephemeral) {
2338
return;
2339
}
2340
const query = this.patch_table_query();
2341
const result = await callback2(this.client.query, {
2342
project_id: this.project_id,
2343
query: { patches: [query] },
2344
});
2345
const v: Patch[] = [];
2346
// process_patch assumes immutable objects
2347
fromJS(result.query.patches).forEach((x) => {
2348
const p = this.process_patch(x, new Date(0), this.last_snapshot);
2349
if (p != null) {
2350
v.push(p);
2351
}
2352
});
2353
assertDefined(this.patch_list);
2354
this.patch_list.add(v);
2355
this.load_full_history_done = true;
2356
return;
2357
};
2358
2359
public show_history = (opts = {}): void => {
2360
assertDefined(this.patch_list);
2361
this.patch_list.show_history(opts);
2362
};
2363
2364
public set_snapshot_interval = async (n: number): Promise<void> => {
2365
await this.set_syncstring_table({
2366
snapshot_interval: n,
2367
});
2368
await this.syncstring_table.save();
2369
};
2370
2371
/* Check if any patches that just got confirmed as saved
2372
are relatively old; if so, we mark them as such and
2373
also possibly recompute snapshots.
2374
*/
2375
private async handle_offline(data): Promise<void> {
2376
this.assert_not_closed("handle_offline");
2377
const now: Date = this.client.server_time();
2378
let oldest: Date | undefined = undefined;
2379
for (const obj of data) {
2380
if (obj.sent) {
2381
// CRITICAL: ignore anything already processed! (otherwise, infinite loop)
2382
continue;
2383
}
2384
if (now.valueOf() - obj.time.valueOf() >= 1000 * OFFLINE_THRESH_S) {
2385
// patch is "old" -- mark it as likely being sent as a result of being
2386
// offline, so clients could potentially discard it.
2387
obj.sent = now;
2388
this.patches_table.set(obj);
2389
this.patches_table.save();
2390
if (oldest == null || obj.time < oldest) {
2391
oldest = obj.time;
2392
}
2393
}
2394
}
2395
if (oldest) {
2396
//dbg("oldest=#{oldest}, so check whether any snapshots need to be recomputed")
2397
assertDefined(this.patch_list);
2398
for (const snapshot_time of this.patch_list.snapshot_times()) {
2399
if (snapshot_time >= oldest) {
2400
//console.log("recomputing snapshot #{snapshot_time}")
2401
await this.snapshot(snapshot_time, true);
2402
}
2403
}
2404
}
2405
}
2406
2407
public get_last_save_to_disk_time = (): Date => {
2408
return this.last_save_to_disk_time;
2409
};
2410
2411
private handle_syncstring_save_state = async (
2412
state: string,
2413
time: Date,
2414
): Promise<void> => {
2415
// Called when the save state changes.
2416
2417
/* this.syncstring_save_state is used to make it possible to emit a
2418
'save-to-disk' event, whenever the state changes
2419
to indicate a save completed.
2420
2421
NOTE: it is intentional that this.syncstring_save_state is not defined
2422
the first time this function is called, so that save-to-disk
2423
with last save time gets emitted on initial load (which, e.g., triggers
2424
latex compilation properly in case of a .tex file).
2425
*/
2426
if (state === "done" && this.syncstring_save_state !== "done") {
2427
this.last_save_to_disk_time = time;
2428
this.emit("save-to-disk", time);
2429
}
2430
const dbg = this.dbg("handle_syncstring_save_state");
2431
dbg(
2432
`state=${state}; this.syncstring_save_state=${this.syncstring_save_state}; this.state=${state}`,
2433
);
2434
if (
2435
this.state === "ready" &&
2436
(await this.isFileServer()) &&
2437
this.syncstring_save_state !== "requested" &&
2438
state === "requested"
2439
) {
2440
this.syncstring_save_state = state; // only used in the if above
2441
dbg("requesting save to disk -- calling save_to_disk");
2442
// state just changed to requesting a save to disk...
2443
// so we do it (unless of course syncstring is still
2444
// being initialized).
2445
try {
2446
// Uncomment the following to test simulating a
2447
// random failure in save_to_disk:
2448
// if (Math.random() < 0.5) throw Error("CHAOS MONKEY!"); // FOR TESTING ONLY.
2449
await this.save_to_disk();
2450
} catch (err) {
2451
// CRITICAL: we must unset this.syncstring_save_state (and set the save state);
2452
// otherwise, it stays as "requested" and this if statement would never get
2453
// run again, thus completely breaking saving this doc to disk.
2454
// It is normal behavior that *sometimes* this.save_to_disk might
2455
// throw an exception, e.g., if the file is temporarily deleted
2456
// or save it called before everything is initialized, or file
2457
// is temporarily set readonly, or maybe there is a file system error.
2458
// Of course, the finally below will also take care of this. However,
2459
// it's nice to record the error here.
2460
this.syncstring_save_state = "done";
2461
await this.set_save({ state: "done", error: `${err}` });
2462
dbg(`ERROR saving to disk in handle_syncstring_save_state-- ${err}`);
2463
} finally {
2464
// No matter what, after the above code is run,
2465
// the save state in the table better be "done".
2466
// We triple check that here, though of course
2467
// we believe the logic in save_to_disk and above
2468
// should always accomplish this.
2469
dbg("had to set the state to done in finally block");
2470
if (
2471
this.state === "ready" &&
2472
(this.syncstring_save_state != "done" ||
2473
this.syncstring_table_get_one().getIn(["save", "state"]) != "done")
2474
) {
2475
this.syncstring_save_state = "done";
2476
await this.set_save({ state: "done", error: "" });
2477
}
2478
}
2479
}
2480
};
2481
2482
private async handle_syncstring_update(): Promise<void> {
2483
if (this.state === "closed") {
2484
return;
2485
}
2486
const dbg = this.dbg("handle_syncstring_update");
2487
dbg();
2488
2489
const data = this.syncstring_table_get_one();
2490
const x: any = data != null ? data.toJS() : undefined;
2491
2492
if (x != null && x.save != null) {
2493
this.handle_syncstring_save_state(x.save.state, x.save.time);
2494
}
2495
2496
dbg(JSON.stringify(x));
2497
if (x == null || x.users == null) {
2498
dbg("new_document");
2499
await this.handle_syncstring_update_new_document();
2500
} else {
2501
dbg("update_existing");
2502
await this.handle_syncstring_update_existing_document(x, data);
2503
}
2504
}
2505
2506
private async handle_syncstring_update_new_document(): Promise<void> {
2507
// Brand new document
2508
this.emit("load-time-estimate", { type: "new", time: 1 });
2509
this.last_snapshot = undefined;
2510
this.snapshot_interval =
2511
schema.SCHEMA.syncstrings.user_query?.get?.fields.snapshot_interval;
2512
2513
// Brand new syncstring
2514
// TODO: worry about race condition with everybody making themselves
2515
// have user_id 0... ?
2516
this.my_user_id = 0;
2517
this.users = [this.client.client_id()];
2518
const obj = {
2519
string_id: this.string_id,
2520
project_id: this.project_id,
2521
path: this.path,
2522
last_snapshot: this.last_snapshot,
2523
users: this.users,
2524
doctype: JSON.stringify(this.doctype),
2525
last_active: this.client.server_time(),
2526
};
2527
this.syncstring_table.set(obj);
2528
await this.syncstring_table.save();
2529
this.settings = Map();
2530
this.emit("metadata-change");
2531
this.emit("settings-change", this.settings);
2532
}
2533
2534
private async handle_syncstring_update_existing_document(
2535
x: any,
2536
data: Map<string, any>,
2537
): Promise<void> {
2538
// Existing document.
2539
2540
if (this.path == null) {
2541
// We just opened the file -- emit a load time estimate.
2542
if (x.archived) {
2543
this.emit("load-time-estimate", { type: "archived", time: 3 });
2544
} else {
2545
this.emit("load-time-estimate", { type: "ready", time: 1 });
2546
}
2547
}
2548
// TODO: handle doctype change here (?)
2549
this.last_snapshot = x.last_snapshot;
2550
this.snapshot_interval = x.snapshot_interval;
2551
this.users = x.users;
2552
// @ts-ignore
2553
this.project_id = x.project_id;
2554
// @ts-ignore
2555
this.path = x.path;
2556
2557
const settings = data.get("settings", Map());
2558
if (settings !== this.settings) {
2559
this.settings = settings;
2560
this.emit("settings-change", settings);
2561
}
2562
2563
// Ensure that this client is in the list of clients
2564
const client_id: string = this.client_id();
2565
this.my_user_id = this.users.indexOf(client_id);
2566
if (this.my_user_id === -1) {
2567
this.my_user_id = this.users.length;
2568
this.users.push(client_id);
2569
await this.set_syncstring_table({
2570
users: this.users,
2571
});
2572
}
2573
2574
this.emit("metadata-change");
2575
}
2576
2577
private async init_watch(): Promise<void> {
2578
if (!(await this.isFileServer())) {
2579
// ensures we are NOT watching anything
2580
await this.update_watch_path();
2581
return;
2582
}
2583
2584
// If path isn't being properly watched, make it so.
2585
if (this.watch_path !== this.path) {
2586
await this.update_watch_path(this.path);
2587
}
2588
2589
await this.pending_save_to_disk();
2590
}
2591
2592
private async pending_save_to_disk(): Promise<void> {
2593
this.assert_table_is_ready("syncstring");
2594
if (!(await this.isFileServer())) {
2595
return;
2596
}
2597
2598
const x = this.syncstring_table.get_one();
2599
// Check if there is a pending save-to-disk that is needed.
2600
if (x != null && x.getIn(["save", "state"]) === "requested") {
2601
try {
2602
await this.save_to_disk();
2603
} catch (err) {
2604
const dbg = this.dbg("pending_save_to_disk");
2605
dbg(`ERROR saving to disk in pending_save_to_disk -- ${err}`);
2606
}
2607
}
2608
}
2609
2610
private async update_watch_path(path?: string): Promise<void> {
2611
const dbg = this.dbg("update_watch_path");
2612
if (this.file_watcher != null) {
2613
// clean up
2614
dbg("close");
2615
this.file_watcher.close();
2616
delete this.file_watcher;
2617
delete this.watch_path;
2618
}
2619
if (path != null && this.client.is_deleted(path, this.project_id)) {
2620
dbg(`not setting up watching since "${path}" is explicitly deleted`);
2621
return;
2622
}
2623
if (path == null) {
2624
dbg("not opening another watcher since path is null");
2625
this.watch_path = path;
2626
return;
2627
}
2628
if (this.watch_path != null) {
2629
// this case is impossible since we deleted it above if it is was defined.
2630
dbg("watch_path already defined");
2631
return;
2632
}
2633
dbg("opening watcher...");
2634
if (this.state === "closed") {
2635
throw Error("must not be closed");
2636
}
2637
this.watch_path = path;
2638
try {
2639
if (!(await callback2(this.client.path_exists, { path }))) {
2640
if (this.client.is_deleted(path, this.project_id)) {
2641
dbg(`not setting up watching since "${path}" is explicitly deleted`);
2642
return;
2643
}
2644
// path does not exist
2645
dbg(
2646
`write '${path}' to disk from syncstring in-memory database version`,
2647
);
2648
const data = this.to_str();
2649
await callback2(this.client.write_file, { path, data });
2650
dbg(`wrote '${path}' to disk`);
2651
}
2652
} catch (err) {
2653
// This can happen, e.g, if path is read only.
2654
dbg(`could NOT write '${path}' to disk -- ${err}`);
2655
await this.update_if_file_is_read_only();
2656
// In this case, can't really setup a file watcher.
2657
return;
2658
}
2659
2660
dbg("now requesting to watch file");
2661
this.file_watcher = this.client.watch_file({ path });
2662
this.file_watcher.on("change", this.handle_file_watcher_change);
2663
this.file_watcher.on("delete", this.handle_file_watcher_delete);
2664
this.setupReadOnlyTimer();
2665
}
2666
2667
private setupReadOnlyTimer = () => {
2668
if (this.read_only_timer) {
2669
clearInterval(this.read_only_timer as any);
2670
this.read_only_timer = 0;
2671
}
2672
this.read_only_timer = <any>(
2673
setInterval(this.update_if_file_is_read_only, READ_ONLY_CHECK_INTERVAL_MS)
2674
);
2675
};
2676
2677
private handle_file_watcher_change = async (ctime: Date): Promise<void> => {
2678
const dbg = this.dbg("handle_file_watcher_change");
2679
const time: number = ctime.valueOf();
2680
dbg(
2681
`file_watcher: change, ctime=${time}, this.save_to_disk_start_ctime=${this.save_to_disk_start_ctime}, this.save_to_disk_end_ctime=${this.save_to_disk_end_ctime}`,
2682
);
2683
if (
2684
this.save_to_disk_start_ctime == null ||
2685
(this.save_to_disk_end_ctime != null &&
2686
time - this.save_to_disk_end_ctime >= RECENT_SAVE_TO_DISK_MS)
2687
) {
2688
// Either we never saved to disk, or the last attempt
2689
// to save was at least RECENT_SAVE_TO_DISK_MS ago, and it finished,
2690
// so definitely this change event was not caused by it.
2691
dbg("load_from_disk since no recent save to disk");
2692
await this.load_from_disk();
2693
return;
2694
}
2695
};
2696
2697
private handle_file_watcher_delete = async (): Promise<void> => {
2698
this.assert_is_ready("handle_file_watcher_delete");
2699
const dbg = this.dbg("handle_file_watcher_delete");
2700
dbg("delete: set_deleted and closing");
2701
await this.client.set_deleted(this.path, this.project_id);
2702
this.close();
2703
};
2704
2705
private load_from_disk = async (): Promise<number> => {
2706
const path = this.path;
2707
const dbg = this.dbg("load_from_disk");
2708
dbg();
2709
const exists: boolean = await callback2(this.client.path_exists, { path });
2710
let size: number;
2711
if (!exists) {
2712
dbg("file no longer exists -- setting to blank");
2713
size = 0;
2714
this.from_str("");
2715
} else {
2716
dbg("file exists");
2717
await this.update_if_file_is_read_only();
2718
2719
const data = await callback2<string>(this.client.path_read, {
2720
path,
2721
maxsize_MB: MAX_FILE_SIZE_MB,
2722
});
2723
2724
size = data.length;
2725
dbg(`got it -- length=${size}`);
2726
this.from_str(data);
2727
// we also know that this is the version on disk, so we update the hash
2728
this.commit();
2729
await this.set_save({
2730
state: "done",
2731
error: "",
2732
hash: hash_string(data),
2733
});
2734
}
2735
// save new version to database, which we just set via from_str.
2736
await this.save();
2737
return size;
2738
};
2739
2740
private set_save = async (save: {
2741
state: string;
2742
error: string;
2743
hash?: number;
2744
expected_hash?: number;
2745
time?: number;
2746
}): Promise<void> => {
2747
this.assert_table_is_ready("syncstring");
2748
// set timestamp of when the save happened; this can be useful
2749
// for coordinating running code, etc.... and is just generally useful.
2750
if (!save.time) {
2751
save.time = Date.now();
2752
}
2753
await this.set_syncstring_table({ save });
2754
};
2755
2756
private set_read_only = async (read_only: boolean): Promise<void> => {
2757
this.assert_table_is_ready("syncstring");
2758
await this.set_syncstring_table({ read_only });
2759
};
2760
2761
public is_read_only = (): boolean => {
2762
this.assert_table_is_ready("syncstring");
2763
return this.syncstring_table_get_one().get("read_only");
2764
};
2765
2766
public wait_until_read_only_known = async (): Promise<void> => {
2767
await this.wait_until_ready();
2768
function read_only_defined(t: SyncTable): boolean {
2769
const x = t.get_one();
2770
if (x == null) {
2771
return false;
2772
}
2773
return x.get("read_only") != null;
2774
}
2775
await this.syncstring_table.wait(read_only_defined, 5 * 60);
2776
};
2777
2778
/* Returns true if the current live version of this document has
2779
a different hash than the version mostly recently saved to disk.
2780
I.e., if there are changes that have not yet been **saved to
2781
disk**. See the other function has_uncommitted_changes below
2782
for determining whether there are changes that haven't been
2783
commited to the database yet. Returns *undefined* if
2784
initialization not even done yet. */
2785
public has_unsaved_changes = (): boolean | undefined => {
2786
if (this.state !== "ready") {
2787
return;
2788
}
2789
const dbg = this.dbg("has_unsaved_changes");
2790
try {
2791
return this.hash_of_saved_version() !== this.hash_of_live_version();
2792
} catch (err) {
2793
dbg(
2794
"exception computing hash_of_saved_version and hash_of_live_version",
2795
err,
2796
);
2797
// This could happen, e.g. when syncstring_table isn't connected
2798
// in some edge case. Better to just say we don't know then crash
2799
// everything. See https://github.com/sagemathinc/cocalc/issues/3577
2800
return;
2801
}
2802
};
2803
2804
// Returns hash of last version saved to disk (as far as we know).
2805
public hash_of_saved_version = (): number | undefined => {
2806
if (this.state !== "ready") {
2807
return;
2808
}
2809
return this.syncstring_table_get_one().getIn(["save", "hash"]) as
2810
| number
2811
| undefined;
2812
};
2813
2814
/* Return hash of the live version of the document,
2815
or undefined if the document isn't loaded yet.
2816
(TODO: write faster version of this for syncdb, which
2817
avoids converting to a string, which is a waste of time.) */
2818
public hash_of_live_version = (): number | undefined => {
2819
if (this.state !== "ready") {
2820
return;
2821
}
2822
return hash_string(this.doc.to_str());
2823
};
2824
2825
/* Return true if there are changes to this syncstring that
2826
have not been committed to the database (with the commit
2827
acknowledged). This does not mean the file has been
2828
written to disk; however, it does mean that it safe for
2829
the user to close their browser.
2830
*/
2831
public has_uncommitted_changes = (): boolean => {
2832
if (this.state !== "ready") {
2833
return false;
2834
}
2835
return this.patches_table.has_uncommitted_changes();
2836
};
2837
2838
// Commit any changes to the live document to
2839
// history as a new patch. Returns true if there
2840
// were changes and false otherwise. This works
2841
// fine offline, and does not wait until anything
2842
// is saved to the network, etc.
2843
public commit = (emitChangeImmediately = false): boolean => {
2844
if (this.last == null || this.doc == null || this.last.is_equal(this.doc)) {
2845
return false;
2846
}
2847
// console.trace('commit');
2848
2849
if (emitChangeImmediately) {
2850
// used for local clients. NOTE: don't do this without explicit
2851
// request, since it could in some cases cause serious trouble.
2852
// E.g., for the jupyter backend doing this by default causes
2853
// an infinite recurse. Having this as an option is important, e.g.,
2854
// to avoid flicker/delay in the UI.
2855
this.emit_change();
2856
}
2857
2858
// Now save to backend as a new patch:
2859
this.emit("user-change");
2860
const patch = this.last.make_patch(this.doc); // must be nontrivial
2861
this.last = this.doc;
2862
// ... and save that to patches table
2863
const time = this.next_patch_time();
2864
this.commit_patch(time, patch);
2865
this.save(); // so eventually also gets sent out.
2866
return true;
2867
};
2868
2869
/* Initiates a save of file to disk, then waits for the
2870
state to change. */
2871
public save_to_disk = async (): Promise<void> => {
2872
if (this.state != "ready") {
2873
// We just make save_to_disk a successful
2874
// no operation, if the document is either
2875
// closed or hasn't finished opening, since
2876
// there's a lot of code that tries to save
2877
// on exit/close or automatically, and it
2878
// is difficult to ensure it all checks state
2879
// properly.
2880
return;
2881
}
2882
const dbg = this.dbg("save_to_disk");
2883
if (this.client.is_deleted(this.path, this.project_id)) {
2884
dbg("not saving to disk because deleted");
2885
await this.set_save({ state: "done", error: "" });
2886
return;
2887
}
2888
2889
// Make sure to include changes to the live document.
2890
// A side effect of save if we didn't do this is potentially
2891
// discarding them, which is obviously not good.
2892
this.commit();
2893
2894
dbg("initiating the save");
2895
if (!this.has_unsaved_changes()) {
2896
dbg("no unsaved changes, so don't save");
2897
// CRITICAL: this optimization is assumed by
2898
// autosave, etc.
2899
await this.set_save({ state: "done", error: "" });
2900
return;
2901
}
2902
2903
if (this.is_read_only()) {
2904
dbg("read only, so can't save to disk");
2905
// save should fail if file is read only and there are changes
2906
throw Error("can't save readonly file with changes to disk");
2907
}
2908
2909
// First make sure any changes are saved to the database.
2910
// One subtle case where this matters is that loading a file
2911
// with \r's into codemirror changes them to \n...
2912
if (!(await this.isFileServer())) {
2913
dbg("browser client -- sending any changes over network");
2914
await this.save();
2915
dbg("save done; now do actual save to the *disk*.");
2916
this.assert_is_ready("save_to_disk - after save");
2917
}
2918
2919
try {
2920
await this.save_to_disk_aux();
2921
} catch (err) {
2922
const error = `save to disk failed -- ${err}`;
2923
dbg(error);
2924
if (await this.isFileServer()) {
2925
this.set_save({ error, state: "done" });
2926
}
2927
}
2928
2929
if (!(await this.isFileServer())) {
2930
dbg("now wait for the save to disk to finish");
2931
this.assert_is_ready("save_to_disk - waiting to finish");
2932
await this.wait_for_save_to_disk_done();
2933
}
2934
this.update_has_unsaved_changes();
2935
};
2936
2937
/* Export the (currently loaded) history of editing of this
2938
document to a simple JSON-able object. */
2939
public export_history = (
2940
options: HistoryExportOptions = {},
2941
): HistoryEntry[] => {
2942
this.assert_is_ready("export_history");
2943
const info = this.syncstring_table.get_one();
2944
if (info == null || !info.has("users")) {
2945
throw Error("syncstring table must be defined and users initialized");
2946
}
2947
const account_ids: string[] = info.get("users").toJS();
2948
assertDefined(this.patch_list);
2949
return export_history(account_ids, this.patch_list, options);
2950
};
2951
2952
private update_has_unsaved_changes(): void {
2953
if (this.state != "ready") {
2954
// This can happen, since this is called by a debounced function.
2955
// Make it a no-op in case we're not ready.
2956
// See https://github.com/sagemathinc/cocalc/issues/3577
2957
return;
2958
}
2959
const cur = this.has_unsaved_changes();
2960
if (cur !== this.last_has_unsaved_changes) {
2961
this.emit("has-unsaved-changes", cur);
2962
this.last_has_unsaved_changes = cur;
2963
}
2964
}
2965
2966
// wait for save.state to change state.
2967
private async wait_for_save_to_disk_done(): Promise<void> {
2968
const dbg = this.dbg("wait_for_save_to_disk_done");
2969
dbg();
2970
function until(table): boolean {
2971
const done = table.get_one().getIn(["save", "state"]) === "done";
2972
dbg("checking... done=", done);
2973
return done;
2974
}
2975
2976
let last_err: string | undefined = undefined;
2977
const f = async () => {
2978
dbg("f");
2979
if (
2980
this.state != "ready" ||
2981
this.client.is_deleted(this.path, this.project_id)
2982
) {
2983
dbg("not ready or deleted - no longer trying to save.");
2984
return;
2985
}
2986
try {
2987
dbg("waiting until done...");
2988
await this.syncstring_table.wait(until, 15);
2989
} catch (err) {
2990
dbg("timed out after 15s");
2991
throw Error("timed out");
2992
}
2993
if (
2994
this.state != "ready" ||
2995
this.client.is_deleted(this.path, this.project_id)
2996
) {
2997
dbg("not ready or deleted - no longer trying to save.");
2998
return;
2999
}
3000
const err = this.syncstring_table_get_one().getIn(["save", "error"]) as
3001
| string
3002
| undefined;
3003
if (err) {
3004
dbg("error", err);
3005
last_err = err;
3006
throw Error(err);
3007
}
3008
dbg("done, with no error.");
3009
last_err = undefined;
3010
return;
3011
};
3012
await retry_until_success({
3013
f,
3014
max_tries: 8,
3015
desc: "wait_for_save_to_disk_done",
3016
});
3017
if (
3018
this.state != "ready" ||
3019
this.client.is_deleted(this.path, this.project_id)
3020
) {
3021
return;
3022
}
3023
if (last_err && typeof this.client.log_error === "function") {
3024
this.client.log_error({
3025
string_id: this.string_id,
3026
path: this.path,
3027
project_id: this.project_id,
3028
error: `Error saving file -- ${last_err}`,
3029
});
3030
}
3031
}
3032
3033
/* Auxiliary function 2 for saving to disk:
3034
If this is associated with
3035
a project and has a filename.
3036
A user (web browsers) sets the save state to requested.
3037
The project sets the state to saving, does the save
3038
to disk, then sets the state to done.
3039
*/
3040
private async save_to_disk_aux(): Promise<void> {
3041
this.assert_is_ready("save_to_disk_aux");
3042
3043
if (!(await this.isFileServer())) {
3044
return await this.save_to_disk_non_filesystem_owner();
3045
}
3046
3047
try {
3048
return await this.save_to_disk_filesystem_owner();
3049
} catch (err) {
3050
this.emit("save_to_disk_filesystem_owner", err);
3051
throw err;
3052
}
3053
}
3054
3055
private async save_to_disk_non_filesystem_owner(): Promise<void> {
3056
this.assert_is_ready("save_to_disk_non_filesystem_owner");
3057
3058
if (!this.has_unsaved_changes()) {
3059
/* Browser client has no unsaved changes,
3060
so don't need to save --
3061
CRITICAL: this optimization is assumed by autosave.
3062
*/
3063
return;
3064
}
3065
const x = this.syncstring_table.get_one();
3066
if (x != null && x.getIn(["save", "state"]) === "requested") {
3067
// Nothing to do -- save already requested, which is
3068
// all the browser client has to do.
3069
return;
3070
}
3071
3072
// string version of this doc
3073
const data: string = this.to_str();
3074
const expected_hash = hash_string(data);
3075
await this.set_save({ state: "requested", error: "", expected_hash });
3076
}
3077
3078
private async save_to_disk_filesystem_owner(): Promise<void> {
3079
this.assert_is_ready("save_to_disk_filesystem_owner");
3080
const dbg = this.dbg("save_to_disk_filesystem_owner");
3081
3082
// check if on-disk version is same as in memory, in
3083
// which case no save is needed.
3084
const data = this.to_str(); // string version of this doc
3085
const hash = hash_string(data);
3086
dbg("hash = ", hash);
3087
3088
/*
3089
// TODO: put this consistency check back in (?).
3090
const expected_hash = this.syncstring_table
3091
.get_one()
3092
.getIn(["save", "expected_hash"]);
3093
*/
3094
3095
if (hash === this.hash_of_saved_version()) {
3096
// No actual save to disk needed; still we better
3097
// record this fact in table in case it
3098
// isn't already recorded
3099
this.set_save({ state: "done", error: "", hash });
3100
return;
3101
}
3102
3103
const path = this.path;
3104
if (!path) {
3105
const err = "cannot save without path";
3106
this.set_save({ state: "done", error: err });
3107
throw Error(err);
3108
}
3109
3110
dbg("project - write to disk file", path);
3111
// set window to slightly earlier to account for clock
3112
// imprecision.
3113
// Over an sshfs mount, all stats info is **rounded down
3114
// to the nearest second**, which this also takes care of.
3115
this.save_to_disk_start_ctime = Date.now() - 1500;
3116
this.save_to_disk_end_ctime = undefined;
3117
try {
3118
await callback2(this.client.write_file, { path, data });
3119
this.assert_is_ready("save_to_disk_filesystem_owner -- after write_file");
3120
const stat = await callback2(this.client.path_stat, { path });
3121
this.assert_is_ready("save_to_disk_filesystem_owner -- after path_state");
3122
this.save_to_disk_end_ctime = stat.ctime.valueOf() + 1500;
3123
this.set_save({
3124
state: "done",
3125
error: "",
3126
hash: hash_string(data),
3127
});
3128
} catch (err) {
3129
this.set_save({ state: "done", error: JSON.stringify(err) });
3130
throw err;
3131
}
3132
}
3133
3134
/*
3135
When the underlying synctable that defines the state
3136
of the document changes due to new remote patches, this
3137
function is called.
3138
It handles update of the remote version, updating our
3139
live version as a result.
3140
*/
3141
private async handle_patch_update(changed_keys): Promise<void> {
3142
if (changed_keys == null || changed_keys.length === 0) {
3143
// this happens right now when we do a save.
3144
return;
3145
}
3146
3147
const dbg = this.dbg("handle_patch_update");
3148
//dbg(changed_keys);
3149
if (this.patch_update_queue == null) {
3150
this.patch_update_queue = [];
3151
}
3152
for (const key of changed_keys) {
3153
this.patch_update_queue.push(key);
3154
}
3155
3156
dbg("Clear patch update_queue in a later event loop...");
3157
await delay(1);
3158
await this.handle_patch_update_queue();
3159
dbg("done");
3160
}
3161
3162
/*
3163
Whenever new patches are added to this.patches_table,
3164
their timestamp gets added to this.patch_update_queue.
3165
*/
3166
private async handle_patch_update_queue(): Promise<void> {
3167
const dbg = this.dbg("handle_patch_update_queue");
3168
try {
3169
this.handle_patch_update_queue_running = true;
3170
while (this.state != "closed" && this.patch_update_queue.length > 0) {
3171
dbg("queue size = ", this.patch_update_queue.length);
3172
const v: Patch[] = [];
3173
for (const key of this.patch_update_queue) {
3174
const x = this.patches_table.get(key);
3175
if (x != null) {
3176
// may be null, e.g., when deleted.
3177
const t = x.get("time");
3178
// Only need to process patches that we didn't
3179
// create ourselves.
3180
if (t && !this.my_patches[`${t.valueOf()}`]) {
3181
const p = this.process_patch(x);
3182
//dbg(`patch=${JSON.stringify(p)}`);
3183
if (p != null) {
3184
v.push(p);
3185
}
3186
}
3187
}
3188
}
3189
this.patch_update_queue = [];
3190
assertDefined(this.patch_list);
3191
this.patch_list.add(v);
3192
3193
dbg("waiting for remote and doc to sync...");
3194
this.sync_remote_and_doc(v.length > 0);
3195
await this.patches_table.save();
3196
if (this.state === ("closed" as State)) return; // closed during await; nothing further to do
3197
dbg("remote and doc now synced");
3198
3199
if (this.patch_update_queue.length > 0) {
3200
// It is very important that next loop happen in a later
3201
// event loop to avoid the this.sync_remote_and_doc call
3202
// in this.handle_patch_update_queue above from causing
3203
// sync_remote_and_doc to get called from within itself,
3204
// due to synctable changes being emited on save.
3205
dbg("wait for next event loop");
3206
await delay(1);
3207
} else {
3208
dbg("Patch sent, now make a snapshot if we are due for one.");
3209
await this.snapshot_if_necessary();
3210
}
3211
}
3212
} finally {
3213
if (this.state == "closed") return; // got closed, so nothing further to do
3214
3215
// OK, done and nothing in the queue
3216
// Notify save() to try again -- it may have
3217
// paused waiting for this to clear.
3218
dbg("done");
3219
this.handle_patch_update_queue_running = false;
3220
this.emit("handle_patch_update_queue_done");
3221
}
3222
}
3223
3224
/* Disable and enable sync. When disabled we still
3225
collect patches from upstream (but do not apply them
3226
locally), and changes we make are broadcast into
3227
the patch stream. When we re-enable sync, all
3228
patches are put together in the stream and
3229
everything is synced as normal. This is useful, e.g.,
3230
to make it so a user **actively** editing a document is
3231
not interrupted by being forced to sync (in particular,
3232
by the 'before-change' event that they use to update
3233
the live document).
3234
3235
Also, delay_sync will delay syncing local with upstream
3236
for the given number of ms. Calling it regularly while
3237
user is actively editing to avoid them being bothered
3238
by upstream patches getting merged in.
3239
3240
IMPORTANT: I implemented this, but it is NOT used anywhere
3241
else in the codebase, so don't trust that it works.
3242
*/
3243
3244
public disable_sync = (): void => {
3245
this.sync_is_disabled = true;
3246
};
3247
3248
public enable_sync = (): void => {
3249
this.sync_is_disabled = false;
3250
this.sync_remote_and_doc(true);
3251
};
3252
3253
public delay_sync = (timeout_ms = 2000): void => {
3254
clearTimeout(this.delay_sync_timer);
3255
this.disable_sync();
3256
this.delay_sync_timer = setTimeout(() => {
3257
this.enable_sync();
3258
}, timeout_ms);
3259
};
3260
3261
/*
3262
Merge remote patches and live version to create new live version,
3263
which is equal to result of applying all patches.
3264
*/
3265
private sync_remote_and_doc(upstreamPatches: boolean): void {
3266
if (this.last == null || this.doc == null || this.sync_is_disabled) {
3267
return;
3268
}
3269
3270
// Critical to save what we have now so it doesn't get overwritten during
3271
// before-change or setting this.doc below. This caused
3272
// https://github.com/sagemathinc/cocalc/issues/5871
3273
this.commit();
3274
3275
if (upstreamPatches && this.state == "ready") {
3276
// First save any unsaved changes from the live document, which this
3277
// sync-doc doesn't acutally know the state of. E.g., this is some
3278
// rapidly changing live editor with changes not yet saved here.
3279
this.emit("before-change");
3280
// As a result of the emit in the previous line, all kinds of
3281
// nontrivial listener code probably just ran, and it should
3282
// have updated this.doc. We commit this.doc, so that the
3283
// upstream patches get applied against the correct live this.doc.
3284
this.commit();
3285
}
3286
3287
// Compute the global current state of the document,
3288
// which is got by applying all patches in order.
3289
// It is VERY important to do this, even if the
3290
// document is not yet ready, since it is critical
3291
// to properly set the state of this.doc to the value
3292
// of the patch list (e.g., not doing this 100% breaks
3293
// opening a file for the first time on cocalc-docker).
3294
assertDefined(this.patch_list);
3295
const new_remote = this.patch_list.value();
3296
if (!this.doc.is_equal(new_remote)) {
3297
// There is a possibility that live document changed, so
3298
// set to new version.
3299
this.last = this.doc = new_remote;
3300
if (this.state == "ready") {
3301
this.emit("after-change");
3302
this.emit_change();
3303
}
3304
}
3305
}
3306
3307
// Immediately alert all watchers of all changes since
3308
// last time.
3309
private emit_change(): void {
3310
this.emit("change", this.doc?.changes(this.before_change));
3311
this.before_change = this.doc;
3312
}
3313
3314
// Alert to changes soon, but debounced in case there are a large
3315
// number of calls in a group. This is called by default.
3316
// The debounce param is 0, since the idea is that this just waits
3317
// until the next "render loop" to avoid huge performance issues
3318
// with a nested for loop of sets. Doing it this way, massively
3319
// simplifies client code.
3320
emit_change_debounced = debounce(this.emit_change.bind(this), 0);
3321
3322
private set_syncstring_table = async (obj, save = true) => {
3323
let value = this.syncstring_table_get_one();
3324
const value0 = value;
3325
for (const key in obj) {
3326
value = value.set(key, obj[key]);
3327
}
3328
if (value0.equals(value)) {
3329
return;
3330
}
3331
this.syncstring_table.set(value);
3332
if (save) {
3333
await this.syncstring_table.save();
3334
}
3335
};
3336
}
3337
3338