Path: blob/master/src/packages/sync/editor/generic/sync-doc.ts
1450 views
/*1* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45/*6SyncDoc -- the core class for editing with a synchronized document.78This code supports both string-doc and db-doc, for editing both9strings and small database tables efficiently, with history,10undo, save to disk, etc.1112This code is run *both* in browser clients and under node.js13in projects, and behaves slightly differently in each case.1415EVENTS:1617- before-change: fired before merging in changes from upstream18- ... TODO19*/2021const USE_CONAT = true;2223/* OFFLINE_THRESH_S - If the client becomes disconnected from24the backend for more than this long then---on reconnect---do25extra work to ensure that all snapshots are up to date (in26case snapshots were made when we were offline), and mark the27sent field of patches that weren't saved. I.e., we rebase28all offline changes. */29// const OFFLINE_THRESH_S = 5 * 60; // 5 minutes.3031/* How often the local hub will autosave this file to disk if32it has it open and there are unsaved changes. This is very33important since it ensures that a user that edits a file but34doesn't click "Save" and closes their browser (right after35their edits have gone to the database), still has their36file saved to disk soon. This is important, e.g., for homework37getting collected and not missing the last few changes. It turns38out this is what people expect.39Set to 0 to disable. (But don't do that.) */40const FILE_SERVER_AUTOSAVE_S = 45;41// const FILE_SERVER_AUTOSAVE_S = 5;4243// How big of files we allow users to open using syncstrings.44const MAX_FILE_SIZE_MB = 32;4546// How frequently to check if file is or is not read only.47// The filesystem watcher is NOT sufficient for this, because48// it is NOT triggered on permissions changes. Thus we must49// poll for read only status periodically, unfortunately.50const READ_ONLY_CHECK_INTERVAL_MS = 7500;5152// This parameter determines throttling when broadcasting cursor position53// updates. Make this larger to reduce bandwidth at the expense of making54// cursors less responsive.55const CURSOR_THROTTLE_MS = 750;5657// NATS is much faster and can handle load, and cursors only uses pub/sub58const CURSOR_THROTTLE_NATS_MS = 150;5960// Ignore file changes for this long after save to disk.61const RECENT_SAVE_TO_DISK_MS = 2000;6263const PARALLEL_INIT = true;6465import {66COMPUTE_THRESH_MS,67COMPUTER_SERVER_CURSOR_TYPE,68decodeUUIDtoNum,69SYNCDB_PARAMS as COMPUTE_SERVE_MANAGER_SYNCDB_PARAMS,70} from "@cocalc/util/compute/manager";7172import { DEFAULT_SNAPSHOT_INTERVAL } from "@cocalc/util/db-schema/syncstring-schema";7374type XPatch = any;7576import { reuseInFlight } from "@cocalc/util/reuse-in-flight";77import { SyncTable } from "@cocalc/sync/table/synctable";78import {79callback2,80cancel_scheduled,81once,82retry_until_success,83reuse_in_flight_methods,84until,85} from "@cocalc/util/async-utils";86import { wait } from "@cocalc/util/async-wait";87import {88auxFileToOriginal,89assertDefined,90close,91endswith,92field_cmp,93filename_extension,94hash_string,95keys,96minutes_ago,97} from "@cocalc/util/misc";98import * as schema from "@cocalc/util/schema";99import { delay } from "awaiting";100import { EventEmitter } from "events";101import { Map, fromJS } from "immutable";102import { debounce, throttle } from "lodash";103import { Evaluator } from "./evaluator";104import { HistoryEntry, HistoryExportOptions, export_history } from "./export";105import { IpywidgetsState } from "./ipywidgets-state";106import { SortedPatchList } from "./sorted-patch-list";107import type {108Client,109CompressedPatch,110DocType,111Document,112FileWatcher,113Patch,114} from "./types";115import { isTestClient, patch_cmp } from "./util";116import { CONAT_OPEN_FILE_TOUCH_INTERVAL } from "@cocalc/util/conat";117import mergeDeep from "@cocalc/util/immutable-deep-merge";118import { JUPYTER_SYNCDB_EXTENSIONS } from "@cocalc/util/jupyter/names";119import { LegacyHistory } from "./legacy";120import { getLogger } from "@cocalc/conat/client";121122const DEBUG = false;123124export type State = "init" | "ready" | "closed";125export type DataServer = "project" | "database";126127export interface SyncOpts0 {128project_id: string;129path: string;130client: Client;131patch_interval?: number;132133// file_use_interval defaults to 60000.134// Specify 0 to disable.135file_use_interval?: number;136137string_id?: string;138cursors?: boolean;139change_throttle?: number;140141// persistent backend session in project, so only close142// backend when explicitly requested:143persistent?: boolean;144145// If true, entire sync-doc is assumed ephemeral, in the146// sense that no edit history gets saved via patches to147// the database. The one syncstring record for coordinating148// users does get created in the database.149ephemeral?: boolean;150151// which data/changefeed server to use152data_server?: DataServer;153}154155export interface SyncOpts extends SyncOpts0 {156from_str: (str: string) => Document;157doctype: DocType;158}159160export interface UndoState {161my_times: number[];162pointer: number;163without: number[];164final?: CompressedPatch;165}166167// NOTE: Do not make multiple SyncDoc's for the same document, especially168// not on the frontend.169170const logger = getLogger("sync-doc");171logger.debug("init");172173export class SyncDoc extends EventEmitter {174public readonly project_id: string; // project_id that contains the doc175public readonly path: string; // path of the file corresponding to the doc176private string_id: string;177private my_user_id: number;178179private client: Client;180private _from_str: (str: string) => Document; // creates a doc from a string.181182// Throttling of incoming upstream patches from project to client.183private patch_interval: number = 250;184185// This is what's actually output by setInterval -- it's186// not an amount of time.187private fileserver_autosave_timer: number = 0;188189private read_only_timer: number = 0;190191// throttling of change events -- e.g., is useful for course192// editor where we have hundreds of changes and the UI gets193// overloaded unless we throttle and group them.194private change_throttle: number = 0;195196// file_use_interval throttle: default is 60s for everything197private file_use_interval: number;198private throttled_file_use?: Function;199200private cursors: boolean = false; // if true, also provide cursor tracking functionality201private cursor_map: Map<string, any> = Map();202private cursor_last_time: Date = new Date(0);203204// doctype: object describing document constructor205// (used by project to open file)206private doctype: DocType;207208private state: State = "init";209210private syncstring_table: SyncTable;211private patches_table: SyncTable;212private cursors_table: SyncTable;213214public evaluator?: Evaluator;215216public ipywidgets_state?: IpywidgetsState;217218private patch_list?: SortedPatchList;219220private last: Document;221private doc: Document;222private before_change?: Document;223224private last_user_change: Date = minutes_ago(60);225private last_save_to_disk_time: Date = new Date(0);226227private last_snapshot?: number;228private last_seq?: number;229private snapshot_interval: number;230231private users: string[];232233private settings: Map<string, any> = Map();234235private syncstring_save_state: string = "";236237// patches that this client made during this editing session.238private my_patches: { [time: string]: XPatch } = {};239240private watch_path?: string;241private file_watcher?: FileWatcher;242243private handle_patch_update_queue_running: boolean;244private patch_update_queue: string[] = [];245246private undo_state: UndoState | undefined;247248private save_to_disk_start_ctime: number | undefined;249private save_to_disk_end_ctime: number | undefined;250251private persistent: boolean = false;252253private last_has_unsaved_changes?: boolean = undefined;254255private ephemeral: boolean = false;256257private sync_is_disabled: boolean = false;258private delay_sync_timer: any;259260// static because we want exactly one across all docs!261private static computeServerManagerDoc?: SyncDoc;262263private useConat: boolean;264legacy: LegacyHistory;265266constructor(opts: SyncOpts) {267super();268if (opts.string_id === undefined) {269this.string_id = schema.client_db.sha1(opts.project_id, opts.path);270} else {271this.string_id = opts.string_id;272}273274for (const field of [275"project_id",276"path",277"client",278"patch_interval",279"file_use_interval",280"change_throttle",281"cursors",282"doctype",283"from_patch_str",284"persistent",285"data_server",286"ephemeral",287]) {288if (opts[field] != undefined) {289this[field] = opts[field];290}291}292293this.legacy = new LegacyHistory({294project_id: this.project_id,295path: this.path,296client: this.client,297});298299// NOTE: Do not use conat in test mode, since there we use a minimal300// "fake" client that does all communication internally and doesn't301// use conat. We also use this for the messages composer.302this.useConat = USE_CONAT && !isTestClient(opts.client);303if (this.ephemeral) {304// So the doctype written to the database reflects the305// ephemeral state. Here ephemeral determines whether306// or not patches are written to the database by the307// project.308this.doctype.opts = { ...this.doctype.opts, ephemeral: true };309}310if (this.cursors) {311// similarly to ephemeral, but for cursors. We track them312// on the backend since they can also be very useful, e.g.,313// with jupyter they are used for connecting remote compute,314// and **should** also be used for broadcasting load and other315// status information (TODO).316this.doctype.opts = { ...this.doctype.opts, cursors: true };317}318this._from_str = opts.from_str;319320// Initialize to time when we create the syncstring, so we don't321// see our own cursor when we refresh the browser (before we move322// to update this).323this.cursor_last_time = this.client?.server_time();324325reuse_in_flight_methods(this, [326"save",327"save_to_disk",328"load_from_disk",329"handle_patch_update_queue",330]);331332if (this.change_throttle) {333this.emit_change = throttle(this.emit_change, this.change_throttle);334}335336this.setMaxListeners(100);337338this.init();339}340341/*342Initialize everything.343This should be called *exactly* once by the constructor,344and no other time. It tries to set everything up. If345the browser isn't connected to the network, it'll wait346until it is (however long, etc.). If this fails, it closes347this SyncDoc.348*/349private initialized = false;350private init = async () => {351if (this.initialized) {352throw Error("init can only be called once");353}354// const start = Date.now();355this.assert_not_closed("init");356const log = this.dbg("init");357await until(358async () => {359if (this.state != "init") {360return true;361}362try {363log("initializing all tables...");364await this.initAll();365log("initAll succeeded");366return true;367} catch (err) {368console.trace(err);369const m = `WARNING: problem initializing ${this.path} -- ${err}`;370log(m);371// log always:372console.log(m);373}374log("wait then try again");375return false;376},377{ start: 3000, max: 15000, decay: 1.3 },378);379380// Success -- everything initialized with no issues.381this.set_state("ready");382this.init_watch();383this.emit_change(); // from nothing to something.384};385386// True if this client is responsible for managing387// the state of this document with respect to388// the file system. By default, the project is responsible,389// but it could be something else (e.g., a compute server!). It's390// important that whatever algorithm determines this, it is391// a function of state that is eventually consistent.392// IMPORTANT: whether or not we are the file server can393// change over time, so if you call isFileServer and394// set something up (e.g., autosave or a watcher), based395// on the result, you need to clear it when the state396// changes. See the function handleComputeServerManagerChange.397private isFileServer = reuseInFlight(async () => {398if (this.state == "closed") return;399if (this.client == null || this.client.is_browser()) {400// browser is never the file server (yet), and doesn't need to do401// anything related to watching for changes in state.402// Someday via webassembly or browsers making users files availabl,403// etc., we will have this. Not today.404return false;405}406const computeServerManagerDoc = this.getComputeServerManagerDoc();407const log = this.dbg("isFileServer");408if (computeServerManagerDoc == null) {409log("not using compute server manager for this doc");410return this.client.is_project();411}412413const state = computeServerManagerDoc.get_state();414log("compute server manager doc state: ", state);415if (state == "closed") {416log("compute server manager is closed");417// something really messed up418return this.client.is_project();419}420if (state != "ready") {421try {422log(423"waiting for compute server manager doc to be ready; current state=",424state,425);426await once(computeServerManagerDoc, "ready", 15000);427log("compute server manager is ready");428} catch (err) {429log(430"WARNING -- failed to initialize computeServerManagerDoc -- err=",431err,432);433return this.client.is_project();434}435}436437// id of who the user *wants* to be the file server.438const path = this.getFileServerPath();439const fileServerId =440computeServerManagerDoc.get_one({ path })?.get("id") ?? 0;441if (this.client.is_project()) {442log(443"we are project, so we are fileserver if fileServerId=0 and it is ",444fileServerId,445);446return fileServerId == 0;447}448// at this point we have to be a compute server449const computeServerId = decodeUUIDtoNum(this.client.client_id());450// this is usually true -- but might not be if we are switching451// directly from one compute server to another.452log("we are compute server and ", { fileServerId, computeServerId });453return fileServerId == computeServerId;454});455456private getFileServerPath = () => {457if (this.path?.endsWith("." + JUPYTER_SYNCDB_EXTENSIONS)) {458// treating jupyter as a weird special case here.459return auxFileToOriginal(this.path);460}461return this.path;462};463464private getComputeServerManagerDoc = () => {465if (this.path == COMPUTE_SERVE_MANAGER_SYNCDB_PARAMS.path) {466// don't want to recursively explode!467return null;468}469if (SyncDoc.computeServerManagerDoc == null) {470if (this.client.is_project()) {471// @ts-ignore: TODO!472SyncDoc.computeServerManagerDoc = this.client.syncdoc({473path: COMPUTE_SERVE_MANAGER_SYNCDB_PARAMS.path,474});475} else {476// @ts-ignore: TODO!477SyncDoc.computeServerManagerDoc = this.client.sync_client.sync_db({478project_id: this.project_id,479...COMPUTE_SERVE_MANAGER_SYNCDB_PARAMS,480});481}482if (483SyncDoc.computeServerManagerDoc != null &&484!this.client.is_browser()485) {486// start watching for state changes487SyncDoc.computeServerManagerDoc.on(488"change",489this.handleComputeServerManagerChange,490);491}492}493return SyncDoc.computeServerManagerDoc;494};495496private handleComputeServerManagerChange = async (keys) => {497if (SyncDoc.computeServerManagerDoc == null) {498return;499}500let relevant = false;501for (const key of keys ?? []) {502if (key.get("path") == this.path) {503relevant = true;504break;505}506}507if (!relevant) {508return;509}510const path = this.getFileServerPath();511const fileServerId =512SyncDoc.computeServerManagerDoc.get_one({ path })?.get("id") ?? 0;513const ourId = this.client.is_project()514? 0515: decodeUUIDtoNum(this.client.client_id());516// we are considering ourself the file server already if we have517// either a watcher or autosave on.518const thinkWeAreFileServer =519this.file_watcher != null || this.fileserver_autosave_timer;520const weAreFileServer = fileServerId == ourId;521if (thinkWeAreFileServer != weAreFileServer) {522// life has changed! Let's adapt.523if (thinkWeAreFileServer) {524// we were acting as the file server, but now we are not.525await this.save_to_disk_filesystem_owner();526// Stop doing things we are no longer supposed to do.527clearInterval(this.fileserver_autosave_timer as any);528this.fileserver_autosave_timer = 0;529// stop watching filesystem530await this.update_watch_path();531} else {532// load our state from the disk533await this.load_from_disk();534// we were not acting as the file server, but now we need. Let's535// step up to the plate.536// start watching filesystem537await this.update_watch_path(this.path);538// enable autosave539await this.init_file_autosave();540}541}542};543544// Return id of ACTIVE remote compute server, if one is connected and pinging, or 0545// if none is connected. This is used by Jupyter to determine who546// should evaluate code.547// We always take the smallest id of the remote548// compute servers, in case there is more than one, so exactly one of them549// takes control. Always returns 0 if cursors are not enabled for this550// document, since the cursors table is used to coordinate the compute551// server.552getComputeServerId = (): number => {553if (!this.cursors) {554return 0;555}556// This info is in the "cursors" table instead of the document itself557// to avoid wasting space in the database longterm. Basically a remote558// Jupyter client that can provide compute announces this by reporting it's559// cursor to look a certain way.560const cursors = this.get_cursors({561maxAge: COMPUTE_THRESH_MS,562// don't exclude self since getComputeServerId called from the compute563// server also to know if it is the chosen one.564excludeSelf: "never",565});566const dbg = this.dbg("getComputeServerId");567dbg("num cursors = ", cursors.size);568let minId = Infinity;569// NOTE: similar code is in frontend/jupyter/cursor-manager.ts570for (const [client_id, cursor] of cursors) {571if (cursor.getIn(["locs", 0, "type"]) == COMPUTER_SERVER_CURSOR_TYPE) {572try {573minId = Math.min(minId, decodeUUIDtoNum(client_id));574} catch (err) {575// this should never happen unless a client were being malicious.576dbg(577"WARNING -- client_id should encode server id, but is",578client_id,579);580}581}582}583584return isFinite(minId) ? minId : 0;585};586587registerAsComputeServer = () => {588this.setCursorLocsNoThrottle([{ type: COMPUTER_SERVER_CURSOR_TYPE }]);589};590591/* Set this user's cursors to the given locs. */592setCursorLocsNoThrottle = async (593// locs is 'any' and not any[] because of a codemirror syntax highlighting bug!594locs: any,595side_effect: boolean = false,596) => {597if (this.state != "ready") {598return;599}600if (this.cursors_table == null) {601if (!this.cursors) {602throw Error("cursors are not enabled");603}604// table not initialized yet605return;606}607if (this.useConat) {608const time = this.client.server_time().valueOf();609const x: {610user_id: number;611locs: any;612time: number;613} = {614user_id: this.my_user_id,615locs,616time,617};618// will actually always be non-null due to above619this.cursor_last_time = new Date(x.time);620this.cursors_table.set(x);621return;622}623624const x: {625string_id?: string;626user_id: number;627locs: any[];628time?: Date;629} = {630string_id: this.string_id,631user_id: this.my_user_id,632locs,633};634const now = this.client.server_time();635if (!side_effect || (x.time ?? now) >= now) {636// the now comparison above is in case the cursor time637// is in the future (due to clock issues) -- always fix that.638x.time = now;639}640if (x.time != null) {641// will actually always be non-null due to above642this.cursor_last_time = x.time;643}644this.cursors_table.set(x, "none");645await this.cursors_table.save();646};647648set_cursor_locs: typeof this.setCursorLocsNoThrottle = throttle(649this.setCursorLocsNoThrottle,650USE_CONAT ? CURSOR_THROTTLE_NATS_MS : CURSOR_THROTTLE_MS,651{652leading: true,653trailing: true,654},655);656657private init_file_use_interval = (): void => {658if (this.file_use_interval == null) {659this.file_use_interval = 60 * 1000;660}661662if (!this.file_use_interval || !this.client.is_browser()) {663// file_use_interval has to be nonzero, and we only do664// this for browser user.665return;666}667668const file_use = async () => {669await delay(100); // wait a little so my_patches and gets updated.670// We ONLY count this and record that the file was671// edited if there was an actual change record in the672// patches log, by this user, since last time.673let user_is_active: boolean = false;674for (const tm in this.my_patches) {675if (new Date(parseInt(tm)) > this.last_user_change) {676user_is_active = true;677break;678}679}680if (!user_is_active) {681return;682}683this.last_user_change = new Date();684this.client.mark_file?.({685project_id: this.project_id,686path: this.path,687action: "edit",688ttl: this.file_use_interval,689});690};691this.throttled_file_use = throttle(file_use, this.file_use_interval, {692leading: true,693});694695this.on("user-change", this.throttled_file_use as any);696};697698private set_state = (state: State): void => {699this.state = state;700this.emit(state);701};702703get_state = (): State => {704return this.state;705};706707get_project_id = (): string => {708return this.project_id;709};710711get_path = (): string => {712return this.path;713};714715get_string_id = (): string => {716return this.string_id;717};718719get_my_user_id = (): number => {720return this.my_user_id != null ? this.my_user_id : 0;721};722723private assert_not_closed(desc: string): void {724if (this.state === "closed") {725//console.trace();726throw Error(`must not be closed -- ${desc}`);727}728}729730set_doc = (doc: Document, exit_undo_mode: boolean = true): void => {731if (doc.is_equal(this.doc)) {732// no change.733return;734}735if (exit_undo_mode) this.undo_state = undefined;736// console.log(`sync-doc.set_doc("${doc.to_str()}")`);737this.doc = doc;738739// debounced, so don't immediately alert, in case there are many740// more sets comming in the same loop:741this.emit_change_debounced();742};743744// Convenience function to avoid having to do745// get_doc and set_doc constantly.746set = (x: any): void => {747this.set_doc(this.doc.set(x));748};749750delete = (x?: any): void => {751this.set_doc(this.doc.delete(x));752};753754get = (x?: any): any => {755return this.doc.get(x);756};757758get_one(x?: any): any {759return this.doc.get_one(x);760}761762// Return underlying document, or undefined if document763// hasn't been set yet.764get_doc = (): Document => {765if (this.doc == null) {766throw Error("doc must be set");767}768return this.doc;769};770771// Set this doc from its string representation.772from_str = (value: string): void => {773// console.log(`sync-doc.from_str("${value}")`);774this.doc = this._from_str(value);775};776777// Return string representation of this doc,778// or exception if not yet ready.779to_str = (): string => {780if (this.doc == null) {781throw Error("doc must be set");782}783return this.doc.to_str();784};785786count = (): number => {787return this.doc.count();788};789790// Version of the document at a given point in time; if no791// time specified, gives the version right now.792// If not fully initialized, will throw exception.793version = (time?: number): Document => {794this.assert_table_is_ready("patches");795assertDefined(this.patch_list);796return this.patch_list.value({ time });797};798799/* Compute version of document if the patches at the given times800were simply not included. This is a building block that is801used for implementing undo functionality for client editors. */802version_without = (without_times: number[]): Document => {803this.assert_table_is_ready("patches");804assertDefined(this.patch_list);805return this.patch_list.value({ without_times });806};807808// Revert document to what it was at the given point in time.809// There doesn't have to be a patch at exactly that point in810// time -- if there isn't it just uses the patch before that811// point in time.812revert = (time: number): void => {813this.set_doc(this.version(time));814};815816/* Undo/redo public api.817Calling this.undo and this.redo returns the version of818the document after the undo or redo operation, and records819a commit changing to that.820The first time calling this.undo switches into undo821state in which additional822calls to undo/redo move up and down the stack of changes made823by this user during this session.824825Call this.exit_undo_mode() to exit undo/redo mode.826827Undo and redo *only* impact changes made by this user during828this session. Other users edits are unaffected, and work by829this same user working from another browser tab or session is830also unaffected.831832Finally, undo of a past patch by definition means "the state833of the document" if that patch was not applied. The impact834of undo is NOT that the patch is removed from the patch history.835Instead, it records a new patch that is what would have happened836had we replayed history with the patches being undone not there.837838Doing any set_doc explicitly exits undo mode automatically.839*/840undo = (): Document => {841const prev = this._undo();842this.set_doc(prev, false);843this.commit();844return prev;845};846847redo = (): Document => {848const next = this._redo();849this.set_doc(next, false);850this.commit();851return next;852};853854private _undo(): Document {855this.assert_is_ready("_undo");856let state = this.undo_state;857if (state == null) {858// not in undo mode859state = this.initUndoState();860}861if (state.pointer === state.my_times.length) {862// pointing at live state (e.g., happens on entering undo mode)863const value: Document = this.version(); // last saved version864const live: Document = this.doc;865if (!live.is_equal(value)) {866// User had unsaved changes, so last undo is to revert to version without those.867state.final = value.make_patch(live); // live redo if needed868state.pointer -= 1; // most recent timestamp869return value;870} else {871// User had no unsaved changes, so last undo is version without last saved change.872const tm = state.my_times[state.pointer - 1];873state.pointer -= 2;874if (tm != null) {875state.without.push(tm);876return this.version_without(state.without);877} else {878// no undo information during this session879return value;880}881}882} else {883// pointing at particular timestamp in the past884if (state.pointer >= 0) {885// there is still more to undo886state.without.push(state.my_times[state.pointer]);887state.pointer -= 1;888}889return this.version_without(state.without);890}891}892893private _redo(): Document {894this.assert_is_ready("_redo");895const state = this.undo_state;896if (state == null) {897// nothing to do but return latest live version898return this.get_doc();899}900if (state.pointer === state.my_times.length) {901// pointing at live state -- nothing to do902return this.get_doc();903} else if (state.pointer === state.my_times.length - 1) {904// one back from live state, so apply unsaved patch to live version905const value = this.version();906if (value == null) {907// see remark in undo -- do nothing908return this.get_doc();909}910state.pointer += 1;911return value.apply_patch(state.final);912} else {913// at least two back from live state914state.without.pop();915state.pointer += 1;916if (state.final == null && state.pointer === state.my_times.length - 1) {917// special case when there wasn't any live change918state.pointer += 1;919}920return this.version_without(state.without);921}922}923924in_undo_mode = (): boolean => {925return this.undo_state != null;926};927928exit_undo_mode = (): void => {929this.undo_state = undefined;930};931932private initUndoState = (): UndoState => {933if (this.undo_state != null) {934return this.undo_state;935}936const my_times = keys(this.my_patches).map((x) => parseInt(x));937my_times.sort();938this.undo_state = {939my_times,940pointer: my_times.length,941without: [],942};943return this.undo_state;944};945946private save_to_disk_autosave = async (): Promise<void> => {947if (this.state !== "ready") {948return;949}950const dbg = this.dbg("save_to_disk_autosave");951dbg();952try {953await this.save_to_disk();954} catch (err) {955dbg(`failed -- ${err}`);956}957};958959/* Make it so the local hub project will automatically save960the file to disk periodically. */961private init_file_autosave = async () => {962// Do not autosave sagews until we resolve963// https://github.com/sagemathinc/cocalc/issues/974964// Similarly, do not autosave ipynb because of965// https://github.com/sagemathinc/cocalc/issues/5216966if (967!FILE_SERVER_AUTOSAVE_S ||968!(await this.isFileServer()) ||969this.fileserver_autosave_timer ||970endswith(this.path, ".sagews") ||971endswith(this.path, "." + JUPYTER_SYNCDB_EXTENSIONS)972) {973return;974}975976// Explicit cast due to node vs browser typings.977this.fileserver_autosave_timer = <any>(978setInterval(this.save_to_disk_autosave, FILE_SERVER_AUTOSAVE_S * 1000)979);980};981982// account_id of the user who made the edit at983// the given point in time.984account_id = (time: number): string => {985this.assert_is_ready("account_id");986return this.users[this.user_id(time)];987};988989// Integer index of user who made the edit at given990// point in time.991user_id = (time: number): number => {992this.assert_table_is_ready("patches");993assertDefined(this.patch_list);994return this.patch_list.user_id(time);995};996997private syncstring_table_get_one = (): Map<string, any> => {998if (this.syncstring_table == null) {999throw Error("syncstring_table must be defined");1000}1001const t = this.syncstring_table.get_one();1002if (t == null) {1003// project has not initialized it yet.1004return Map();1005}1006return t;1007};10081009/* The project calls set_initialized once it has checked for1010the file on disk; this way the frontend knows that the1011syncstring has been initialized in the database, and also1012if there was an error doing the check.1013*/1014private set_initialized = async (1015error: string,1016read_only: boolean,1017size: number,1018): Promise<void> => {1019this.assert_table_is_ready("syncstring");1020this.dbg("set_initialized")({ error, read_only, size });1021const init = { time: this.client.server_time(), size, error };1022for (let i = 0; i < 3; i++) {1023await this.set_syncstring_table({1024init,1025read_only,1026last_active: this.client.server_time(),1027});1028await delay(1000);1029}1030};10311032/* List of logical timestamps of the versions of this string in the sync1033table that we opened to start editing (so starts with what was1034the most recent snapshot when we started). The list of timestamps1035is sorted from oldest to newest. */1036versions = (): number[] => {1037assertDefined(this.patch_list);1038return this.patch_list.versions();1039};10401041wallTime = (version: number): number | undefined => {1042return this.patch_list?.wallTime(version);1043};10441045// newest version of any non-staging known patch on this client,1046// including ones just made that might not be in patch_list yet.1047newestVersion = (): number | undefined => {1048return this.patch_list?.newest_patch_time();1049};10501051hasVersion = (time: number): boolean => {1052assertDefined(this.patch_list);1053return this.patch_list.hasVersion(time);1054};10551056historyFirstVersion = () => {1057this.assert_table_is_ready("patches");1058assertDefined(this.patch_list);1059return this.patch_list.firstVersion();1060};10611062historyLastVersion = () => {1063this.assert_table_is_ready("patches");1064assertDefined(this.patch_list);1065return this.patch_list.lastVersion();1066};10671068historyVersionNumber = (time: number): number | undefined => {1069return this.patch_list?.versionNumber(time);1070};10711072last_changed = (): number => {1073const v = this.versions();1074return v[v.length - 1] ?? 0;1075};10761077private init_table_close_handlers(): void {1078for (const x of ["syncstring", "patches", "cursors"]) {1079const t = this[x + "_table"];1080if (t != null) {1081t.on("close", this.close);1082}1083}1084}10851086// more gentle version -- this can cause the project actions1087// to be *created* etc.1088end = reuseInFlight(async () => {1089if (this.client.is_browser() && this.state == "ready") {1090try {1091await this.save_to_disk();1092} catch (err) {1093// has to be non-fatal since we are closing the document,1094// and of couse we need to clear up everything else.1095// Do nothing here.1096}1097}1098this.close();1099});11001101// Close synchronized editing of this string; this stops listening1102// for changes and stops broadcasting changes.1103close = reuseInFlight(async () => {1104if (this.state == "closed") {1105return;1106}1107const dbg = this.dbg("close");1108dbg("close");11091110SyncDoc.computeServerManagerDoc?.removeListener(1111"change",1112this.handleComputeServerManagerChange,1113);1114//1115// SYNC STUFF1116//11171118// WARNING: that 'closed' is emitted at the beginning of the1119// close function (before anything async) for the project is1120// assumed in src/packages/project/sync/sync-doc.ts, because1121// that ensures that the moment close is called we lock trying1122// try create the syncdoc again until closing is finished.1123// (This set_state call emits "closed"):1124this.set_state("closed");11251126this.emit("close");11271128// must be after the emits above, so clients know1129// what happened and can respond.1130this.removeAllListeners();11311132if (this.throttled_file_use != null) {1133// Cancel any pending file_use calls.1134cancel_scheduled(this.throttled_file_use);1135(this.throttled_file_use as any).cancel();1136}11371138if (this.emit_change != null) {1139// Cancel any pending change emit calls.1140cancel_scheduled(this.emit_change);1141}11421143if (this.fileserver_autosave_timer) {1144clearInterval(this.fileserver_autosave_timer as any);1145this.fileserver_autosave_timer = 0;1146}11471148if (this.read_only_timer) {1149clearInterval(this.read_only_timer as any);1150this.read_only_timer = 0;1151}11521153this.patch_update_queue = [];11541155// Stop watching for file changes. It's important to1156// do this *before* all the await's below, since1157// this syncdoc can't do anything in response to a1158// a file change in its current state.1159this.update_watch_path(); // no input = closes it, if open11601161if (this.patch_list != null) {1162// not async -- just a data structure in memory1163this.patch_list.close();1164}11651166try {1167this.closeTables();1168dbg("closeTables -- successfully saved all data to database");1169} catch (err) {1170dbg(`closeTables -- ERROR -- ${err}`);1171}1172// this avoids memory leaks:1173close(this);11741175// after doing that close, we need to keep the state (which just got deleted) as 'closed'1176this.set_state("closed");1177dbg("close done");1178});11791180private closeTables = async () => {1181this.syncstring_table?.close();1182this.patches_table?.close();1183this.cursors_table?.close();1184this.evaluator?.close();1185this.ipywidgets_state?.close();1186};11871188// TODO: We **have** to do this on the client, since the backend1189// **security model** for accessing the patches table only1190// knows the string_id, but not the project_id/path. Thus1191// there is no way currently to know whether or not the client1192// has access to the patches, and hence the patches table1193// query fails. This costs significant time -- a roundtrip1194// and write to the database -- whenever the user opens a file.1195// This fix should be to change the patches schema somehow1196// to have the user also provide the project_id and path, thus1197// proving they have access to the sha1 hash (string_id), but1198// don't actually use the project_id and path as columns in1199// the table. This requires some new idea I guess of virtual1200// fields....1201// Also, this also establishes the correct doctype.12021203// Since this MUST succeed before doing anything else. This is critical1204// because the patches table can't be opened anywhere if the syncstring1205// object doesn't exist, due to how our security works, *AND* that the1206// patches table uses the string_id, which is a SHA1 hash.1207private ensure_syncstring_exists_in_db = async (): Promise<void> => {1208const dbg = this.dbg("ensure_syncstring_exists_in_db");1209if (this.useConat) {1210dbg("skipping -- no database");1211return;1212}12131214if (!this.client.is_connected()) {1215dbg("wait until connected...", this.client.is_connected());1216await once(this.client, "connected");1217}12181219if (this.client.is_browser() && !this.client.is_signed_in()) {1220// the browser has to sign in, unlike the project (and compute servers)1221await once(this.client, "signed_in");1222}12231224if (this.state == ("closed" as State)) return;12251226dbg("do syncstring write query...");12271228await callback2(this.client.query, {1229query: {1230syncstrings: {1231string_id: this.string_id,1232project_id: this.project_id,1233path: this.path,1234doctype: JSON.stringify(this.doctype),1235},1236},1237});1238dbg("wrote syncstring to db - done.");1239};12401241private synctable = async (1242query,1243options: any[],1244throttle_changes?: undefined | number,1245): Promise<SyncTable> => {1246this.assert_not_closed("synctable");1247const dbg = this.dbg("synctable");1248if (!this.useConat && !this.ephemeral && this.persistent) {1249// persistent table in a non-ephemeral syncdoc, so ensure that table is1250// persisted to database (not just in memory).1251options = options.concat([{ persistent: true }]);1252}1253if (this.ephemeral) {1254options.push({ ephemeral: true });1255}1256let synctable;1257let ephemeral = false;1258for (const x of options) {1259if (x.ephemeral) {1260ephemeral = true;1261break;1262}1263}1264if (this.useConat && query.patches) {1265synctable = await this.client.synctable_conat(query, {1266obj: {1267project_id: this.project_id,1268path: this.path,1269},1270stream: true,1271atomic: true,1272desc: { path: this.path },1273start_seq: this.last_seq,1274ephemeral,1275});12761277if (this.last_seq) {1278// any possibility last_seq is wrong?1279if (!isCompletePatchStream(synctable.dstream)) {1280// we load everything and fix it. This happened1281// for data moving to conat when the seq numbers changed.1282console.log("updating invalid timetravel -- ", this.path);12831284synctable.close();1285synctable = await this.client.synctable_conat(query, {1286obj: {1287project_id: this.project_id,1288path: this.path,1289},1290stream: true,1291atomic: true,1292desc: { path: this.path },1293ephemeral,1294});12951296// also find the correct last_seq:1297let n = synctable.dstream.length - 1;1298for (; n >= 0; n--) {1299const x = synctable.dstream[n];1300if (x?.is_snapshot) {1301const time = x.time;1302// find the seq number with time1303let m = n - 1;1304let last_seq = 0;1305while (m >= 1) {1306if (synctable.dstream[m].time == time) {1307last_seq = synctable.dstream.seq(m);1308break;1309}1310m -= 1;1311}1312this.last_seq = last_seq;1313await this.set_syncstring_table({1314last_snapshot: time,1315last_seq,1316});1317this.setLastSnapshot(time);1318break;1319}1320}1321if (n == -1) {1322// no snapshot? should never happen, but just in case.1323delete this.last_seq;1324await this.set_syncstring_table({1325last_seq: undefined,1326});1327}1328}1329}1330} else if (this.useConat && query.syncstrings) {1331synctable = await this.client.synctable_conat(query, {1332obj: {1333project_id: this.project_id,1334path: this.path,1335},1336stream: false,1337atomic: false,1338immutable: true,1339desc: { path: this.path },1340ephemeral,1341});1342} else if (this.useConat && query.ipywidgets) {1343synctable = await this.client.synctable_conat(query, {1344obj: {1345project_id: this.project_id,1346path: this.path,1347},1348stream: false,1349atomic: true,1350immutable: true,1351// for now just putting a 1-day limit on the ipywidgets table1352// so we don't waste a ton of space.1353config: { max_age: 1000 * 60 * 60 * 24 },1354desc: { path: this.path },1355ephemeral: true, // ipywidgets state always ephemeral1356});1357} else if (this.useConat && (query.eval_inputs || query.eval_outputs)) {1358synctable = await this.client.synctable_conat(query, {1359obj: {1360project_id: this.project_id,1361path: this.path,1362},1363stream: false,1364atomic: true,1365immutable: true,1366config: { max_age: 5 * 60 * 1000 },1367desc: { path: this.path },1368ephemeral: true, // eval state (for sagews) is always ephemeral1369});1370} else if (this.useConat) {1371synctable = await this.client.synctable_conat(query, {1372obj: {1373project_id: this.project_id,1374path: this.path,1375},1376stream: false,1377atomic: true,1378immutable: true,1379desc: { path: this.path },1380ephemeral,1381});1382} else {1383// only used for unit tests and the ephemeral messaging composer1384if (this.client.synctable_ephemeral == null) {1385throw Error(`client does not support sync properly`);1386}1387synctable = await this.client.synctable_ephemeral(1388this.project_id,1389query,1390options,1391throttle_changes,1392);1393}1394// We listen and log error events. This is useful because in some settings, e.g.,1395// in the project, an eventemitter with no listener for errors, which has an error,1396// will crash the entire process.1397synctable.on("error", (error) => dbg("ERROR", error));1398return synctable;1399};14001401private init_syncstring_table = async (): Promise<void> => {1402const query = {1403syncstrings: [1404{1405string_id: this.string_id,1406project_id: this.project_id,1407path: this.path,1408users: null,1409last_snapshot: null,1410last_seq: null,1411snapshot_interval: null,1412save: null,1413last_active: null,1414init: null,1415read_only: null,1416last_file_change: null,1417doctype: null,1418archived: null,1419settings: null,1420},1421],1422};1423const dbg = this.dbg("init_syncstring_table");14241425dbg("getting table...");1426this.syncstring_table = await this.synctable(query, []);1427if (this.ephemeral && this.client.is_project()) {1428await this.set_syncstring_table({1429doctype: JSON.stringify(this.doctype),1430});1431} else {1432dbg("handling the first update...");1433this.handle_syncstring_update();1434}1435this.syncstring_table.on("change", this.handle_syncstring_update);1436};14371438// Used for internal debug logging1439private dbg = (_f: string = ""): Function => {1440if (DEBUG) {1441return (...args) => {1442logger.debug(this.path, _f, ...args);1443};1444} else {1445return (..._args) => {};1446}1447};14481449private initAll = async (): Promise<void> => {1450if (this.state !== "init") {1451throw Error("connect can only be called in init state");1452}1453const log = this.dbg("initAll");14541455log("update interest");1456this.initInterestLoop();14571458log("ensure syncstring exists in database (if not using NATS)");1459this.assert_not_closed("initAll -- before ensuring syncstring exists");1460await this.ensure_syncstring_exists_in_db();14611462await this.init_syncstring_table();1463this.assert_not_closed("initAll -- successful init_syncstring_table");14641465log("patch_list, cursors, evaluator, ipywidgets");1466this.assert_not_closed(1467"initAll -- before init patch_list, cursors, evaluator, ipywidgets",1468);1469if (PARALLEL_INIT) {1470await Promise.all([1471this.init_patch_list(),1472this.init_cursors(),1473this.init_evaluator(),1474this.init_ipywidgets(),1475]);1476this.assert_not_closed(1477"initAll -- successful init patch_list, cursors, evaluator, and ipywidgets",1478);1479} else {1480await this.init_patch_list();1481this.assert_not_closed("initAll -- successful init_patch_list");1482await this.init_cursors();1483this.assert_not_closed("initAll -- successful init_patch_cursors");1484await this.init_evaluator();1485this.assert_not_closed("initAll -- successful init_evaluator");1486await this.init_ipywidgets();1487this.assert_not_closed("initAll -- successful init_ipywidgets");1488}14891490this.init_table_close_handlers();1491this.assert_not_closed("initAll -- successful init_table_close_handlers");14921493log("file_use_interval");1494this.init_file_use_interval();14951496if (await this.isFileServer()) {1497log("load_from_disk");1498// This sets initialized, which is needed to be fully ready.1499// We keep trying this load from disk until sync-doc is closed1500// or it succeeds. It may fail if, e.g., the file is too1501// large or is not readable by the user. They are informed to1502// fix the problem... and once they do (and wait up to 10s),1503// this will finish.1504// if (!this.client.is_browser() && !this.client.is_project()) {1505// // FAKE DELAY!!! Just to simulate flakiness / slow network!!!!1506// await delay(3000);1507// }1508await retry_until_success({1509f: this.init_load_from_disk,1510max_delay: 10000,1511desc: "syncdoc -- load_from_disk",1512});1513log("done loading from disk");1514} else {1515if (this.patch_list!.count() == 0) {1516await Promise.race([1517this.waitUntilFullyReady(),1518once(this.patch_list!, "change"),1519]);1520}1521}1522this.assert_not_closed("initAll -- load from disk");1523this.emit("init");15241525this.assert_not_closed("initAll -- after waiting until fully ready");15261527if (await this.isFileServer()) {1528log("init file autosave");1529this.init_file_autosave();1530}1531this.update_has_unsaved_changes();1532log("done");1533};15341535private init_error = (): string | undefined => {1536let x;1537try {1538x = this.syncstring_table.get_one();1539} catch (_err) {1540// if the table hasn't been initialized yet,1541// it can't be in error state.1542return undefined;1543}1544return x?.get("init")?.get("error");1545};15461547// wait until the syncstring table is ready to be1548// used (so extracted from archive, etc.),1549private waitUntilFullyReady = async (): Promise<void> => {1550this.assert_not_closed("wait_until_fully_ready");1551const dbg = this.dbg("wait_until_fully_ready");1552dbg();15531554if (this.client.is_browser() && this.init_error()) {1555// init is set and is in error state. Give the backend a few seconds1556// to try to fix this error before giving up. The browser client1557// can close and open the file to retry this (as instructed).1558try {1559await this.syncstring_table.wait(() => !this.init_error(), 5);1560} catch (err) {1561// fine -- let the code below deal with this problem...1562}1563}15641565let init;1566const is_init = (t: SyncTable) => {1567this.assert_not_closed("is_init");1568const tbl = t.get_one();1569if (tbl == null) {1570dbg("null");1571return false;1572}1573init = tbl.get("init")?.toJS();1574return init != null;1575};1576dbg("waiting for init...");1577await this.syncstring_table.wait(is_init, 0);1578dbg("init done");1579if (init.error) {1580throw Error(init.error);1581}1582assertDefined(this.patch_list);1583if (init.size == null) {1584// don't crash but warn at least.1585console.warn("SYNC BUG -- init.size must be defined", { init });1586}1587if (1588!this.client.is_project() &&1589this.patch_list.count() === 0 &&1590init.size1591) {1592dbg("waiting for patches for nontrivial file");1593// normally this only happens in a later event loop,1594// so force it now.1595dbg("handling patch update queue since", this.patch_list.count());1596await this.handle_patch_update_queue();1597assertDefined(this.patch_list);1598dbg("done handling, now ", this.patch_list.count());1599if (this.patch_list.count() === 0) {1600// wait for a change -- i.e., project loading the file from1601// disk and making available... Because init.size > 0, we know that1602// there must be SOMETHING in the patches table once initialization is done.1603// This is the root cause of https://github.com/sagemathinc/cocalc/issues/23821604await once(this.patches_table, "change");1605dbg("got patches_table change");1606await this.handle_patch_update_queue();1607dbg("handled update queue");1608}1609}1610};16111612private assert_table_is_ready = (table: string): void => {1613const t = this[table + "_table"]; // not using string template only because it breaks codemirror!1614if (t == null || t.get_state() != "connected") {1615throw Error(1616`Table ${table} must be connected. string_id=${this.string_id}`,1617);1618}1619};16201621assert_is_ready = (desc: string): void => {1622if (this.state != "ready") {1623throw Error(`must be ready -- ${desc}`);1624}1625};16261627wait_until_ready = async (): Promise<void> => {1628this.assert_not_closed("wait_until_ready");1629if (this.state !== ("ready" as State)) {1630// wait for a state change to ready.1631await once(this, "ready");1632}1633};16341635/* Calls wait for the corresponding patches SyncTable, if1636it has been defined. If it hasn't been defined, it waits1637until it is defined, then calls wait. Timeout only starts1638when patches_table is already initialized.1639*/1640wait = async (until: Function, timeout: number = 30): Promise<any> => {1641await this.wait_until_ready();1642//console.trace("SYNC WAIT -- start...");1643const result = await wait({1644obj: this,1645until,1646timeout,1647change_event: "change",1648});1649//console.trace("SYNC WAIT -- got it!");1650return result;1651};16521653/* Delete the synchronized string and **all** patches from the database1654-- basically delete the complete history of editing this file.1655WARNINGS:1656(1) If a project has this string open, then things may be messed1657up, unless that project is restarted.1658(2) Only available for an **admin** user right now!16591660To use: from a javascript console in the browser as admin, do:16611662await smc.client.sync_string({1663project_id:'9f2e5869-54b8-4890-8828-9aeba9a64af4',1664path:'a.txt'}).delete_from_database()16651666Then make sure project and clients refresh.16671668WORRY: Race condition where constructor might write stuff as1669it is being deleted?1670*/1671delete_from_database = async (): Promise<void> => {1672const queries: object[] = this.ephemeral1673? []1674: [1675{1676patches_delete: {1677id: [this.string_id],1678dummy: null,1679},1680},1681];1682queries.push({1683syncstrings_delete: {1684project_id: this.project_id,1685path: this.path,1686},1687});16881689const v: Promise<any>[] = [];1690for (let i = 0; i < queries.length; i++) {1691v.push(callback2(this.client.query, { query: queries[i] }));1692}1693await Promise.all(v);1694};16951696private pathExistsAndIsReadOnly = async (path): Promise<boolean> => {1697try {1698await callback2(this.client.path_access, {1699path,1700mode: "w",1701});1702// clearly exists and is NOT read only:1703return false;1704} catch (err) {1705// either it doesn't exist or it is read only1706if (await callback2(this.client.path_exists, { path })) {1707// it exists, so is read only and exists1708return true;1709}1710// doesn't exist1711return false;1712}1713};17141715private file_is_read_only = async (): Promise<boolean> => {1716if (await this.pathExistsAndIsReadOnly(this.path)) {1717return true;1718}1719const path = this.getFileServerPath();1720if (path != this.path) {1721if (await this.pathExistsAndIsReadOnly(path)) {1722return true;1723}1724}1725return false;1726};17271728private update_if_file_is_read_only = async (): Promise<void> => {1729const read_only = await this.file_is_read_only();1730if (this.state == "closed") {1731return;1732}1733this.set_read_only(read_only);1734};17351736private init_load_from_disk = async (): Promise<void> => {1737if (this.state == "closed") {1738// stop trying, no error -- this is assumed1739// in a retry_until_success elsewhere.1740return;1741}1742if (await this.load_from_disk_if_newer()) {1743throw Error("failed to load from disk");1744}1745};17461747private load_from_disk_if_newer = async (): Promise<boolean> => {1748const last_changed = new Date(this.last_changed());1749const firstLoad = this.versions().length == 0;1750const dbg = this.dbg("load_from_disk_if_newer");1751let is_read_only: boolean = false;1752let size: number = 0;1753let error: string = "";1754try {1755dbg("check if path exists");1756if (await callback2(this.client.path_exists, { path: this.path })) {1757// the path exists1758dbg("path exists -- stat file");1759const stats = await callback2(this.client.path_stat, {1760path: this.path,1761});1762if (firstLoad || stats.ctime > last_changed) {1763dbg(1764`disk file changed more recently than edits (or first load), so loading, ${stats.ctime} > ${last_changed}; firstLoad=${firstLoad}`,1765);1766size = await this.load_from_disk();1767if (firstLoad) {1768dbg("emitting first-load event");1769// this event is emited the first time the document is ever loaded from disk.1770this.emit("first-load");1771}1772dbg("loaded");1773} else {1774dbg("stick with database version");1775}1776dbg("checking if read only");1777is_read_only = await this.file_is_read_only();1778dbg("read_only", is_read_only);1779}1780} catch (err) {1781error = `${err}`;1782}17831784await this.set_initialized(error, is_read_only, size);1785dbg("done");1786return !!error;1787};17881789private patch_table_query = (cutoff?: number) => {1790const query = {1791string_id: this.string_id,1792is_snapshot: false, // only used with conat1793time: cutoff ? { ">=": cutoff } : null,1794wall: null,1795// compressed format patch as a JSON *string*1796patch: null,1797// integer id of user (maps to syncstring table)1798user_id: null,1799// (optional) a snapshot at this point in time1800snapshot: null,1801// info about sequence number, count, etc. of this snapshot1802seq_info: null,1803parents: null,1804version: null,1805};1806if (this.doctype.patch_format != null) {1807(query as any).format = this.doctype.patch_format;1808}1809return query;1810};18111812private setLastSnapshot(last_snapshot?: number) {1813// only set last_snapshot here, so we can keep it in sync with patch_list.last_snapshot1814// and also be certain about the data type (being number or undefined).1815if (last_snapshot !== undefined && typeof last_snapshot != "number") {1816throw Error("type of last_snapshot must be number or undefined");1817}1818this.last_snapshot = last_snapshot;1819}18201821private init_patch_list = async (): Promise<void> => {1822this.assert_not_closed("init_patch_list - start");1823const dbg = this.dbg("init_patch_list");1824dbg();18251826// CRITICAL: note that handle_syncstring_update checks whether1827// init_patch_list is done by testing whether this.patch_list is defined!1828// That is why we first define "patch_list" below, then set this.patch_list1829// to it only after we're done.1830delete this.patch_list;18311832const patch_list = new SortedPatchList({1833from_str: this._from_str,1834});18351836dbg("opening the table...");1837const query = { patches: [this.patch_table_query(this.last_snapshot)] };1838this.patches_table = await this.synctable(query, [], this.patch_interval);1839this.assert_not_closed("init_patch_list -- after making synctable");18401841const update_has_unsaved_changes = debounce(1842this.update_has_unsaved_changes,1843500,1844{ leading: true, trailing: true },1845);18461847this.patches_table.on("has-uncommitted-changes", (val) => {1848this.emit("has-uncommitted-changes", val);1849});18501851this.on("change", () => {1852update_has_unsaved_changes();1853});18541855this.syncstring_table.on("change", () => {1856update_has_unsaved_changes();1857});18581859dbg("adding all known patches");1860patch_list.add(this.get_patches());18611862dbg("possibly kick off loading more history");1863let last_start_seq: null | number = null;1864while (patch_list.needsMoreHistory()) {1865// @ts-ignore1866const dstream = this.patches_table.dstream;1867if (dstream == null) {1868break;1869}1870const snap = patch_list.getOldestSnapshot();1871if (snap == null) {1872break;1873}1874const seq_info = snap.seq_info ?? {1875prev_seq: 1,1876};1877const start_seq = seq_info.prev_seq ?? 1;1878if (last_start_seq != null && start_seq >= last_start_seq) {1879// no progress, e.g., corruption would cause this.1880// "corruption" is EXPECTED, since a user might be submitting1881// patches after being offline, and get disconnected halfway through.1882break;1883}1884last_start_seq = start_seq;1885await dstream.load({ start_seq });1886dbg("load more history");1887patch_list.add(this.get_patches());1888if (start_seq <= 1) {1889// loaded everything1890break;1891}1892}18931894//this.patches_table.on("saved", this.handle_offline);1895this.patch_list = patch_list;18961897let doc;1898try {1899doc = patch_list.value();1900} catch (err) {1901console.warn("error getting doc", err);1902doc = this._from_str("");1903}1904this.last = this.doc = doc;1905this.patches_table.on("change", this.handle_patch_update);19061907dbg("done");1908};19091910private init_evaluator = async () => {1911const dbg = this.dbg("init_evaluator");1912const ext = filename_extension(this.path);1913if (ext !== "sagews") {1914dbg("done -- only use init_evaluator for sagews");1915return;1916}1917dbg("creating the evaluator and waiting for init");1918this.evaluator = new Evaluator(this, this.client, this.synctable);1919await this.evaluator.init();1920dbg("done");1921};19221923private init_ipywidgets = async () => {1924const dbg = this.dbg("init_evaluator");1925const ext = filename_extension(this.path);1926if (ext != JUPYTER_SYNCDB_EXTENSIONS) {1927dbg("done -- only use ipywidgets for jupyter");1928return;1929}1930dbg("creating the ipywidgets state table, and waiting for init");1931this.ipywidgets_state = new IpywidgetsState(1932this,1933this.client,1934this.synctable,1935);1936await this.ipywidgets_state.init();1937dbg("done");1938};19391940private init_cursors = async () => {1941const dbg = this.dbg("init_cursors");1942if (!this.cursors) {1943dbg("done -- do not care about cursors for this syncdoc.");1944return;1945}1946if (this.useConat) {1947dbg("cursors broadcast using pub/sub");1948this.cursors_table = await this.client.pubsub_conat({1949project_id: this.project_id,1950path: this.path,1951name: "cursors",1952});1953this.cursors_table.on(1954"change",1955(obj: { user_id: number; locs: any; time: number }) => {1956const account_id = this.users[obj.user_id];1957if (!account_id) {1958return;1959}1960if (obj.locs == null && !this.cursor_map.has(account_id)) {1961// gone, and already gone.1962return;1963}1964if (obj.locs != null) {1965// changed1966this.cursor_map = this.cursor_map.set(account_id, fromJS(obj));1967} else {1968// deleted1969this.cursor_map = this.cursor_map.delete(account_id);1970}1971this.emit("cursor_activity", account_id);1972},1973);1974return;1975}19761977dbg("getting cursors ephemeral table");1978const query = {1979cursors: [1980{1981string_id: this.string_id,1982user_id: null,1983locs: null,1984time: null,1985},1986],1987};1988// We make cursors an ephemeral table, since there is no1989// need to persist it to the database, obviously!1990// Also, queue_size:1 makes it so only the last cursor position is1991// saved, e.g., in case of disconnect and reconnect.1992const options = [{ ephemeral: true }, { queue_size: 1 }]; // probably deprecated1993this.cursors_table = await this.synctable(query, options, 1000);1994this.assert_not_closed("init_cursors -- after making synctable");19951996// cursors now initialized; first initialize the1997// local this._cursor_map, which tracks positions1998// of cursors by account_id:1999dbg("loading initial state");2000const s = this.cursors_table.get();2001if (s == null) {2002throw Error("bug -- get should not return null once table initialized");2003}2004s.forEach((locs: any, k: string) => {2005if (locs == null) {2006return;2007}2008const u = JSON.parse(k);2009if (u != null) {2010this.cursor_map = this.cursor_map.set(this.users[u[1]], locs);2011}2012});2013this.cursors_table.on("change", this.handle_cursors_change);20142015dbg("done");2016};20172018private handle_cursors_change = (keys) => {2019if (this.state === "closed") {2020return;2021}2022for (const k of keys) {2023const u = JSON.parse(k);2024if (u == null) {2025continue;2026}2027const account_id = this.users[u[1]];2028if (!account_id) {2029// this happens for ephemeral table when project restarts and browser2030// has data it is trying to send.2031continue;2032}2033const locs = this.cursors_table.get(k);2034if (locs == null && !this.cursor_map.has(account_id)) {2035// gone, and already gone.2036continue;2037}2038if (locs != null) {2039// changed2040this.cursor_map = this.cursor_map.set(account_id, locs);2041} else {2042// deleted2043this.cursor_map = this.cursor_map.delete(account_id);2044}2045this.emit("cursor_activity", account_id);2046}2047};20482049/* Returns *immutable* Map from account_id to list2050of cursor positions, if cursors are enabled.20512052- excludeSelf: do not include our own cursor2053- maxAge: only include cursors that have been updated with maxAge ms from now.2054*/2055get_cursors = ({2056maxAge = 60 * 1000,2057// excludeSelf:2058// 'always' -- *always* exclude self2059// 'never' -- never exclude self2060// 'heuristic' -- exclude self is older than last set from here, e.g., useful on2061// frontend so we don't see our own cursor unless more than one browser.2062excludeSelf = "always",2063}: {2064maxAge?: number;2065excludeSelf?: "always" | "never" | "heuristic";2066} = {}): Map<string, any> => {2067this.assert_not_closed("get_cursors");2068if (!this.cursors) {2069throw Error("cursors are not enabled");2070}2071if (this.cursors_table == null) {2072return Map(); // not loaded yet -- so no info yet.2073}2074const account_id: string = this.client_id();2075let map = this.cursor_map;2076if (map.has(account_id) && excludeSelf != "never") {2077if (2078excludeSelf == "always" ||2079(excludeSelf == "heuristic" &&2080this.cursor_last_time >=2081new Date(map.getIn([account_id, "time"], 0) as number))2082) {2083map = map.delete(account_id);2084}2085}2086// Remove any old cursors, where "old" is by default more than maxAge old.2087const now = Date.now();2088for (const [client_id, value] of map as any) {2089const time = value.get("time");2090if (time == null) {2091// this should always be set.2092map = map.delete(client_id);2093continue;2094}2095if (maxAge) {2096// we use abs to implicitly exclude a bad value that is somehow in the future,2097// if that were to happen.2098if (Math.abs(now - time.valueOf()) >= maxAge) {2099map = map.delete(client_id);2100continue;2101}2102}2103if (time >= now + 10 * 1000) {2104// We *always* delete any cursors more than 10 seconds in the future, since2105// that can only happen if a client inserts invalid data (e.g., clock not2106// yet synchronized). See https://github.com/sagemathinc/cocalc/issues/79692107map = map.delete(client_id);2108continue;2109}2110}2111return map;2112};21132114/* Set settings map. Used for custom configuration just for2115this one file, e.g., overloading the spell checker language.2116*/2117set_settings = async (obj): Promise<void> => {2118this.assert_is_ready("set_settings");2119await this.set_syncstring_table({2120settings: obj,2121});2122};21232124client_id = () => {2125return this.client.client_id();2126};21272128// get settings object2129get_settings = (): Map<string, any> => {2130this.assert_is_ready("get_settings");2131return this.syncstring_table_get_one().get("settings", Map());2132};21332134/*2135Commits and saves current live syncdoc to backend.21362137Function only returns when there is nothing needing2138saving.21392140Save any changes we have as a new patch.2141*/2142save = reuseInFlight(async () => {2143const dbg = this.dbg("save");2144dbg();2145// We just keep trying while syncdoc is ready and there2146// are changes that have not been saved (due to this.doc2147// changing during the while loop!).2148if (this.doc == null || this.last == null || this.state == "closed") {2149// EXPECTED: this happens after document is closed2150// There's nothing to do regarding save if the table is2151// already closed. Note that we *do* have to save when2152// the table is init stage, since the project has to2153// record the newly opened version of the file to the2154// database! See2155// https://github.com/sagemathinc/cocalc/issues/49862156return;2157}2158if (this.client?.is_deleted(this.path, this.project_id)) {2159dbg("not saving because deleted");2160return;2161}2162// Compute any patches.2163while (!this.doc.is_equal(this.last)) {2164dbg("something to save");2165this.emit("user-change");2166const doc = this.doc;2167// TODO: put in a delay if just saved too recently?2168// Or maybe won't matter since not using database?2169if (this.handle_patch_update_queue_running) {2170dbg("wait until the update queue is done");2171await once(this, "handle_patch_update_queue_done");2172// but wait until next loop (so as to check that needed2173// and state still ready).2174continue;2175}2176dbg("Compute new patch.");2177this.sync_remote_and_doc(false);2178// Emit event since this syncstring was2179// changed locally (or we wouldn't have had2180// to save at all).2181if (doc.is_equal(this.doc)) {2182dbg("no change during loop -- done!");2183break;2184}2185}2186if (this.state != "ready") {2187// above async waits could have resulted in state change.2188return;2189}2190await this.handle_patch_update_queue();2191if (this.state != "ready") {2192return;2193}21942195// Ensure all patches are saved to backend.2196// We do this after the above, so that creating the newest patch2197// happens immediately on save, which makes it possible for clients2198// to save current state without having to wait on an async, which is2199// useful to ensure specific undo points (e.g., right before a paste).2200await this.patches_table.save();2201});22022203private timeOfLastCommit: number | undefined = undefined;2204private next_patch_time = (): number => {2205let time = this.client.server_time().valueOf();2206if (time == this.timeOfLastCommit) {2207time = this.timeOfLastCommit + 1;2208}2209assertDefined(this.patch_list);2210time = this.patch_list.next_available_time(2211time,2212this.my_user_id,2213this.users.length,2214);2215return time;2216};22172218private commit_patch = (time: number, patch: XPatch): void => {2219this.timeOfLastCommit = time;2220this.assert_not_closed("commit_patch");2221assertDefined(this.patch_list);2222const obj: any = {2223// version for database2224string_id: this.string_id,2225// logical time -- usually the sync'd walltime, but2226// guaranteed to be increasing.2227time,2228// what we show user2229wall: this.client.server_time().valueOf(),2230patch: JSON.stringify(patch),2231user_id: this.my_user_id,2232is_snapshot: false,2233parents: this.patch_list.getHeads(),2234version: this.patch_list.lastVersion() + 1,2235};22362237this.my_patches[time.valueOf()] = obj;22382239if (this.doctype.patch_format != null) {2240obj.format = this.doctype.patch_format;2241}22422243// If in undo mode put the just-created patch in our2244// without timestamp list, so it won't be included2245// when doing undo/redo.2246if (this.undo_state != null) {2247this.undo_state.without.unshift(time);2248}22492250//console.log 'saving patch with time ', time.valueOf()2251let x = this.patches_table.set(obj, "none");2252if (x == null) {2253// TODO: just for NATS right now!2254x = fromJS(obj);2255}2256const y = this.processPatch({ x, patch, size: obj.patch.size });2257this.patch_list.add([y]);2258// Since *we* just made a definite change to the document, we're2259// active, so we check if we should make a snapshot. There is the2260// potential of a race condition where more than one clients make2261// a snapshot at the same time -- this would waste a little space2262// in the stream, but is otherwise harmless, since the snapshots2263// are identical.2264this.snapshot_if_necessary();2265};22662267private dstream = () => {2268// @ts-ignore -- in general patches_table might not be a conat one still,2269// or at least dstream is an internal implementation detail.2270const { dstream } = this.patches_table ?? {};2271if (dstream == null) {2272throw Error("dstream must be defined");2273}2274return dstream;2275};22762277// return the conat-assigned sequence number of the oldest entry in the2278// patch list with the given time, and also:2279// - prev_seq -- the sequence number of previous patch before that, for use in "load more"2280// - index -- the global index of the entry with the given time.2281private conatSnapshotSeqInfo = (2282time: number,2283): { seq: number; prev_seq?: number } => {2284const dstream = this.dstream();2285// seq = actual sequence number of the message with the patch that we're2286// snapshotting at -- i.e., at time2287let seq: number | undefined = undefined;2288// prev_seq = sequence number of patch of *previous* snapshot, if there is a previous one.2289// This is needed for incremental loading of more history.2290let prev_seq: number | undefined;2291let i = 0;2292for (const mesg of dstream.getAll()) {2293if (mesg.is_snapshot && mesg.time < time) {2294// the seq field of this message has the actual sequence number of the patch2295// that was snapshotted, along with the index of that patch.2296prev_seq = mesg.seq_info.seq;2297}2298if (seq === undefined && mesg.time == time) {2299seq = dstream.seq(i);2300}2301i += 1;2302}2303if (seq == null) {2304throw Error(2305`unable to find message with time '${time}'=${new Date(time)}`,2306);2307}2308return { seq, prev_seq };2309};23102311/* Create and store in the database a snapshot of the state2312of the string at the given point in time. This should2313be the time of an existing patch.23142315The point of a snapshot is that if you load all patches recorded2316>= this point in time, then you don't need any earlier ones to2317reconstruct the document, since otherwise, why have the snapshot at2318all, as it does not good. Due to potentially long offline users2319putting old data into history, this can fail. However, in the usual2320case we should never record a snapshot with this bad property.2321*/2322private snapshot = reuseInFlight(async (time: number): Promise<void> => {2323assertDefined(this.patch_list);2324const x = this.patch_list.patch(time);2325if (x == null) {2326throw Error(`no patch at time ${time}`);2327}2328if (x.snapshot != null) {2329// there is already a snapshot at this point in time,2330// so nothing further to do.2331return;2332}23332334const snapshot: string = this.patch_list.value({ time }).to_str();2335// save the snapshot itself in the patches table.2336const seq_info = this.conatSnapshotSeqInfo(time);2337const obj = {2338size: snapshot.length,2339string_id: this.string_id,2340time,2341wall: time,2342is_snapshot: true,2343snapshot,2344user_id: x.user_id,2345seq_info,2346};2347// also set snapshot in the this.patch_list, which which saves a little time.2348// and ensures that "(x.snapshot != null)" above works if snapshot is called again.2349this.patch_list.add([obj]);2350this.patches_table.set(obj);2351await this.patches_table.save();2352if (this.state != "ready") {2353return;2354}23552356const last_seq = seq_info.seq;2357await this.set_syncstring_table({2358last_snapshot: time,2359last_seq,2360});2361this.setLastSnapshot(time);2362this.last_seq = last_seq;2363});23642365// Have a snapshot every this.snapshot_interval patches, except2366// for the very last interval.2367private snapshot_if_necessary = async (): Promise<void> => {2368if (this.get_state() !== "ready") return;2369const dbg = this.dbg("snapshot_if_necessary");2370const max_size = Math.floor(1.2 * MAX_FILE_SIZE_MB * 1000000);2371const interval = this.snapshot_interval;2372dbg("check if we need to make a snapshot:", { interval, max_size });2373assertDefined(this.patch_list);2374const time = this.patch_list.time_of_unmade_periodic_snapshot(2375interval,2376max_size,2377);2378if (time != null) {2379dbg("yes, try to make a snapshot at time", time);2380try {2381await this.snapshot(time);2382} catch (err) {2383// this is expected to happen sometimes, e.g., when sufficient information2384// isn't known about the stream of patches.2385console.log(2386`(expected) WARNING: client temporarily unable to make a snapshot of ${this.path} -- ${err}`,2387);2388}2389} else {2390dbg("no need to make a snapshot yet");2391}2392};23932394/*- x - patch object2395- patch: if given will be used as an actual patch2396instead of x.patch, which is a JSON string.2397*/2398private processPatch = ({2399x,2400patch,2401size: size0,2402}: {2403x: Map<string, any>;2404patch?: any;2405size?: number;2406}): Patch => {2407let t = x.get("time");2408if (typeof t != "number") {2409// backwards compat2410t = new Date(t).valueOf();2411}2412const time: number = t;2413const wall = x.get("wall") ?? time;2414const user_id: number = x.get("user_id");2415let parents: number[] = x.get("parents")?.toJS() ?? [];2416let size: number;2417const is_snapshot = x.get("is_snapshot");2418if (is_snapshot) {2419size = x.get("snapshot")?.length ?? 0;2420} else {2421if (patch == null) {2422/* Do **NOT** use misc.from_json, since we definitely2423do not want to unpack ISO timestamps as Date,2424since patch just contains the raw patches from2425user editing. This was done for a while, which2426led to horrific bugs in some edge cases...2427See https://github.com/sagemathinc/cocalc/issues/17712428*/2429if (x.has("patch")) {2430const p: string = x.get("patch");2431patch = JSON.parse(p);2432size = p.length;2433} else {2434patch = [];2435size = 2;2436}2437} else {2438const p = x.get("patch");2439size = p?.length ?? size0 ?? JSON.stringify(patch).length;2440}2441}24422443const obj: Patch = {2444time,2445wall,2446user_id,2447patch,2448size,2449is_snapshot,2450parents,2451version: x.get("version"),2452};2453if (is_snapshot) {2454obj.snapshot = x.get("snapshot"); // this is a string2455obj.seq_info = x.get("seq_info")?.toJS();2456if (obj.snapshot == null || obj.seq_info == null) {2457console.warn("WARNING: message = ", x.toJS());2458throw Error(2459`message with is_snapshot true must also set snapshot and seq_info fields -- time=${time}`,2460);2461}2462}2463return obj;2464};24652466/* Return all patches with time such that2467time0 <= time <= time1;2468If time0 undefined then sets time0 equal to time of last_snapshot.2469If time1 undefined treated as +oo.2470*/2471private get_patches = (): Patch[] => {2472this.assert_table_is_ready("patches");24732474// m below is an immutable map with keys the string that2475// is the JSON version of the primary key2476// [string_id, timestamp, user_number].2477let m: Map<string, any> | undefined = this.patches_table.get();2478if (m == null) {2479// won't happen because of assert above.2480throw Error("patches_table must be initialized");2481}2482if (!Map.isMap(m)) {2483// TODO: this is just for proof of concept NATS!!2484m = fromJS(m);2485}2486const v: Patch[] = [];2487m.forEach((x, _) => {2488const p = this.processPatch({ x });2489if (p != null) {2490return v.push(p);2491}2492});2493v.sort(patch_cmp);2494return v;2495};24962497hasFullHistory = (): boolean => {2498if (this.patch_list == null) {2499return false;2500}2501return this.patch_list.hasFullHistory();2502};25032504// returns true if there may be additional history to load2505// after loading this. return false if definitely done.2506loadMoreHistory = async ({2507all,2508}: {2509// if true, loads all history2510all?: boolean;2511} = {}): Promise<boolean> => {2512if (this.hasFullHistory() || this.ephemeral || this.patch_list == null) {2513return false;2514}2515let start_seq;2516if (all) {2517start_seq = 1;2518} else {2519const seq_info = this.patch_list.getOldestSnapshot()?.seq_info;2520if (seq_info == null) {2521// nothing more to load2522return false;2523}2524start_seq = seq_info.prev_seq ?? 1;2525}2526// Doing this load triggers change events for all the patch info2527// that gets loaded.2528// TODO: right now we load everything, since the seq_info is wrong2529// from the NATS migration. Maybe this is fine since it is very efficient.2530// @ts-ignore2531await this.patches_table.dstream?.load({ start_seq: 0 });25322533// Wait until patch update queue is empty2534while (this.patch_update_queue.length > 0) {2535await once(this, "patch-update-queue-empty");2536}2537return start_seq > 1;2538};25392540legacyHistoryExists = async () => {2541const info = await this.legacy.getInfo();2542return !!info.uuid;2543};25442545private loadedLegacyHistory = false;2546loadLegacyHistory = reuseInFlight(async () => {2547if (this.loadedLegacyHistory) {2548return;2549}2550this.loadedLegacyHistory = true;2551if (!this.hasFullHistory()) {2552throw Error("must first load full history first");2553}2554const { patches, users } = await this.legacy.getPatches();2555if (this.patch_list == null) {2556return;2557}2558// @ts-ignore - cheating here2559const first = this.patch_list.patches[0];2560if ((first?.parents ?? []).length > 0) {2561throw Error("first patch should have no parents");2562}2563for (const patch of patches) {2564// @ts-ignore2565patch.time = new Date(patch.time).valueOf();2566}2567patches.sort(field_cmp("time"));2568const v: Patch[] = [];2569let version = -patches.length;2570let i = 0;2571for (const patch of patches) {2572// @ts-ignore2573patch.version = version;2574version += 1;2575if (i > 0) {2576// @ts-ignore2577patch.parents = [patches[i - 1].time];2578} else {2579// @ts-ignore2580patch.parents = [];2581}25822583// remap the user_id field2584const account_id = users[patch.user_id];2585let user_id = this.users.indexOf(account_id);2586if (user_id == -1) {2587this.users.push(account_id);2588user_id = this.users.length - 1;2589}2590patch.user_id = user_id;25912592const p = this.processPatch({ x: fromJS(patch) });2593i += 1;2594v.push(p);2595}2596if (first != null) {2597// @ts-ignore2598first.parents = [patches[patches.length - 1].time];2599first.is_snapshot = true;2600first.snapshot = this.patch_list.value({ time: first.time }).to_str();2601}2602this.patch_list.add(v);2603this.emit("change");2604});26052606show_history = (opts = {}): void => {2607assertDefined(this.patch_list);2608this.patch_list.show_history(opts);2609};26102611set_snapshot_interval = async (n: number): Promise<void> => {2612await this.set_syncstring_table({2613snapshot_interval: n,2614});2615await this.syncstring_table.save();2616};26172618get_last_save_to_disk_time = (): Date => {2619return this.last_save_to_disk_time;2620};26212622private handle_syncstring_save_state = async (2623state: string,2624time: Date,2625): Promise<void> => {2626// Called when the save state changes.26272628/* this.syncstring_save_state is used to make it possible to emit a2629'save-to-disk' event, whenever the state changes2630to indicate a save completed.26312632NOTE: it is intentional that this.syncstring_save_state is not defined2633the first time this function is called, so that save-to-disk2634with last save time gets emitted on initial load (which, e.g., triggers2635latex compilation properly in case of a .tex file).2636*/2637if (state === "done" && this.syncstring_save_state !== "done") {2638this.last_save_to_disk_time = time;2639this.emit("save-to-disk", time);2640}2641const dbg = this.dbg("handle_syncstring_save_state");2642dbg(2643`state='${state}', this.syncstring_save_state='${this.syncstring_save_state}', this.state='${this.state}'`,2644);2645if (2646this.state === "ready" &&2647(await this.isFileServer()) &&2648this.syncstring_save_state !== "requested" &&2649state === "requested"2650) {2651this.syncstring_save_state = state; // only used in the if above2652dbg("requesting save to disk -- calling save_to_disk");2653// state just changed to requesting a save to disk...2654// so we do it (unless of course syncstring is still2655// being initialized).2656try {2657// Uncomment the following to test simulating a2658// random failure in save_to_disk:2659// if (Math.random() < 0.5) throw Error("CHAOS MONKEY!"); // FOR TESTING ONLY.2660await this.save_to_disk();2661} catch (err) {2662// CRITICAL: we must unset this.syncstring_save_state (and set the save state);2663// otherwise, it stays as "requested" and this if statement would never get2664// run again, thus completely breaking saving this doc to disk.2665// It is normal behavior that *sometimes* this.save_to_disk might2666// throw an exception, e.g., if the file is temporarily deleted2667// or save it called before everything is initialized, or file2668// is temporarily set readonly, or maybe there is a file system error.2669// Of course, the finally below will also take care of this. However,2670// it's nice to record the error here.2671this.syncstring_save_state = "done";2672await this.set_save({ state: "done", error: `${err}` });2673dbg(`ERROR saving to disk in handle_syncstring_save_state-- ${err}`);2674} finally {2675// No matter what, after the above code is run,2676// the save state in the table better be "done".2677// We triple check that here, though of course2678// we believe the logic in save_to_disk and above2679// should always accomplish this.2680dbg("had to set the state to done in finally block");2681if (2682this.state === "ready" &&2683(this.syncstring_save_state != "done" ||2684this.syncstring_table_get_one().getIn(["save", "state"]) != "done")2685) {2686this.syncstring_save_state = "done";2687await this.set_save({ state: "done", error: "" });2688}2689}2690}2691};26922693private handle_syncstring_update = async (): Promise<void> => {2694if (this.state === "closed") {2695return;2696}2697const dbg = this.dbg("handle_syncstring_update");2698dbg();26992700const data = this.syncstring_table_get_one();2701const x: any = data != null ? data.toJS() : undefined;27022703if (x != null && x.save != null) {2704this.handle_syncstring_save_state(x.save.state, x.save.time);2705}27062707dbg(JSON.stringify(x));2708if (x == null || x.users == null) {2709dbg("new_document");2710await this.handle_syncstring_update_new_document();2711} else {2712dbg("update_existing");2713await this.handle_syncstring_update_existing_document(x, data);2714}2715};27162717private handle_syncstring_update_new_document = async (): Promise<void> => {2718// Brand new document2719this.emit("load-time-estimate", { type: "new", time: 1 });2720this.setLastSnapshot();2721this.last_seq = undefined;2722this.snapshot_interval =2723schema.SCHEMA.syncstrings.user_query?.get?.fields.snapshot_interval ??2724DEFAULT_SNAPSHOT_INTERVAL;27252726// Brand new syncstring2727// TODO: worry about race condition with everybody making themselves2728// have user_id 0... and also setting doctype.2729this.my_user_id = 0;2730this.users = [this.client.client_id()];2731const obj = {2732string_id: this.string_id,2733project_id: this.project_id,2734path: this.path,2735last_snapshot: this.last_snapshot,2736users: this.users,2737doctype: JSON.stringify(this.doctype),2738last_active: this.client.server_time(),2739};2740this.syncstring_table.set(obj);2741await this.syncstring_table.save();2742this.settings = Map();2743this.emit("metadata-change");2744this.emit("settings-change", this.settings);2745};27462747private handle_syncstring_update_existing_document = async (2748x: any,2749data: Map<string, any>,2750): Promise<void> => {2751if (this.state === "closed") {2752return;2753}2754// Existing document.27552756if (this.path == null) {2757// We just opened the file -- emit a load time estimate.2758this.emit("load-time-estimate", { type: "ready", time: 1 });2759}2760// TODO: handle doctype change here (?)2761this.setLastSnapshot(x.last_snapshot);2762this.last_seq = x.last_seq;2763this.snapshot_interval = x.snapshot_interval ?? DEFAULT_SNAPSHOT_INTERVAL;2764this.users = x.users ?? [];2765if (x.project_id) {2766// @ts-ignore2767this.project_id = x.project_id;2768}2769if (x.path) {2770// @ts-ignore2771this.path = x.path;2772}27732774const settings = data.get("settings", Map());2775if (settings !== this.settings) {2776this.settings = settings;2777this.emit("settings-change", settings);2778}27792780if (this.client != null) {2781// Ensure that this client is in the list of clients2782const client_id: string = this.client_id();2783this.my_user_id = this.users.indexOf(client_id);2784if (this.my_user_id === -1) {2785this.my_user_id = this.users.length;2786this.users.push(client_id);2787await this.set_syncstring_table({2788users: this.users,2789});2790}2791}2792this.emit("metadata-change");2793};27942795private init_watch = async (): Promise<void> => {2796if (!(await this.isFileServer())) {2797// ensures we are NOT watching anything2798await this.update_watch_path();2799return;2800}28012802// If path isn't being properly watched, make it so.2803if (this.watch_path !== this.path) {2804await this.update_watch_path(this.path);2805}28062807await this.pending_save_to_disk();2808};28092810private pending_save_to_disk = async (): Promise<void> => {2811this.assert_table_is_ready("syncstring");2812if (!(await this.isFileServer())) {2813return;2814}28152816const x = this.syncstring_table.get_one();2817// Check if there is a pending save-to-disk that is needed.2818if (x != null && x.getIn(["save", "state"]) === "requested") {2819try {2820await this.save_to_disk();2821} catch (err) {2822const dbg = this.dbg("pending_save_to_disk");2823dbg(`ERROR saving to disk in pending_save_to_disk -- ${err}`);2824}2825}2826};28272828private update_watch_path = async (path?: string): Promise<void> => {2829const dbg = this.dbg("update_watch_path");2830if (this.file_watcher != null) {2831// clean up2832dbg("close");2833this.file_watcher.close();2834delete this.file_watcher;2835delete this.watch_path;2836}2837if (path != null && this.client.is_deleted(path, this.project_id)) {2838dbg(`not setting up watching since "${path}" is explicitly deleted`);2839return;2840}2841if (path == null) {2842dbg("not opening another watcher since path is null");2843this.watch_path = path;2844return;2845}2846if (this.watch_path != null) {2847// this case is impossible since we deleted it above if it is was defined.2848dbg("watch_path already defined");2849return;2850}2851dbg("opening watcher...");2852if (this.state === "closed") {2853throw Error("must not be closed");2854}2855this.watch_path = path;2856try {2857if (!(await callback2(this.client.path_exists, { path }))) {2858if (this.client.is_deleted(path, this.project_id)) {2859dbg(`not setting up watching since "${path}" is explicitly deleted`);2860return;2861}2862// path does not exist2863dbg(2864`write '${path}' to disk from syncstring in-memory database version`,2865);2866const data = this.to_str();2867await callback2(this.client.write_file, { path, data });2868dbg(`wrote '${path}' to disk`);2869}2870} catch (err) {2871// This can happen, e.g, if path is read only.2872dbg(`could NOT write '${path}' to disk -- ${err}`);2873await this.update_if_file_is_read_only();2874// In this case, can't really setup a file watcher.2875return;2876}28772878dbg("now requesting to watch file");2879this.file_watcher = this.client.watch_file({ path });2880this.file_watcher.on("change", this.handle_file_watcher_change);2881this.file_watcher.on("delete", this.handle_file_watcher_delete);2882this.setupReadOnlyTimer();2883};28842885private setupReadOnlyTimer = () => {2886if (this.read_only_timer) {2887clearInterval(this.read_only_timer as any);2888this.read_only_timer = 0;2889}2890this.read_only_timer = <any>(2891setInterval(this.update_if_file_is_read_only, READ_ONLY_CHECK_INTERVAL_MS)2892);2893};28942895private handle_file_watcher_change = async (ctime: Date): Promise<void> => {2896const dbg = this.dbg("handle_file_watcher_change");2897const time: number = ctime.valueOf();2898dbg(2899`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}`,2900);2901if (2902this.save_to_disk_start_ctime == null ||2903(this.save_to_disk_end_ctime != null &&2904time - this.save_to_disk_end_ctime >= RECENT_SAVE_TO_DISK_MS)2905) {2906// Either we never saved to disk, or the last attempt2907// to save was at least RECENT_SAVE_TO_DISK_MS ago, and it finished,2908// so definitely this change event was not caused by it.2909dbg("load_from_disk since no recent save to disk");2910await this.load_from_disk();2911return;2912}2913};29142915private handle_file_watcher_delete = async (): Promise<void> => {2916this.assert_is_ready("handle_file_watcher_delete");2917const dbg = this.dbg("handle_file_watcher_delete");2918dbg("delete: set_deleted and closing");2919await this.client.set_deleted(this.path, this.project_id);2920this.close();2921};29222923private load_from_disk = async (): Promise<number> => {2924const path = this.path;2925const dbg = this.dbg("load_from_disk");2926dbg();2927const exists: boolean = await callback2(this.client.path_exists, { path });2928let size: number;2929if (!exists) {2930dbg("file no longer exists -- setting to blank");2931size = 0;2932this.from_str("");2933} else {2934dbg("file exists");2935await this.update_if_file_is_read_only();29362937const data = await callback2<string>(this.client.path_read, {2938path,2939maxsize_MB: MAX_FILE_SIZE_MB,2940});29412942size = data.length;2943dbg(`got it -- length=${size}`);2944this.from_str(data);2945this.commit();2946// we also know that this is the version on disk, so we update the hash2947await this.set_save({2948state: "done",2949error: "",2950hash: hash_string(data),2951});2952}2953// save new version to database, which we just set via from_str.2954await this.save();2955return size;2956};29572958private set_save = async (save: {2959state: string;2960error: string;2961hash?: number;2962expected_hash?: number;2963time?: number;2964}): Promise<void> => {2965this.assert_table_is_ready("syncstring");2966// set timestamp of when the save happened; this can be useful2967// for coordinating running code, etc.... and is just generally useful.2968const cur = this.syncstring_table_get_one().toJS()?.save;2969if (cur != null) {2970if (2971cur.state == save.state &&2972cur.error == save.error &&2973cur.hash == (save.hash ?? cur.hash) &&2974cur.expected_hash == (save.expected_hash ?? cur.expected_hash) &&2975cur.time == (save.time ?? cur.time)2976) {2977// no genuine change, so no point in wasting cycles on updating.2978return;2979}2980}2981if (!save.time) {2982save.time = Date.now();2983}2984await this.set_syncstring_table({ save });2985};29862987private set_read_only = async (read_only: boolean): Promise<void> => {2988this.assert_table_is_ready("syncstring");2989await this.set_syncstring_table({ read_only });2990};29912992is_read_only = (): boolean => {2993this.assert_table_is_ready("syncstring");2994return this.syncstring_table_get_one().get("read_only");2995};29962997wait_until_read_only_known = async (): Promise<void> => {2998await this.wait_until_ready();2999function read_only_defined(t: SyncTable): boolean {3000const x = t.get_one();3001if (x == null) {3002return false;3003}3004return x.get("read_only") != null;3005}3006await this.syncstring_table.wait(read_only_defined, 5 * 60);3007};30083009/* Returns true if the current live version of this document has3010a different hash than the version mostly recently saved to disk.3011I.e., if there are changes that have not yet been **saved to3012disk**. See the other function has_uncommitted_changes below3013for determining whether there are changes that haven't been3014commited to the database yet. Returns *undefined* if3015initialization not even done yet. */3016has_unsaved_changes = (): boolean | undefined => {3017if (this.state !== "ready") {3018return;3019}3020const dbg = this.dbg("has_unsaved_changes");3021try {3022return this.hash_of_saved_version() !== this.hash_of_live_version();3023} catch (err) {3024dbg(3025"exception computing hash_of_saved_version and hash_of_live_version",3026err,3027);3028// This could happen, e.g. when syncstring_table isn't connected3029// in some edge case. Better to just say we don't know then crash3030// everything. See https://github.com/sagemathinc/cocalc/issues/35773031return;3032}3033};30343035// Returns hash of last version saved to disk (as far as we know).3036hash_of_saved_version = (): number | undefined => {3037if (this.state !== "ready") {3038return;3039}3040return this.syncstring_table_get_one().getIn(["save", "hash"]) as3041| number3042| undefined;3043};30443045/* Return hash of the live version of the document,3046or undefined if the document isn't loaded yet.3047(TODO: write faster version of this for syncdb, which3048avoids converting to a string, which is a waste of time.) */3049hash_of_live_version = (): number | undefined => {3050if (this.state !== "ready") {3051return;3052}3053return hash_string(this.doc.to_str());3054};30553056/* Return true if there are changes to this syncstring that3057have not been committed to the database (with the commit3058acknowledged). This does not mean the file has been3059written to disk; however, it does mean that it safe for3060the user to close their browser.3061*/3062has_uncommitted_changes = (): boolean => {3063if (this.state !== "ready") {3064return false;3065}3066return this.patches_table.has_uncommitted_changes();3067};30683069// Commit any changes to the live document to3070// history as a new patch. Returns true if there3071// were changes and false otherwise. This works3072// fine offline, and does not wait until anything3073// is saved to the network, etc.3074commit = (emitChangeImmediately = false): boolean => {3075if (this.last == null || this.doc == null || this.last.is_equal(this.doc)) {3076return false;3077}3078// console.trace('commit');30793080if (emitChangeImmediately) {3081// used for local clients. NOTE: don't do this without explicit3082// request, since it could in some cases cause serious trouble.3083// E.g., for the jupyter backend doing this by default causes3084// an infinite recurse. Having this as an option is important, e.g.,3085// to avoid flicker/delay in the UI.3086this.emit_change();3087}30883089// Now save to backend as a new patch:3090this.emit("user-change");3091const patch = this.last.make_patch(this.doc); // must be nontrivial3092this.last = this.doc;3093// ... and save that to patches table3094const time = this.next_patch_time();3095this.commit_patch(time, patch);3096this.save(); // so eventually also gets sent out.3097this.touchProject();3098return true;3099};31003101/* Initiates a save of file to disk, then waits for the3102state to change. */3103save_to_disk = async (): Promise<void> => {3104if (this.state != "ready") {3105// We just make save_to_disk a successful3106// no operation, if the document is either3107// closed or hasn't finished opening, since3108// there's a lot of code that tries to save3109// on exit/close or automatically, and it3110// is difficult to ensure it all checks state3111// properly.3112return;3113}3114const dbg = this.dbg("save_to_disk");3115if (this.client.is_deleted(this.path, this.project_id)) {3116dbg("not saving to disk because deleted");3117await this.set_save({ state: "done", error: "" });3118return;3119}31203121// Make sure to include changes to the live document.3122// A side effect of save if we didn't do this is potentially3123// discarding them, which is obviously not good.3124this.commit();31253126dbg("initiating the save");3127if (!this.has_unsaved_changes()) {3128dbg("no unsaved changes, so don't save");3129// CRITICAL: this optimization is assumed by3130// autosave, etc.3131await this.set_save({ state: "done", error: "" });3132return;3133}31343135if (this.is_read_only()) {3136dbg("read only, so can't save to disk");3137// save should fail if file is read only and there are changes3138throw Error("can't save readonly file with changes to disk");3139}31403141// First make sure any changes are saved to the database.3142// One subtle case where this matters is that loading a file3143// with \r's into codemirror changes them to \n...3144if (!(await this.isFileServer())) {3145dbg("browser client -- sending any changes over network");3146await this.save();3147dbg("save done; now do actual save to the *disk*.");3148this.assert_is_ready("save_to_disk - after save");3149}31503151try {3152await this.save_to_disk_aux();3153} catch (err) {3154if (this.state != "ready") return;3155const error = `save to disk failed -- ${err}`;3156dbg(error);3157if (await this.isFileServer()) {3158this.set_save({ error, state: "done" });3159}3160}3161if (this.state != "ready") return;31623163if (!(await this.isFileServer())) {3164dbg("now wait for the save to disk to finish");3165this.assert_is_ready("save_to_disk - waiting to finish");3166await this.wait_for_save_to_disk_done();3167}3168this.update_has_unsaved_changes();3169};31703171/* Export the (currently loaded) history of editing of this3172document to a simple JSON-able object. */3173export_history = (options: HistoryExportOptions = {}): HistoryEntry[] => {3174this.assert_is_ready("export_history");3175const info = this.syncstring_table.get_one();3176if (info == null || !info.has("users")) {3177throw Error("syncstring table must be defined and users initialized");3178}3179const account_ids: string[] = info.get("users").toJS();3180assertDefined(this.patch_list);3181return export_history(account_ids, this.patch_list, options);3182};31833184private update_has_unsaved_changes = (): void => {3185if (this.state != "ready") {3186// This can happen, since this is called by a debounced function.3187// Make it a no-op in case we're not ready.3188// See https://github.com/sagemathinc/cocalc/issues/35773189return;3190}3191const cur = this.has_unsaved_changes();3192if (cur !== this.last_has_unsaved_changes) {3193this.emit("has-unsaved-changes", cur);3194this.last_has_unsaved_changes = cur;3195}3196};31973198// wait for save.state to change state.3199private wait_for_save_to_disk_done = async (): Promise<void> => {3200const dbg = this.dbg("wait_for_save_to_disk_done");3201dbg();3202function until(table): boolean {3203const done = table.get_one().getIn(["save", "state"]) === "done";3204dbg("checking... done=", done);3205return done;3206}32073208let last_err: string | undefined = undefined;3209const f = async () => {3210dbg("f");3211if (3212this.state != "ready" ||3213this.client.is_deleted(this.path, this.project_id)3214) {3215dbg("not ready or deleted - no longer trying to save.");3216return;3217}3218try {3219dbg("waiting until done...");3220await this.syncstring_table.wait(until, 15);3221} catch (err) {3222dbg("timed out after 15s");3223throw Error("timed out");3224}3225if (3226this.state != "ready" ||3227this.client.is_deleted(this.path, this.project_id)3228) {3229dbg("not ready or deleted - no longer trying to save.");3230return;3231}3232const err = this.syncstring_table_get_one().getIn(["save", "error"]) as3233| string3234| undefined;3235if (err) {3236dbg("error", err);3237last_err = err;3238throw Error(err);3239}3240dbg("done, with no error.");3241last_err = undefined;3242return;3243};3244await retry_until_success({3245f,3246max_tries: 8,3247desc: "wait_for_save_to_disk_done",3248});3249if (3250this.state != "ready" ||3251this.client.is_deleted(this.path, this.project_id)3252) {3253return;3254}3255if (last_err && typeof this.client.log_error != null) {3256this.client.log_error?.({3257string_id: this.string_id,3258path: this.path,3259project_id: this.project_id,3260error: `Error saving file -- ${last_err}`,3261});3262}3263};32643265/* Auxiliary function 2 for saving to disk:3266If this is associated with3267a project and has a filename.3268A user (web browsers) sets the save state to requested.3269The project sets the state to saving, does the save3270to disk, then sets the state to done.3271*/3272private save_to_disk_aux = async (): Promise<void> => {3273this.assert_is_ready("save_to_disk_aux");32743275if (!(await this.isFileServer())) {3276return await this.save_to_disk_non_filesystem_owner();3277}32783279try {3280return await this.save_to_disk_filesystem_owner();3281} catch (err) {3282this.emit("save_to_disk_filesystem_owner", err);3283throw err;3284}3285};32863287private save_to_disk_non_filesystem_owner = async (): Promise<void> => {3288this.assert_is_ready("save_to_disk_non_filesystem_owner");32893290if (!this.has_unsaved_changes()) {3291/* Browser client has no unsaved changes,3292so don't need to save --3293CRITICAL: this optimization is assumed by autosave.3294*/3295return;3296}3297const x = this.syncstring_table.get_one();3298if (x != null && x.getIn(["save", "state"]) === "requested") {3299// Nothing to do -- save already requested, which is3300// all the browser client has to do.3301return;3302}33033304// string version of this doc3305const data: string = this.to_str();3306const expected_hash = hash_string(data);3307await this.set_save({ state: "requested", error: "", expected_hash });3308};33093310private save_to_disk_filesystem_owner = async (): Promise<void> => {3311this.assert_is_ready("save_to_disk_filesystem_owner");3312const dbg = this.dbg("save_to_disk_filesystem_owner");33133314// check if on-disk version is same as in memory, in3315// which case no save is needed.3316const data = this.to_str(); // string version of this doc3317const hash = hash_string(data);3318dbg("hash = ", hash);33193320/*3321// TODO: put this consistency check back in (?).3322const expected_hash = this.syncstring_table3323.get_one()3324.getIn(["save", "expected_hash"]);3325*/33263327if (hash === this.hash_of_saved_version()) {3328// No actual save to disk needed; still we better3329// record this fact in table in case it3330// isn't already recorded3331this.set_save({ state: "done", error: "", hash });3332return;3333}33343335const path = this.path;3336if (!path) {3337const err = "cannot save without path";3338this.set_save({ state: "done", error: err });3339throw Error(err);3340}33413342dbg("project - write to disk file", path);3343// set window to slightly earlier to account for clock3344// imprecision.3345// Over an sshfs mount, all stats info is **rounded down3346// to the nearest second**, which this also takes care of.3347this.save_to_disk_start_ctime = Date.now() - 1500;3348this.save_to_disk_end_ctime = undefined;3349try {3350await callback2(this.client.write_file, { path, data });3351this.assert_is_ready("save_to_disk_filesystem_owner -- after write_file");3352const stat = await callback2(this.client.path_stat, { path });3353this.assert_is_ready("save_to_disk_filesystem_owner -- after path_state");3354this.save_to_disk_end_ctime = stat.ctime.valueOf() + 1500;3355this.set_save({3356state: "done",3357error: "",3358hash: hash_string(data),3359});3360} catch (err) {3361this.set_save({ state: "done", error: JSON.stringify(err) });3362throw err;3363}3364};33653366/*3367When the underlying synctable that defines the state3368of the document changes due to new remote patches, this3369function is called.3370It handles update of the remote version, updating our3371live version as a result.3372*/3373private handle_patch_update = async (changed_keys): Promise<void> => {3374// console.log("handle_patch_update", { changed_keys });3375if (changed_keys == null || changed_keys.length === 0) {3376// this happens right now when we do a save.3377return;3378}33793380const dbg = this.dbg("handle_patch_update");3381//dbg(changed_keys);3382if (this.patch_update_queue == null) {3383this.patch_update_queue = [];3384}3385for (const key of changed_keys) {3386this.patch_update_queue.push(key);3387}33883389dbg("Clear patch update_queue in a later event loop...");3390await delay(1);3391await this.handle_patch_update_queue();3392dbg("done");3393};33943395/*3396Whenever new patches are added to this.patches_table,3397their timestamp gets added to this.patch_update_queue.3398*/3399private handle_patch_update_queue = async (): Promise<void> => {3400const dbg = this.dbg("handle_patch_update_queue");3401try {3402this.handle_patch_update_queue_running = true;3403while (this.state != "closed" && this.patch_update_queue.length > 0) {3404dbg("queue size = ", this.patch_update_queue.length);3405const v: Patch[] = [];3406for (const key of this.patch_update_queue) {3407let x = this.patches_table.get(key);3408if (x == null) {3409continue;3410}3411if (!Map.isMap(x)) {3412// TODO: my NATS synctable-stream doesn't convert to immutable on get.3413x = fromJS(x);3414}3415// may be null, e.g., when deleted.3416const t = x.get("time");3417// Optimization: only need to process patches that we didn't3418// create ourselves during this session.3419if (t && !this.my_patches[t.valueOf()]) {3420const p = this.processPatch({ x });3421//dbg(`patch=${JSON.stringify(p)}`);3422if (p != null) {3423v.push(p);3424}3425}3426}3427this.patch_update_queue = [];3428this.emit("patch-update-queue-empty");3429assertDefined(this.patch_list);3430this.patch_list.add(v);34313432dbg("waiting for remote and doc to sync...");3433this.sync_remote_and_doc(v.length > 0);3434await this.patches_table.save();3435if (this.state === ("closed" as State)) return; // closed during await; nothing further to do3436dbg("remote and doc now synced");34373438if (this.patch_update_queue.length > 0) {3439// It is very important that next loop happen in a later3440// event loop to avoid the this.sync_remote_and_doc call3441// in this.handle_patch_update_queue above from causing3442// sync_remote_and_doc to get called from within itself,3443// due to synctable changes being emited on save.3444dbg("wait for next event loop");3445await delay(1);3446}3447}3448} finally {3449if (this.state == "closed") return; // got closed, so nothing further to do34503451// OK, done and nothing in the queue3452// Notify save() to try again -- it may have3453// paused waiting for this to clear.3454dbg("done");3455this.handle_patch_update_queue_running = false;3456this.emit("handle_patch_update_queue_done");3457}3458};34593460/* Disable and enable sync. When disabled we still3461collect patches from upstream (but do not apply them3462locally), and changes we make are broadcast into3463the patch stream. When we re-enable sync, all3464patches are put together in the stream and3465everything is synced as normal. This is useful, e.g.,3466to make it so a user **actively** editing a document is3467not interrupted by being forced to sync (in particular,3468by the 'before-change' event that they use to update3469the live document).34703471Also, delay_sync will delay syncing local with upstream3472for the given number of ms. Calling it regularly while3473user is actively editing to avoid them being bothered3474by upstream patches getting merged in.34753476IMPORTANT: I implemented this, but it is NOT used anywhere3477else in the codebase, so don't trust that it works.3478*/34793480disable_sync = (): void => {3481this.sync_is_disabled = true;3482};34833484enable_sync = (): void => {3485this.sync_is_disabled = false;3486this.sync_remote_and_doc(true);3487};34883489delay_sync = (timeout_ms = 2000): void => {3490clearTimeout(this.delay_sync_timer);3491this.disable_sync();3492this.delay_sync_timer = setTimeout(() => {3493this.enable_sync();3494}, timeout_ms);3495};34963497/*3498Merge remote patches and live version to create new live version,3499which is equal to result of applying all patches.3500*/3501private sync_remote_and_doc = (upstreamPatches: boolean): void => {3502if (this.last == null || this.doc == null || this.sync_is_disabled) {3503return;3504}35053506// Critical to save what we have now so it doesn't get overwritten during3507// before-change or setting this.doc below. This caused3508// https://github.com/sagemathinc/cocalc/issues/58713509this.commit();35103511if (upstreamPatches && this.state == "ready") {3512// First save any unsaved changes from the live document, which this3513// sync-doc doesn't acutally know the state of. E.g., this is some3514// rapidly changing live editor with changes not yet saved here.3515this.emit("before-change");3516// As a result of the emit in the previous line, all kinds of3517// nontrivial listener code probably just ran, and it should3518// have updated this.doc. We commit this.doc, so that the3519// upstream patches get applied against the correct live this.doc.3520this.commit();3521}35223523// Compute the global current state of the document,3524// which is got by applying all patches in order.3525// It is VERY important to do this, even if the3526// document is not yet ready, since it is critical3527// to properly set the state of this.doc to the value3528// of the patch list (e.g., not doing this 100% breaks3529// opening a file for the first time on cocalc-docker).3530assertDefined(this.patch_list);3531const new_remote = this.patch_list.value();3532if (!this.doc.is_equal(new_remote)) {3533// There is a possibility that live document changed, so3534// set to new version.3535this.last = this.doc = new_remote;3536if (this.state == "ready") {3537this.emit("after-change");3538this.emit_change();3539}3540}3541};35423543// Immediately alert all watchers of all changes since3544// last time.3545private emit_change = (): void => {3546this.emit("change", this.doc?.changes(this.before_change));3547this.before_change = this.doc;3548};35493550// Alert to changes soon, but debounced in case there are a large3551// number of calls in a group. This is called by default.3552// The debounce param is 0, since the idea is that this just waits3553// until the next "render loop" to avoid huge performance issues3554// with a nested for loop of sets. Doing it this way, massively3555// simplifies client code.3556emit_change_debounced: typeof this.emit_change = debounce(3557this.emit_change,35580,3559);35603561private set_syncstring_table = async (obj, save = true) => {3562const value0 = this.syncstring_table_get_one();3563const value = mergeDeep(value0, fromJS(obj));3564if (value0.equals(value)) {3565return;3566}3567this.syncstring_table.set(value);3568if (save) {3569await this.syncstring_table.save();3570}3571};35723573// this keeps the project from idle timing out -- it happens3574// whenever there is an edit to the file by a browser, and3575// keeps the project from stopping.3576private touchProject = throttle(() => {3577if (this.client?.is_browser()) {3578this.client.touch_project?.(this.path);3579}3580}, 60000);35813582private initInterestLoop = async () => {3583if (!this.client.is_browser()) {3584// only browser clients -- so actual humans3585return;3586}3587const touch = async () => {3588if (this.state == "closed" || this.client?.touchOpenFile == null) return;3589await this.client.touchOpenFile({3590path: this.path,3591project_id: this.project_id,3592doctype: this.doctype,3593});3594};3595// then every CONAT_OPEN_FILE_TOUCH_INTERVAL (30 seconds).3596await until(3597async () => {3598if (this.state == "closed") {3599return true;3600}3601await touch();3602return false;3603},3604{3605start: CONAT_OPEN_FILE_TOUCH_INTERVAL,3606max: CONAT_OPEN_FILE_TOUCH_INTERVAL,3607},3608);3609};3610}36113612function isCompletePatchStream(dstream) {3613if (dstream.length == 0) {3614return false;3615}3616const first = dstream[0];3617if (first.is_snapshot) {3618return false;3619}3620if (first.parents == null) {3621// first ever commit3622return true;3623}3624for (let i = 1; i < dstream.length; i++) {3625if (dstream[i].is_snapshot && dstream[i].time == first.time) {3626return true;3627}3628}3629return false;3630}363136323633