Path: blob/master/src/packages/sync/editor/generic/sync-doc.ts
5700 views
/*1* This file is part of CoCalc: Copyright © 2020-2025 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,110CursorMap,111DocType,112Document,113FileWatcher,114Patch,115} from "./types";116import { isTestClient, patch_cmp } from "./util";117import { CONAT_OPEN_FILE_TOUCH_INTERVAL } from "@cocalc/util/conat";118import mergeDeep from "@cocalc/util/immutable-deep-merge";119import { JUPYTER_SYNCDB_EXTENSIONS } from "@cocalc/util/jupyter/names";120import { LegacyHistory } from "./legacy";121import { getLogger } from "@cocalc/conat/client";122123const DEBUG = false;124125export type State = "init" | "ready" | "closed";126export type DataServer = "project" | "database";127128export interface SyncOpts0 {129project_id: string;130path: string;131client: Client;132patch_interval?: number;133134// file_use_interval defaults to 60000.135// Specify 0 to disable.136file_use_interval?: number;137138string_id?: string;139cursors?: boolean;140change_throttle?: number;141142// persistent backend session in project, so only close143// backend when explicitly requested:144persistent?: boolean;145146// If true, entire sync-doc is assumed ephemeral, in the147// sense that no edit history gets saved via patches to148// the database. The one syncstring record for coordinating149// users does get created in the database.150ephemeral?: boolean;151152// which data/changefeed server to use153data_server?: DataServer;154}155156export interface SyncOpts extends SyncOpts0 {157from_str: (str: string) => Document;158doctype: DocType;159}160161export interface UndoState {162my_times: number[];163pointer: number;164without: number[];165final?: CompressedPatch;166}167168// NOTE: Do not make multiple SyncDoc's for the same document, especially169// not on the frontend.170171const logger = getLogger("sync-doc");172logger.debug("init");173174export class SyncDoc extends EventEmitter {175public readonly project_id: string; // project_id that contains the doc176public readonly path: string; // path of the file corresponding to the doc177private string_id: string;178private my_user_id: number;179180private client: Client;181private _from_str: (str: string) => Document; // creates a doc from a string.182183// Throttling of incoming upstream patches from project to client.184private patch_interval: number = 250;185186// This is what's actually output by setInterval -- it's187// not an amount of time.188private fileserver_autosave_timer: number = 0;189190private read_only_timer: number = 0;191192// throttling of change events -- e.g., is useful for course193// editor where we have hundreds of changes and the UI gets194// overloaded unless we throttle and group them.195private change_throttle: number = 0;196197// file_use_interval throttle: default is 60s for everything198private file_use_interval: number;199private throttled_file_use?: Function;200201private cursors: boolean = false; // if true, also provide cursor tracking functionality202private cursor_map: CursorMap = Map() as CursorMap;203private cursor_last_time: Date = new Date(0);204205// doctype: object describing document constructor206// (used by project to open file)207private doctype: DocType;208209private state: State = "init";210211private syncstring_table: SyncTable;212private patches_table: SyncTable;213private cursors_table: SyncTable;214215public evaluator?: Evaluator;216217public ipywidgets_state?: IpywidgetsState;218219private patch_list?: SortedPatchList;220221private last: Document;222private doc: Document;223private before_change?: Document;224225private last_user_change: Date = minutes_ago(60);226private last_save_to_disk_time: Date = new Date(0);227228private last_snapshot?: number;229private last_seq?: number;230private snapshot_interval: number;231232private users: string[];233234private settings: Map<string, any> = Map();235236private syncstring_save_state: string = "";237238// patches that this client made during this editing session.239private my_patches: { [time: string]: XPatch } = {};240241private watch_path?: string;242private file_watcher?: FileWatcher;243244private handle_patch_update_queue_running: boolean;245private patch_update_queue: string[] = [];246247private undo_state: UndoState | undefined;248249private save_to_disk_start_ctime: number | undefined;250private save_to_disk_end_ctime: number | undefined;251252private persistent: boolean = false;253254private last_has_unsaved_changes?: boolean = undefined;255256private ephemeral: boolean = false;257258private sync_is_disabled: boolean = false;259private delay_sync_timer: any;260261// static because we want exactly one across all docs!262private static computeServerManagerDoc?: SyncDoc;263264private useConat: boolean;265legacy: LegacyHistory;266267constructor(opts: SyncOpts) {268super();269if (opts.string_id === undefined) {270this.string_id = schema.client_db.sha1(opts.project_id, opts.path);271} else {272this.string_id = opts.string_id;273}274275for (const field of [276"project_id",277"path",278"client",279"patch_interval",280"file_use_interval",281"change_throttle",282"cursors",283"doctype",284"from_patch_str",285"persistent",286"data_server",287"ephemeral",288]) {289if (opts[field] != undefined) {290this[field] = opts[field];291}292}293294this.legacy = new LegacyHistory({295project_id: this.project_id,296path: this.path,297client: this.client,298});299300// NOTE: Do not use conat in test mode, since there we use a minimal301// "fake" client that does all communication internally and doesn't302// use conat. We also use this for the messages composer.303this.useConat = USE_CONAT && !isTestClient(opts.client);304if (this.ephemeral) {305// So the doctype written to the database reflects the306// ephemeral state. Here ephemeral determines whether307// or not patches are written to the database by the308// project.309this.doctype.opts = { ...this.doctype.opts, ephemeral: true };310}311if (this.cursors) {312// similarly to ephemeral, but for cursors. We track them313// on the backend since they can also be very useful, e.g.,314// with jupyter they are used for connecting remote compute,315// and **should** also be used for broadcasting load and other316// status information (TODO).317this.doctype.opts = { ...this.doctype.opts, cursors: true };318}319this._from_str = opts.from_str;320321// Initialize to time when we create the syncstring, so we don't322// see our own cursor when we refresh the browser (before we move323// to update this).324this.cursor_last_time = this.client?.server_time();325326reuse_in_flight_methods(this, [327"save",328"save_to_disk",329"load_from_disk",330"handle_patch_update_queue",331]);332333if (this.change_throttle) {334this.emit_change = throttle(this.emit_change, this.change_throttle);335}336337this.setMaxListeners(100);338339this.init();340}341342/*343Initialize everything.344This should be called *exactly* once by the constructor,345and no other time. It tries to set everything up. If346the browser isn't connected to the network, it'll wait347until it is (however long, etc.). If this fails, it closes348this SyncDoc.349*/350private initialized = false;351private init = async () => {352if (this.initialized) {353throw Error("init can only be called once");354}355// const start = Date.now();356this.assert_not_closed("init");357const log = this.dbg("init");358await until(359async () => {360if (this.state != "init") {361return true;362}363try {364log("initializing all tables...");365await this.initAll();366log("initAll succeeded");367return true;368} catch (err) {369if (this.isClosed()) {370return true;371}372const m = `WARNING: problem initializing ${this.path} -- ${err}`;373log(m);374if (DEBUG) {375console.trace(err);376}377// log always378console.log(m);379}380log("wait then try again");381return false;382},383{ start: 3000, max: 15000, decay: 1.3 },384);385386// Success -- everything initialized with no issues.387this.set_state("ready");388this.init_watch();389this.emit_change(); // from nothing to something.390};391392// True if this client is responsible for managing393// the state of this document with respect to394// the file system. By default, the project is responsible,395// but it could be something else (e.g., a compute server!). It's396// important that whatever algorithm determines this, it is397// a function of state that is eventually consistent.398// IMPORTANT: whether or not we are the file server can399// change over time, so if you call isFileServer and400// set something up (e.g., autosave or a watcher), based401// on the result, you need to clear it when the state402// changes. See the function handleComputeServerManagerChange.403private isFileServer = reuseInFlight(async () => {404if (this.state == "closed") return;405if (this.client == null || this.client.is_browser()) {406// browser is never the file server (yet), and doesn't need to do407// anything related to watching for changes in state.408// Someday via webassembly or browsers making users files availabl,409// etc., we will have this. Not today.410return false;411}412const computeServerManagerDoc = this.getComputeServerManagerDoc();413const log = this.dbg("isFileServer");414if (computeServerManagerDoc == null) {415log("not using compute server manager for this doc");416return this.client.is_project();417}418419const state = computeServerManagerDoc.get_state();420log("compute server manager doc state: ", state);421if (state == "closed") {422log("compute server manager is closed");423// something really messed up424return this.client.is_project();425}426if (state != "ready") {427try {428log(429"waiting for compute server manager doc to be ready; current state=",430state,431);432await once(computeServerManagerDoc, "ready", 15000);433log("compute server manager is ready");434} catch (err) {435log(436"WARNING -- failed to initialize computeServerManagerDoc -- err=",437err,438);439return this.client.is_project();440}441}442443// id of who the user *wants* to be the file server.444const path = this.getFileServerPath();445const fileServerId =446computeServerManagerDoc.get_one({ path })?.get("id") ?? 0;447if (this.client.is_project()) {448log(449"we are project, so we are fileserver if fileServerId=0 and it is ",450fileServerId,451);452return fileServerId == 0;453}454// at this point we have to be a compute server455const computeServerId = decodeUUIDtoNum(this.client.client_id());456// this is usually true -- but might not be if we are switching457// directly from one compute server to another.458log("we are compute server and ", { fileServerId, computeServerId });459return fileServerId == computeServerId;460});461462private getFileServerPath = () => {463if (this.path?.endsWith("." + JUPYTER_SYNCDB_EXTENSIONS)) {464// treating jupyter as a weird special case here.465return auxFileToOriginal(this.path);466}467return this.path;468};469470private getComputeServerManagerDoc = () => {471if (this.path == COMPUTE_SERVE_MANAGER_SYNCDB_PARAMS.path) {472// don't want to recursively explode!473return null;474}475if (SyncDoc.computeServerManagerDoc == null) {476if (this.client.is_project()) {477// @ts-ignore: TODO!478SyncDoc.computeServerManagerDoc = this.client.syncdoc({479path: COMPUTE_SERVE_MANAGER_SYNCDB_PARAMS.path,480});481} else {482// @ts-ignore: TODO!483SyncDoc.computeServerManagerDoc = this.client.sync_client.sync_db({484project_id: this.project_id,485...COMPUTE_SERVE_MANAGER_SYNCDB_PARAMS,486});487}488if (489SyncDoc.computeServerManagerDoc != null &&490!this.client.is_browser()491) {492// start watching for state changes493SyncDoc.computeServerManagerDoc.on(494"change",495this.handleComputeServerManagerChange,496);497}498}499return SyncDoc.computeServerManagerDoc;500};501502private handleComputeServerManagerChange = async (keys) => {503if (SyncDoc.computeServerManagerDoc == null) {504return;505}506let relevant = false;507for (const key of keys ?? []) {508if (key.get("path") == this.path) {509relevant = true;510break;511}512}513if (!relevant) {514return;515}516const path = this.getFileServerPath();517const fileServerId =518SyncDoc.computeServerManagerDoc.get_one({ path })?.get("id") ?? 0;519const ourId = this.client.is_project()520? 0521: decodeUUIDtoNum(this.client.client_id());522// we are considering ourself the file server already if we have523// either a watcher or autosave on.524const thinkWeAreFileServer =525this.file_watcher != null || this.fileserver_autosave_timer;526const weAreFileServer = fileServerId == ourId;527if (thinkWeAreFileServer != weAreFileServer) {528// life has changed! Let's adapt.529if (thinkWeAreFileServer) {530// we were acting as the file server, but now we are not.531await this.save_to_disk_filesystem_owner();532// Stop doing things we are no longer supposed to do.533clearInterval(this.fileserver_autosave_timer as any);534this.fileserver_autosave_timer = 0;535// stop watching filesystem536await this.update_watch_path();537} else {538// load our state from the disk539await this.load_from_disk();540// we were not acting as the file server, but now we need. Let's541// step up to the plate.542// start watching filesystem543await this.update_watch_path(this.path);544// enable autosave545await this.init_file_autosave();546}547}548};549550// Return id of ACTIVE remote compute server, if one is connected and pinging, or 0551// if none is connected. This is used by Jupyter to determine who552// should evaluate code.553// We always take the smallest id of the remote554// compute servers, in case there is more than one, so exactly one of them555// takes control. Always returns 0 if cursors are not enabled for this556// document, since the cursors table is used to coordinate the compute557// server.558getComputeServerId = (): number => {559if (!this.cursors) {560return 0;561}562// This info is in the "cursors" table instead of the document itself563// to avoid wasting space in the database longterm. Basically a remote564// Jupyter client that can provide compute announces this by reporting it's565// cursor to look a certain way.566const cursors = this.get_cursors({567maxAge: COMPUTE_THRESH_MS,568// don't exclude self since getComputeServerId called from the compute569// server also to know if it is the chosen one.570excludeSelf: "never",571});572const dbg = this.dbg("getComputeServerId");573dbg("num cursors = ", cursors.size);574let minId = Infinity;575// NOTE: similar code is in frontend/jupyter/cursor-manager.ts576for (const [client_id, cursor] of cursors) {577if (cursor.getIn(["locs", 0, "type"]) == COMPUTER_SERVER_CURSOR_TYPE) {578try {579minId = Math.min(minId, decodeUUIDtoNum(client_id));580} catch (err) {581// this should never happen unless a client were being malicious.582dbg(583"WARNING -- client_id should encode server id, but is",584client_id,585);586}587}588}589590return isFinite(minId) ? minId : 0;591};592593registerAsComputeServer = () => {594this.setCursorLocsNoThrottle([{ type: COMPUTER_SERVER_CURSOR_TYPE }]);595};596597/* Set this user's cursors to the given locs. */598setCursorLocsNoThrottle = async (599// locs is 'any' and not any[] because of a codemirror syntax highlighting bug!600locs: any,601side_effect: boolean = false,602) => {603if (this.state != "ready") {604return;605}606if (this.cursors_table == null) {607if (!this.cursors) {608throw Error("cursors are not enabled");609}610// table not initialized yet611return;612}613if (this.useConat) {614const time = this.client.server_time().valueOf();615const x: {616user_id: number;617locs: any;618time: number;619} = {620user_id: this.my_user_id,621locs,622time,623};624// will actually always be non-null due to above625this.cursor_last_time = new Date(x.time);626this.cursors_table.set(x);627return;628}629630const x: {631string_id?: string;632user_id: number;633locs: any[];634time?: Date;635} = {636string_id: this.string_id,637user_id: this.my_user_id,638locs,639};640const now = this.client.server_time();641if (!side_effect || (x.time ?? now) >= now) {642// the now comparison above is in case the cursor time643// is in the future (due to clock issues) -- always fix that.644x.time = now;645}646if (x.time != null) {647// will actually always be non-null due to above648this.cursor_last_time = x.time;649}650this.cursors_table.set(x, "none");651await this.cursors_table.save();652};653654set_cursor_locs: typeof this.setCursorLocsNoThrottle = throttle(655this.setCursorLocsNoThrottle,656USE_CONAT ? CURSOR_THROTTLE_NATS_MS : CURSOR_THROTTLE_MS,657{658leading: true,659trailing: true,660},661);662663private init_file_use_interval = (): void => {664if (this.file_use_interval == null) {665this.file_use_interval = 60 * 1000;666}667668if (!this.file_use_interval || !this.client.is_browser()) {669// file_use_interval has to be nonzero, and we only do670// this for browser user.671return;672}673674const file_use = async () => {675await delay(100); // wait a little so my_patches and gets updated.676// We ONLY count this and record that the file was677// edited if there was an actual change record in the678// patches log, by this user, since last time.679let user_is_active: boolean = false;680for (const tm in this.my_patches) {681if (new Date(parseInt(tm)) > this.last_user_change) {682user_is_active = true;683break;684}685}686if (!user_is_active) {687return;688}689this.last_user_change = new Date();690this.client.mark_file?.({691project_id: this.project_id,692path: this.path,693action: "edit",694ttl: this.file_use_interval,695});696};697this.throttled_file_use = throttle(file_use, this.file_use_interval, {698leading: true,699});700701this.on("user-change", this.throttled_file_use as any);702};703704isClosed = () => (this.state ?? "closed") == "closed";705706private set_state = (state: State): void => {707this.state = state;708this.emit(state);709};710711get_state = (): State => {712return this.state;713};714715get_project_id = (): string => {716return this.project_id;717};718719get_path = (): string => {720return this.path;721};722723get_string_id = (): string => {724return this.string_id;725};726727get_my_user_id = (): number => {728return this.my_user_id != null ? this.my_user_id : 0;729};730731private assert_not_closed(desc: string): void {732if (this.state === "closed") {733//console.trace();734throw Error(`must not be closed -- ${desc}`);735}736}737738set_doc = (doc: Document, exit_undo_mode: boolean = true): void => {739if (doc.is_equal(this.doc)) {740// no change.741return;742}743if (exit_undo_mode) this.undo_state = undefined;744// console.log(`sync-doc.set_doc("${doc.to_str()}")`);745this.doc = doc;746747// debounced, so don't immediately alert, in case there are many748// more sets comming in the same loop:749this.emit_change_debounced();750};751752// Convenience function to avoid having to do753// get_doc and set_doc constantly.754set = (x: any): void => {755this.set_doc(this.doc.set(x));756};757758delete = (x?: any): void => {759this.set_doc(this.doc.delete(x));760};761762get = (x?: any): any => {763return this.doc.get(x);764};765766get_one(x?: any): any {767return this.doc.get_one(x);768}769770// Return underlying document, or undefined if document771// hasn't been set yet.772get_doc = (): Document => {773if (this.doc == null) {774throw Error("doc must be set");775}776return this.doc;777};778779// Set this doc from its string representation.780from_str = (value: string): void => {781// console.log(`sync-doc.from_str("${value}")`);782this.doc = this._from_str(value);783};784785// Return string representation of this doc,786// or exception if not yet ready.787to_str = (): string => {788if (this.doc == null) {789throw Error("doc must be set");790}791return this.doc.to_str();792};793794count = (): number => {795return this.doc.count();796};797798// Version of the document at a given point in time; if no799// time specified, gives the version right now.800// If not fully initialized, will throw exception.801version = (time?: number): Document => {802this.assert_table_is_ready("patches");803assertDefined(this.patch_list);804return this.patch_list.value({ time });805};806807/* Compute version of document if the patches at the given times808were simply not included. This is a building block that is809used for implementing undo functionality for client editors. */810version_without = (without_times: number[]): Document => {811this.assert_table_is_ready("patches");812assertDefined(this.patch_list);813return this.patch_list.value({ without_times });814};815816// Revert document to what it was at the given point in time.817// There doesn't have to be a patch at exactly that point in818// time -- if there isn't it just uses the patch before that819// point in time.820revert = (time: number): void => {821this.set_doc(this.version(time));822};823824/* Undo/redo public api.825Calling this.undo and this.redo returns the version of826the document after the undo or redo operation, and records827a commit changing to that.828The first time calling this.undo switches into undo829state in which additional830calls to undo/redo move up and down the stack of changes made831by this user during this session.832833Call this.exit_undo_mode() to exit undo/redo mode.834835Undo and redo *only* impact changes made by this user during836this session. Other users edits are unaffected, and work by837this same user working from another browser tab or session is838also unaffected.839840Finally, undo of a past patch by definition means "the state841of the document" if that patch was not applied. The impact842of undo is NOT that the patch is removed from the patch history.843Instead, it records a new patch that is what would have happened844had we replayed history with the patches being undone not there.845846Doing any set_doc explicitly exits undo mode automatically.847*/848undo = (): Document => {849const prev = this._undo();850this.set_doc(prev, false);851this.commit();852return prev;853};854855redo = (): Document => {856const next = this._redo();857this.set_doc(next, false);858this.commit();859return next;860};861862private _undo(): Document {863this.assert_is_ready("_undo");864let state = this.undo_state;865if (state == null) {866// not in undo mode867state = this.initUndoState();868}869if (state.pointer === state.my_times.length) {870// pointing at live state (e.g., happens on entering undo mode)871const value: Document = this.version(); // last saved version872const live: Document = this.doc;873if (!live.is_equal(value)) {874// User had unsaved changes, so last undo is to revert to version without those.875state.final = value.make_patch(live); // live redo if needed876state.pointer -= 1; // most recent timestamp877return value;878} else {879// User had no unsaved changes, so last undo is version without last saved change.880const tm = state.my_times[state.pointer - 1];881state.pointer -= 2;882if (tm != null) {883state.without.push(tm);884return this.version_without(state.without);885} else {886// no undo information during this session887return value;888}889}890} else {891// pointing at particular timestamp in the past892if (state.pointer >= 0) {893// there is still more to undo894state.without.push(state.my_times[state.pointer]);895state.pointer -= 1;896}897return this.version_without(state.without);898}899}900901private _redo(): Document {902this.assert_is_ready("_redo");903const state = this.undo_state;904if (state == null) {905// nothing to do but return latest live version906return this.get_doc();907}908if (state.pointer === state.my_times.length) {909// pointing at live state -- nothing to do910return this.get_doc();911} else if (state.pointer === state.my_times.length - 1) {912// one back from live state, so apply unsaved patch to live version913const value = this.version();914if (value == null) {915// see remark in undo -- do nothing916return this.get_doc();917}918state.pointer += 1;919return value.apply_patch(state.final);920} else {921// at least two back from live state922state.without.pop();923state.pointer += 1;924if (state.final == null && state.pointer === state.my_times.length - 1) {925// special case when there wasn't any live change926state.pointer += 1;927}928return this.version_without(state.without);929}930}931932in_undo_mode = (): boolean => {933return this.undo_state != null;934};935936exit_undo_mode = (): void => {937this.undo_state = undefined;938};939940private initUndoState = (): UndoState => {941if (this.undo_state != null) {942return this.undo_state;943}944const my_times = keys(this.my_patches).map((x) => parseInt(x));945my_times.sort();946this.undo_state = {947my_times,948pointer: my_times.length,949without: [],950};951return this.undo_state;952};953954private save_to_disk_autosave = async (): Promise<void> => {955if (this.state !== "ready") {956return;957}958const dbg = this.dbg("save_to_disk_autosave");959dbg();960try {961await this.save_to_disk();962} catch (err) {963dbg(`failed -- ${err}`);964}965};966967/* Make it so the local hub project will automatically save968the file to disk periodically. */969private init_file_autosave = async () => {970// Do not autosave sagews until we resolve971// https://github.com/sagemathinc/cocalc/issues/974972// Similarly, do not autosave ipynb because of973// https://github.com/sagemathinc/cocalc/issues/5216974if (975!FILE_SERVER_AUTOSAVE_S ||976!(await this.isFileServer()) ||977this.fileserver_autosave_timer ||978endswith(this.path, ".sagews") ||979endswith(this.path, "." + JUPYTER_SYNCDB_EXTENSIONS)980) {981return;982}983984// Explicit cast due to node vs browser typings.985this.fileserver_autosave_timer = <any>(986setInterval(this.save_to_disk_autosave, FILE_SERVER_AUTOSAVE_S * 1000)987);988};989990// account_id of the user who made the edit at991// the given point in time.992account_id = (time: number): string => {993this.assert_is_ready("account_id");994return this.users[this.user_id(time)];995};996997// Integer index of user who made the edit at given998// point in time.999user_id = (time: number): number => {1000this.assert_table_is_ready("patches");1001assertDefined(this.patch_list);1002return this.patch_list.user_id(time);1003};10041005private syncstring_table_get_one = (): Map<string, any> => {1006if (this.syncstring_table == null) {1007logger.warn("syncstring_table missing", {1008path: this.path,1009state: this.state,1010});1011return Map();1012}1013const t = this.syncstring_table.get_one();1014if (t == null) {1015// project has not initialized it yet.1016return Map();1017}1018return t;1019};10201021/* The project calls set_initialized once it has checked for1022the file on disk; this way the frontend knows that the1023syncstring has been initialized in the database, and also1024if there was an error doing the check.1025*/1026private set_initialized = async (1027error: string,1028read_only: boolean,1029size: number,1030): Promise<void> => {1031this.assert_table_is_ready("syncstring");1032this.dbg("set_initialized")({ error, read_only, size });1033const init = { time: this.client.server_time(), size, error };1034await this.set_syncstring_table({1035init,1036read_only,1037last_active: this.client.server_time(),1038});1039};10401041/* List of logical timestamps of the versions of this string in the sync1042table that we opened to start editing (so starts with what was1043the most recent snapshot when we started). The list of timestamps1044is sorted from oldest to newest. */1045versions = (): number[] => {1046assertDefined(this.patch_list);1047return this.patch_list.versions();1048};10491050wallTime = (version: number): number | undefined => {1051return this.patch_list?.wallTime(version);1052};10531054// newest version of any non-staging known patch on this client,1055// including ones just made that might not be in patch_list yet.1056newestVersion = (): number | undefined => {1057return this.patch_list?.newest_patch_time();1058};10591060hasVersion = (time: number): boolean => {1061assertDefined(this.patch_list);1062return this.patch_list.hasVersion(time);1063};10641065historyFirstVersion = () => {1066this.assert_table_is_ready("patches");1067assertDefined(this.patch_list);1068return this.patch_list.firstVersion();1069};10701071historyLastVersion = () => {1072this.assert_table_is_ready("patches");1073assertDefined(this.patch_list);1074return this.patch_list.lastVersion();1075};10761077historyVersionNumber = (time: number): number | undefined => {1078return this.patch_list?.versionNumber(time);1079};10801081last_changed = (): number => {1082const v = this.versions();1083return v[v.length - 1] ?? 0;1084};10851086private init_table_close_handlers(): void {1087for (const x of ["syncstring", "patches", "cursors"]) {1088const t = this[x + "_table"];1089if (t != null) {1090t.on("close", this.close);1091}1092}1093}10941095// more gentle version -- this can cause the project actions1096// to be *created* etc.1097end = reuseInFlight(async () => {1098if (this.client.is_browser() && this.state == "ready") {1099try {1100await this.save_to_disk();1101} catch (err) {1102// has to be non-fatal since we are closing the document,1103// and of couse we need to clear up everything else.1104// Do nothing here.1105}1106}1107this.close();1108});11091110// Close synchronized editing of this string; this stops listening1111// for changes and stops broadcasting changes.1112close = reuseInFlight(async () => {1113if (this.state == "closed") {1114return;1115}1116const dbg = this.dbg("close");1117dbg("close");11181119SyncDoc.computeServerManagerDoc?.removeListener(1120"change",1121this.handleComputeServerManagerChange,1122);1123//1124// SYNC STUFF1125//11261127// WARNING: that 'closed' is emitted at the beginning of the1128// close function (before anything async) for the project is1129// assumed in src/packages/project/sync/sync-doc.ts, because1130// that ensures that the moment close is called we lock trying1131// try create the syncdoc again until closing is finished.1132// (This set_state call emits "closed"):1133this.set_state("closed");11341135this.emit("close");11361137// must be after the emits above, so clients know1138// what happened and can respond.1139this.removeAllListeners();11401141if (this.throttled_file_use != null) {1142// Cancel any pending file_use calls.1143cancel_scheduled(this.throttled_file_use);1144(this.throttled_file_use as any).cancel();1145}11461147if (this.emit_change != null) {1148// Cancel any pending change emit calls.1149cancel_scheduled(this.emit_change);1150}11511152if (this.fileserver_autosave_timer) {1153clearInterval(this.fileserver_autosave_timer as any);1154this.fileserver_autosave_timer = 0;1155}11561157if (this.read_only_timer) {1158clearInterval(this.read_only_timer as any);1159this.read_only_timer = 0;1160}11611162this.patch_update_queue = [];11631164// Stop watching for file changes. It's important to1165// do this *before* all the await's below, since1166// this syncdoc can't do anything in response to a1167// a file change in its current state.1168this.update_watch_path(); // no input = closes it, if open11691170if (this.patch_list != null) {1171// not async -- just a data structure in memory1172this.patch_list.close();1173}11741175try {1176this.closeTables();1177dbg("closeTables -- successfully saved all data to database");1178} catch (err) {1179dbg(`closeTables -- ERROR -- ${err}`);1180}1181// this avoids memory leaks:1182close(this);11831184// after doing that close, we need to keep the state (which just got deleted) as 'closed'1185this.set_state("closed");1186dbg("close done");1187});11881189private closeTables = async () => {1190this.syncstring_table?.close();1191this.patches_table?.close();1192this.cursors_table?.close();1193this.evaluator?.close();1194this.ipywidgets_state?.close();1195};11961197// TODO: We **have** to do this on the client, since the backend1198// **security model** for accessing the patches table only1199// knows the string_id, but not the project_id/path. Thus1200// there is no way currently to know whether or not the client1201// has access to the patches, and hence the patches table1202// query fails. This costs significant time -- a roundtrip1203// and write to the database -- whenever the user opens a file.1204// This fix should be to change the patches schema somehow1205// to have the user also provide the project_id and path, thus1206// proving they have access to the sha1 hash (string_id), but1207// don't actually use the project_id and path as columns in1208// the table. This requires some new idea I guess of virtual1209// fields....1210// Also, this also establishes the correct doctype.12111212// Since this MUST succeed before doing anything else. This is critical1213// because the patches table can't be opened anywhere if the syncstring1214// object doesn't exist, due to how our security works, *AND* that the1215// patches table uses the string_id, which is a SHA1 hash.1216private ensure_syncstring_exists_in_db = async (): Promise<void> => {1217const dbg = this.dbg("ensure_syncstring_exists_in_db");1218if (this.useConat) {1219dbg("skipping -- no database");1220return;1221}12221223if (!this.client.is_connected()) {1224dbg("wait until connected...", this.client.is_connected());1225await once(this.client, "connected");1226}12271228if (this.client.is_browser() && !this.client.is_signed_in()) {1229// the browser has to sign in, unlike the project (and compute servers)1230await once(this.client, "signed_in");1231}12321233if (this.state == ("closed" as State)) return;12341235dbg("do syncstring write query...");12361237await callback2(this.client.query, {1238query: {1239syncstrings: {1240string_id: this.string_id,1241project_id: this.project_id,1242path: this.path,1243doctype: JSON.stringify(this.doctype),1244},1245},1246});1247dbg("wrote syncstring to db - done.");1248};12491250private synctable = async (1251query,1252options: any[],1253throttle_changes?: undefined | number,1254): Promise<SyncTable> => {1255this.assert_not_closed("synctable");1256const dbg = this.dbg("synctable");1257if (!this.useConat && !this.ephemeral && this.persistent) {1258// persistent table in a non-ephemeral syncdoc, so ensure that table is1259// persisted to database (not just in memory).1260options = options.concat([{ persistent: true }]);1261}1262if (this.ephemeral) {1263options.push({ ephemeral: true });1264}1265let synctable;1266let ephemeral = false;1267for (const x of options) {1268if (x.ephemeral) {1269ephemeral = true;1270break;1271}1272}1273if (this.useConat && query.patches) {1274synctable = await this.client.synctable_conat(query, {1275obj: {1276project_id: this.project_id,1277path: this.path,1278},1279stream: true,1280atomic: true,1281desc: { path: this.path },1282start_seq: this.last_seq,1283ephemeral,1284});12851286if (this.last_seq) {1287// any possibility last_seq is wrong?1288if (!isCompletePatchStream(synctable.dstream)) {1289// we load everything and fix it. This happened1290// for data moving to conat when the seq numbers changed.1291console.log("updating invalid timetravel -- ", this.path);12921293synctable.close();1294synctable = await this.client.synctable_conat(query, {1295obj: {1296project_id: this.project_id,1297path: this.path,1298},1299stream: true,1300atomic: true,1301desc: { path: this.path },1302ephemeral,1303});13041305// also find the correct last_seq:1306let n = synctable.dstream.length - 1;1307for (; n >= 0; n--) {1308const x = synctable.dstream[n];1309if (x?.is_snapshot) {1310const time = x.time;1311// find the seq number with time1312let m = n - 1;1313let last_seq = 0;1314while (m >= 1) {1315if (synctable.dstream[m].time == time) {1316last_seq = synctable.dstream.seq(m);1317break;1318}1319m -= 1;1320}1321this.last_seq = last_seq;1322await this.set_syncstring_table({1323last_snapshot: time,1324last_seq,1325});1326this.setLastSnapshot(time);1327break;1328}1329}1330if (n == -1) {1331// no snapshot? should never happen, but just in case.1332delete this.last_seq;1333await this.set_syncstring_table({1334last_seq: undefined,1335});1336}1337}1338}1339} else if (this.useConat && query.syncstrings) {1340synctable = await this.client.synctable_conat(query, {1341obj: {1342project_id: this.project_id,1343path: this.path,1344},1345stream: false,1346atomic: false,1347immutable: true,1348desc: { path: this.path },1349ephemeral,1350});1351} else if (this.useConat && query.ipywidgets) {1352synctable = await this.client.synctable_conat(query, {1353obj: {1354project_id: this.project_id,1355path: this.path,1356},1357stream: false,1358atomic: true,1359immutable: true,1360// for now just putting a 1-day limit on the ipywidgets table1361// so we don't waste a ton of space.1362config: { max_age: 1000 * 60 * 60 * 24 },1363desc: { path: this.path },1364ephemeral: true, // ipywidgets state always ephemeral1365});1366} else if (this.useConat && (query.eval_inputs || query.eval_outputs)) {1367synctable = await this.client.synctable_conat(query, {1368obj: {1369project_id: this.project_id,1370path: this.path,1371},1372stream: false,1373atomic: true,1374immutable: true,1375config: { max_age: 5 * 60 * 1000 },1376desc: { path: this.path },1377ephemeral: true, // eval state (for sagews) is always ephemeral1378});1379} else if (this.useConat) {1380synctable = await this.client.synctable_conat(query, {1381obj: {1382project_id: this.project_id,1383path: this.path,1384},1385stream: false,1386atomic: true,1387immutable: true,1388desc: { path: this.path },1389ephemeral,1390});1391} else {1392// only used for unit tests and the ephemeral messaging composer1393if (this.client.synctable_ephemeral == null) {1394throw Error(`client does not support sync properly`);1395}1396synctable = await this.client.synctable_ephemeral(1397this.project_id,1398query,1399options,1400throttle_changes,1401);1402}1403// We listen and log error events. This is useful because in some settings, e.g.,1404// in the project, an eventemitter with no listener for errors, which has an error,1405// will crash the entire process.1406synctable.on("error", (error) => dbg("ERROR", error));1407return synctable;1408};14091410private init_syncstring_table = async (): Promise<void> => {1411const query = {1412syncstrings: [1413{1414string_id: this.string_id,1415project_id: this.project_id,1416path: this.path,1417users: null,1418last_snapshot: null,1419last_seq: null,1420snapshot_interval: null,1421save: null,1422last_active: null,1423init: null,1424read_only: null,1425last_file_change: null,1426doctype: null,1427archived: null,1428settings: null,1429},1430],1431};1432const dbg = this.dbg("init_syncstring_table");14331434dbg("getting table...");1435this.syncstring_table = await this.synctable(query, []);1436if (this.ephemeral && this.client.is_project()) {1437await this.set_syncstring_table({1438doctype: JSON.stringify(this.doctype),1439});1440} else {1441dbg("handling the first update...");1442this.handle_syncstring_update();1443}1444this.syncstring_table.on("change", this.handle_syncstring_update);1445};14461447// Used for internal debug logging1448private dbg = (_f: string = ""): Function => {1449if (DEBUG) {1450return (...args) => {1451logger.debug(this.path, _f, ...args);1452};1453} else {1454return (..._args) => {};1455}1456};14571458private initAll = async (): Promise<void> => {1459if (this.state !== "init") {1460throw Error("connect can only be called in init state");1461}1462const log = this.dbg("initAll");14631464log("update interest");1465this.initInterestLoop();14661467log("ensure syncstring exists");1468this.assert_not_closed("initAll -- before ensuring syncstring exists");1469await this.ensure_syncstring_exists_in_db();14701471await this.init_syncstring_table();1472this.assert_not_closed("initAll -- successful init_syncstring_table");14731474log("patch_list, cursors, evaluator, ipywidgets");1475this.assert_not_closed(1476"initAll -- before init patch_list, cursors, evaluator, ipywidgets",1477);1478if (PARALLEL_INIT) {1479await Promise.all([1480this.init_patch_list(),1481this.init_cursors(),1482this.init_evaluator(),1483this.init_ipywidgets(),1484]);1485this.assert_not_closed(1486"initAll -- successful init patch_list, cursors, evaluator, and ipywidgets",1487);1488} else {1489await this.init_patch_list();1490this.assert_not_closed("initAll -- successful init_patch_list");1491await this.init_cursors();1492this.assert_not_closed("initAll -- successful init_patch_cursors");1493await this.init_evaluator();1494this.assert_not_closed("initAll -- successful init_evaluator");1495await this.init_ipywidgets();1496this.assert_not_closed("initAll -- successful init_ipywidgets");1497}14981499this.init_table_close_handlers();1500this.assert_not_closed("initAll -- successful init_table_close_handlers");15011502log("file_use_interval");1503this.init_file_use_interval();15041505if (await this.isFileServer()) {1506log("load_from_disk");1507// This sets initialized, which is needed to be fully ready.1508// We keep trying this load from disk until sync-doc is closed1509// or it succeeds. It may fail if, e.g., the file is too1510// large or is not readable by the user. They are informed to1511// fix the problem... and once they do (and wait up to 10s),1512// this will finish.1513// if (!this.client.is_browser() && !this.client.is_project()) {1514// // FAKE DELAY!!! Just to simulate flakiness / slow network!!!!1515// await delay(3000);1516// }1517await retry_until_success({1518f: this.init_load_from_disk,1519max_delay: 10000,1520desc: "syncdoc -- load_from_disk",1521});1522log("done loading from disk");1523} else {1524if (this.patch_list!.count() == 0) {1525await Promise.race([1526this.waitUntilFullyReady(),1527once(this.patch_list!, "change"),1528]);1529}1530}1531this.assert_not_closed("initAll -- load from disk");1532this.emit("init");15331534this.assert_not_closed("initAll -- after waiting until fully ready");15351536if (await this.isFileServer()) {1537log("init file autosave");1538this.init_file_autosave();1539}1540this.update_has_unsaved_changes();1541log("done");1542};15431544private init_error = (): string | undefined => {1545let x;1546try {1547x = this.syncstring_table.get_one();1548} catch (_err) {1549// if the table hasn't been initialized yet,1550// it can't be in error state.1551return undefined;1552}1553return x?.get("init")?.get("error");1554};15551556// wait until the syncstring table is ready to be1557// used (so extracted from archive, etc.),1558private waitUntilFullyReady = async (): Promise<void> => {1559this.assert_not_closed("wait_until_fully_ready");1560const dbg = this.dbg("wait_until_fully_ready");1561dbg();15621563if (this.client.is_browser() && this.init_error()) {1564// init is set and is in error state. Give the backend a few seconds1565// to try to fix this error before giving up. The browser client1566// can close and open the file to retry this (as instructed).1567try {1568await this.syncstring_table.wait(() => !this.init_error(), 5);1569} catch (err) {1570// fine -- let the code below deal with this problem...1571}1572}15731574let init;1575const is_init = (t: SyncTable) => {1576this.assert_not_closed("is_init");1577const tbl = t.get_one();1578if (tbl == null) {1579dbg("null");1580return false;1581}1582init = tbl.get("init")?.toJS();1583return init != null;1584};1585dbg("waiting for init...");1586await this.syncstring_table.wait(is_init, 0);1587dbg("init done");1588if (init.error) {1589throw Error(init.error);1590}1591assertDefined(this.patch_list);1592if (init.size == null) {1593// don't crash but warn at least.1594console.warn("SYNC BUG -- init.size must be defined", { init });1595}1596if (1597!this.client.is_project() &&1598this.patch_list.count() === 0 &&1599init.size1600) {1601dbg("waiting for patches for nontrivial file");1602// normally this only happens in a later event loop,1603// so force it now.1604dbg("handling patch update queue since", this.patch_list.count());1605await this.handle_patch_update_queue();1606assertDefined(this.patch_list);1607dbg("done handling, now ", this.patch_list.count());1608if (this.patch_list.count() === 0) {1609// wait for a change -- i.e., project loading the file from1610// disk and making available... Because init.size > 0, we know that1611// there must be SOMETHING in the patches table once initialization is done.1612// This is the root cause of https://github.com/sagemathinc/cocalc/issues/23821613await once(this.patches_table, "change");1614dbg("got patches_table change");1615await this.handle_patch_update_queue();1616dbg("handled update queue");1617}1618}1619};16201621private assert_table_is_ready = (table: string): void => {1622const t = this[table + "_table"]; // not using string template only because it breaks codemirror!1623if (t == null || t.get_state() != "connected") {1624throw Error(1625`Table ${table} must be connected. string_id=${this.string_id}`,1626);1627}1628};16291630assert_is_ready = (desc: string): void => {1631if (this.state != "ready") {1632throw Error(`must be ready -- ${desc}`);1633}1634};16351636wait_until_ready = async (): Promise<void> => {1637this.assert_not_closed("wait_until_ready");1638if (this.state !== ("ready" as State)) {1639// wait for a state change to ready.1640await once(this, "ready");1641}1642};16431644/* Calls wait for the corresponding patches SyncTable, if1645it has been defined. If it hasn't been defined, it waits1646until it is defined, then calls wait. Timeout only starts1647when patches_table is already initialized.1648*/1649wait = async (until: Function, timeout: number = 30): Promise<any> => {1650await this.wait_until_ready();1651//console.trace("SYNC WAIT -- start...");1652const result = await wait({1653obj: this,1654until,1655timeout,1656change_event: "change",1657});1658//console.trace("SYNC WAIT -- got it!");1659return result;1660};16611662/* Delete the synchronized string and **all** patches from the database1663-- basically delete the complete history of editing this file.1664WARNINGS:1665(1) If a project has this string open, then things may be messed1666up, unless that project is restarted.1667(2) Only available for an **admin** user right now!16681669To use: from a javascript console in the browser as admin, do:16701671await smc.client.sync_string({1672project_id:'9f2e5869-54b8-4890-8828-9aeba9a64af4',1673path:'a.txt'}).delete_from_database()16741675Then make sure project and clients refresh.16761677WORRY: Race condition where constructor might write stuff as1678it is being deleted?1679*/1680delete_from_database = async (): Promise<void> => {1681const queries: object[] = this.ephemeral1682? []1683: [1684{1685patches_delete: {1686id: [this.string_id],1687dummy: null,1688},1689},1690];1691queries.push({1692syncstrings_delete: {1693project_id: this.project_id,1694path: this.path,1695},1696});16971698const v: Promise<any>[] = [];1699for (let i = 0; i < queries.length; i++) {1700v.push(callback2(this.client.query, { query: queries[i] }));1701}1702await Promise.all(v);1703};17041705private pathExistsAndIsReadOnly = async (path): Promise<boolean> => {1706try {1707await callback2(this.client.path_access, {1708path,1709mode: "w",1710});1711// clearly exists and is NOT read only:1712return false;1713} catch (err) {1714// either it doesn't exist or it is read only1715if (await callback2(this.client.path_exists, { path })) {1716// it exists, so is read only and exists1717return true;1718}1719// doesn't exist1720return false;1721}1722};17231724private file_is_read_only = async (): Promise<boolean> => {1725if (await this.pathExistsAndIsReadOnly(this.path)) {1726return true;1727}1728const path = this.getFileServerPath();1729if (path != this.path) {1730if (await this.pathExistsAndIsReadOnly(path)) {1731return true;1732}1733}1734return false;1735};17361737private update_if_file_is_read_only = async (): Promise<void> => {1738const read_only = await this.file_is_read_only();1739if (this.state == "closed") {1740return;1741}1742this.set_read_only(read_only);1743};17441745private init_load_from_disk = async (): Promise<void> => {1746if (this.state == "closed") {1747// stop trying, no error -- this is assumed1748// in a retry_until_success elsewhere.1749return;1750}1751if (await this.load_from_disk_if_newer()) {1752throw Error("failed to load from disk");1753}1754};17551756private load_from_disk_if_newer = async (): Promise<boolean> => {1757const last_changed = new Date(this.last_changed());1758const firstLoad = this.versions().length == 0;1759const dbg = this.dbg("load_from_disk_if_newer");1760let is_read_only: boolean = false;1761let size: number = 0;1762let error: string = "";1763try {1764dbg("check if path exists");1765if (await callback2(this.client.path_exists, { path: this.path })) {1766// the path exists1767dbg("path exists -- stat file");1768const stats = await callback2(this.client.path_stat, {1769path: this.path,1770});1771if (firstLoad || stats.ctime > last_changed) {1772dbg(1773`disk file changed more recently than edits (or first load), so loading, ${stats.ctime} > ${last_changed}; firstLoad=${firstLoad}`,1774);1775size = await this.load_from_disk();1776if (firstLoad) {1777dbg("emitting first-load event");1778// this event is emited the first time the document is ever loaded from disk.1779this.emit("first-load");1780}1781dbg("loaded");1782} else {1783dbg("stick with database version");1784}1785dbg("checking if read only");1786is_read_only = await this.file_is_read_only();1787dbg("read_only", is_read_only);1788}1789} catch (err) {1790error = `${err}`;1791}17921793await this.set_initialized(error, is_read_only, size);1794dbg("done");1795return !!error;1796};17971798private patch_table_query = (cutoff?: number) => {1799const query = {1800string_id: this.string_id,1801is_snapshot: false, // only used with conat1802time: cutoff ? { ">=": cutoff } : null,1803wall: null,1804// compressed format patch as a JSON *string*1805patch: null,1806// integer id of user (maps to syncstring table)1807user_id: null,1808// (optional) a snapshot at this point in time1809snapshot: null,1810// info about sequence number, count, etc. of this snapshot1811seq_info: null,1812parents: null,1813version: null,1814};1815if (this.doctype.patch_format != null) {1816(query as any).format = this.doctype.patch_format;1817}1818return query;1819};18201821private setLastSnapshot(last_snapshot?: number) {1822// only set last_snapshot here, so we can keep it in sync with patch_list.last_snapshot1823// and also be certain about the data type (being number or undefined).1824if (last_snapshot !== undefined && typeof last_snapshot != "number") {1825throw Error("type of last_snapshot must be number or undefined");1826}1827this.last_snapshot = last_snapshot;1828}18291830private init_patch_list = async (): Promise<void> => {1831this.assert_not_closed("init_patch_list - start");1832const dbg = this.dbg("init_patch_list");1833dbg();18341835// CRITICAL: note that handle_syncstring_update checks whether1836// init_patch_list is done by testing whether this.patch_list is defined!1837// That is why we first define "patch_list" below, then set this.patch_list1838// to it only after we're done.1839delete this.patch_list;18401841const patch_list = new SortedPatchList({1842from_str: this._from_str,1843});18441845dbg("opening the table...");1846const query = { patches: [this.patch_table_query(this.last_snapshot)] };1847this.patches_table = await this.synctable(query, [], this.patch_interval);1848this.assert_not_closed("init_patch_list -- after making synctable");18491850const update_has_unsaved_changes = debounce(1851this.update_has_unsaved_changes,1852500,1853{ leading: true, trailing: true },1854);18551856this.patches_table.on("has-uncommitted-changes", (val) => {1857this.emit("has-uncommitted-changes", val);1858});18591860this.on("change", () => {1861update_has_unsaved_changes();1862});18631864this.syncstring_table.on("change", () => {1865update_has_unsaved_changes();1866});18671868dbg("adding all known patches");1869patch_list.add(this.get_patches());18701871dbg("possibly kick off loading more history");1872let last_start_seq: null | number = null;1873while (patch_list.needsMoreHistory()) {1874// @ts-ignore1875const dstream = this.patches_table.dstream;1876if (dstream == null) {1877break;1878}1879const snap = patch_list.getOldestSnapshot();1880if (snap == null) {1881break;1882}1883const seq_info = snap.seq_info ?? {1884prev_seq: 1,1885};1886const start_seq = seq_info.prev_seq ?? 1;1887if (last_start_seq != null && start_seq >= last_start_seq) {1888// no progress, e.g., corruption would cause this.1889// "corruption" is EXPECTED, since a user might be submitting1890// patches after being offline, and get disconnected halfway through.1891break;1892}1893last_start_seq = start_seq;1894await dstream.load({ start_seq });1895dbg("load more history");1896patch_list.add(this.get_patches());1897if (start_seq <= 1) {1898// loaded everything1899break;1900}1901}19021903//this.patches_table.on("saved", this.handle_offline);1904this.patch_list = patch_list;19051906let doc;1907try {1908doc = patch_list.value();1909} catch (err) {1910console.warn("error getting doc", err);1911doc = this._from_str("");1912}1913this.last = this.doc = doc;1914this.patches_table.on("change", this.handle_patch_update);19151916dbg("done");1917};19181919private init_evaluator = async () => {1920const dbg = this.dbg("init_evaluator");1921const ext = filename_extension(this.path);1922if (ext !== "sagews") {1923dbg("done -- only use init_evaluator for sagews");1924return;1925}1926dbg("creating the evaluator and waiting for init");1927this.evaluator = new Evaluator(this, this.client, this.synctable);1928await this.evaluator.init();1929dbg("done");1930};19311932private init_ipywidgets = async () => {1933const dbg = this.dbg("init_evaluator");1934const ext = filename_extension(this.path);1935if (ext != JUPYTER_SYNCDB_EXTENSIONS) {1936dbg("done -- only use ipywidgets for jupyter");1937return;1938}1939dbg("creating the ipywidgets state table, and waiting for init");1940this.ipywidgets_state = new IpywidgetsState(1941this,1942this.client,1943this.synctable,1944);1945await this.ipywidgets_state.init();1946dbg("done");1947};19481949private init_cursors = async () => {1950const dbg = this.dbg("init_cursors");1951if (!this.cursors) {1952dbg("done -- do not care about cursors for this syncdoc.");1953return;1954}1955if (this.useConat) {1956dbg("cursors broadcast using pub/sub");1957this.cursors_table = await this.client.pubsub_conat({1958project_id: this.project_id,1959path: this.path,1960name: "cursors",1961});1962this.cursors_table.on(1963"change",1964(obj: { user_id: number; locs: any; time: number }) => {1965const account_id = this.users[obj.user_id];1966if (!account_id) {1967return;1968}1969if (obj.locs == null && !this.cursor_map.has(account_id)) {1970// gone, and already gone.1971return;1972}1973if (obj.locs != null) {1974// changed1975this.cursor_map = this.cursor_map.set(account_id, fromJS(obj));1976} else {1977// deleted1978this.cursor_map = this.cursor_map.delete(account_id);1979}1980this.emit("cursor_activity", account_id);1981},1982);1983return;1984}19851986dbg("getting cursors ephemeral table");1987const query = {1988cursors: [1989{1990string_id: this.string_id,1991user_id: null,1992locs: null,1993time: null,1994},1995],1996};1997// We make cursors an ephemeral table, since there is no1998// need to persist it to the database, obviously!1999// Also, queue_size:1 makes it so only the last cursor position is2000// saved, e.g., in case of disconnect and reconnect.2001const options = [{ ephemeral: true }, { queue_size: 1 }]; // probably deprecated2002this.cursors_table = await this.synctable(query, options, 1000);2003this.assert_not_closed("init_cursors -- after making synctable");20042005// cursors now initialized; first initialize the2006// local this._cursor_map, which tracks positions2007// of cursors by account_id:2008dbg("loading initial state");2009const s = this.cursors_table.get();2010if (s == null) {2011throw Error("bug -- get should not return null once table initialized");2012}2013s.forEach((locs: any, k: string) => {2014if (locs == null) {2015return;2016}2017const u = JSON.parse(k);2018if (u != null) {2019this.cursor_map = this.cursor_map.set(this.users[u[1]], locs);2020}2021});2022this.cursors_table.on("change", this.handle_cursors_change);20232024dbg("done");2025};20262027private handle_cursors_change = (keys) => {2028if (this.state === "closed") {2029return;2030}2031for (const k of keys) {2032const u = JSON.parse(k);2033if (u == null) {2034continue;2035}2036const account_id = this.users[u[1]];2037if (!account_id) {2038// this happens for ephemeral table when project restarts and browser2039// has data it is trying to send.2040continue;2041}2042const locs = this.cursors_table.get(k);2043if (locs == null && !this.cursor_map.has(account_id)) {2044// gone, and already gone.2045continue;2046}2047if (locs != null) {2048// changed2049this.cursor_map = this.cursor_map.set(account_id, locs);2050} else {2051// deleted2052this.cursor_map = this.cursor_map.delete(account_id);2053}2054this.emit("cursor_activity", account_id);2055}2056};20572058/* Returns *immutable* Map from account_id to list2059of cursor positions, if cursors are enabled.20602061- excludeSelf: do not include our own cursor2062- maxAge: only include cursors that have been updated with maxAge ms from now.2063*/2064get_cursors = ({2065maxAge = 60 * 1000,2066// excludeSelf:2067// 'always' -- *always* exclude self2068// 'never' -- never exclude self2069// 'heuristic' -- exclude self is older than last set from here, e.g., useful on2070// frontend so we don't see our own cursor unless more than one browser.2071excludeSelf = "always",2072}: {2073maxAge?: number;2074excludeSelf?: "always" | "never" | "heuristic";2075} = {}): CursorMap => {2076this.assert_not_closed("get_cursors");2077if (!this.cursors) {2078throw Error("cursors are not enabled");2079}2080if (this.cursors_table == null) {2081return Map(); // not loaded yet -- so no info yet.2082}2083const account_id: string = this.client_id();2084let map = this.cursor_map;2085if (map.has(account_id) && excludeSelf != "never") {2086if (2087excludeSelf == "always" ||2088(excludeSelf == "heuristic" &&2089this.cursor_last_time >=2090new Date(map.getIn([account_id, "time"], 0) as number))2091) {2092map = map.delete(account_id);2093}2094}2095// Remove any old cursors, where "old" is by default more than maxAge old.2096const now = Date.now();2097for (const [client_id, value] of map as any) {2098const time = value.get("time");2099if (time == null) {2100// this should always be set.2101map = map.delete(client_id);2102continue;2103}2104if (maxAge) {2105// we use abs to implicitly exclude a bad value that is somehow in the future,2106// if that were to happen.2107if (Math.abs(now - time.valueOf()) >= maxAge) {2108map = map.delete(client_id);2109continue;2110}2111}2112if (time >= now + 10 * 1000) {2113// We *always* delete any cursors more than 10 seconds in the future, since2114// that can only happen if a client inserts invalid data (e.g., clock not2115// yet synchronized). See https://github.com/sagemathinc/cocalc/issues/79692116map = map.delete(client_id);2117continue;2118}2119}2120return map;2121};21222123/* Set settings map. Used for custom configuration just for2124this one file, e.g., overloading the spell checker language.2125*/2126set_settings = async (obj): Promise<void> => {2127this.assert_is_ready("set_settings");2128await this.set_syncstring_table({2129settings: obj,2130});2131};21322133client_id = () => {2134return this.client.client_id();2135};21362137// get settings object2138get_settings = (): Map<string, any> => {2139this.assert_is_ready("get_settings");2140return this.syncstring_table_get_one().get("settings", Map());2141};21422143/*2144Commits and saves current live syncdoc to backend.21452146Function only returns when there is nothing needing2147saving.21482149Save any changes we have as a new patch.2150*/2151save = reuseInFlight(async () => {2152const dbg = this.dbg("save");2153dbg();2154// We just keep trying while syncdoc is ready and there2155// are changes that have not been saved (due to this.doc2156// changing during the while loop!).2157if (this.doc == null || this.last == null || this.state == "closed") {2158// EXPECTED: this happens after document is closed2159// There's nothing to do regarding save if the table is2160// already closed. Note that we *do* have to save when2161// the table is init stage, since the project has to2162// record the newly opened version of the file to the2163// database! See2164// https://github.com/sagemathinc/cocalc/issues/49862165return;2166}2167if (this.client?.is_deleted(this.path, this.project_id)) {2168dbg("not saving because deleted");2169return;2170}2171// Compute any patches.2172while (!this.doc.is_equal(this.last)) {2173dbg("something to save");2174this.emit("user-change");2175const doc = this.doc;2176// TODO: put in a delay if just saved too recently?2177// Or maybe won't matter since not using database?2178if (this.handle_patch_update_queue_running) {2179dbg("wait until the update queue is done");2180await once(this, "handle_patch_update_queue_done");2181// but wait until next loop (so as to check that needed2182// and state still ready).2183continue;2184}2185dbg("Compute new patch.");2186this.sync_remote_and_doc(false);2187// Emit event since this syncstring was2188// changed locally (or we wouldn't have had2189// to save at all).2190if (doc.is_equal(this.doc)) {2191dbg("no change during loop -- done!");2192break;2193}2194}2195if (this.state != "ready") {2196// above async waits could have resulted in state change.2197return;2198}2199await this.handle_patch_update_queue();2200if (this.state != "ready") {2201return;2202}22032204// Ensure all patches are saved to backend.2205// We do this after the above, so that creating the newest patch2206// happens immediately on save, which makes it possible for clients2207// to save current state without having to wait on an async, which is2208// useful to ensure specific undo points (e.g., right before a paste).2209await this.patches_table.save();2210});22112212private timeOfLastCommit: number | undefined = undefined;2213private next_patch_time = (): number => {2214let time = this.client.server_time().valueOf();2215if (time == this.timeOfLastCommit) {2216time = this.timeOfLastCommit + 1;2217}2218assertDefined(this.patch_list);2219time = this.patch_list.next_available_time(2220time,2221this.my_user_id,2222this.users.length,2223);2224return time;2225};22262227private commit_patch = (time: number, patch: XPatch): void => {2228this.timeOfLastCommit = time;2229this.assert_not_closed("commit_patch");2230assertDefined(this.patch_list);2231const obj: any = {2232// version for database2233string_id: this.string_id,2234// logical time -- usually the sync'd walltime, but2235// guaranteed to be increasing.2236time,2237// what we show user2238wall: this.client.server_time().valueOf(),2239patch: JSON.stringify(patch),2240user_id: this.my_user_id,2241is_snapshot: false,2242parents: this.patch_list.getHeads(),2243version: this.patch_list.lastVersion() + 1,2244};22452246this.my_patches[time.valueOf()] = obj;22472248if (this.doctype.patch_format != null) {2249obj.format = this.doctype.patch_format;2250}22512252// If in undo mode put the just-created patch in our2253// without timestamp list, so it won't be included2254// when doing undo/redo.2255if (this.undo_state != null) {2256this.undo_state.without.unshift(time);2257}22582259//console.log 'saving patch with time ', time.valueOf()2260let x = this.patches_table.set(obj, "none");2261if (x == null) {2262// TODO: just for NATS right now!2263x = fromJS(obj);2264}2265const y = this.processPatch({ x, patch, size: obj.patch.size });2266this.patch_list.add([y]);2267// Since *we* just made a definite change to the document, we're2268// active, so we check if we should make a snapshot. There is the2269// potential of a race condition where more than one clients make2270// a snapshot at the same time -- this would waste a little space2271// in the stream, but is otherwise harmless, since the snapshots2272// are identical.2273this.snapshotIfNecessary();2274};22752276private dstream = () => {2277// @ts-ignore -- in general patches_table might not be a conat one still,2278// or at least dstream is an internal implementation detail.2279const { dstream } = this.patches_table ?? {};2280if (dstream == null) {2281throw Error("dstream must be defined");2282}2283return dstream;2284};22852286// return the conat-assigned sequence number of the oldest entry in the2287// patch list with the given time, and also:2288// - prev_seq -- the sequence number of previous patch before that, for use in "load more"2289// - index -- the global index of the entry with the given time.2290private conatSnapshotSeqInfo = (2291time: number,2292): { seq: number; prev_seq?: number } => {2293const dstream = this.dstream();2294// seq = actual sequence number of the message with the patch that we're2295// snapshotting at -- i.e., at time2296let seq: number | undefined = undefined;2297// prev_seq = sequence number of patch of *previous* snapshot, if there is a previous one.2298// This is needed for incremental loading of more history.2299let prev_seq: number | undefined;2300let i = 0;2301for (const mesg of dstream.getAll()) {2302if (mesg.is_snapshot && mesg.time < time) {2303// the seq field of this message has the actual sequence number of the patch2304// that was snapshotted, along with the index of that patch.2305prev_seq = mesg.seq_info.seq;2306}2307if (seq === undefined && mesg.time == time) {2308seq = dstream.seq(i);2309}2310i += 1;2311}2312if (seq == null) {2313throw Error(2314`unable to find message with time '${time}'=${new Date(time)}`,2315);2316}2317return { seq, prev_seq };2318};23192320/* Create and store in the database a snapshot of the state2321of the string at the given point in time. This should2322be the time of an existing patch.23232324The point of a snapshot is that if you load all patches recorded2325>= this point in time, then you don't need any earlier ones to2326reconstruct the document, since otherwise, why have the snapshot at2327all, as it does not good. Due to potentially long offline users2328putting old data into history, this can fail. However, in the usual2329case we should never record a snapshot with this bad property.2330*/2331private snapshot = reuseInFlight(async (time: number): Promise<void> => {2332assertDefined(this.patch_list);2333const x = this.patch_list.patch(time);2334if (x == null) {2335throw Error(`no patch at time ${time}`);2336}2337if (x.snapshot != null) {2338// there is already a snapshot at this point in time,2339// so nothing further to do.2340return;2341}23422343const snapshot: string = this.patch_list.value({ time }).to_str();2344// save the snapshot itself in the patches table.2345const seq_info = this.conatSnapshotSeqInfo(time);2346const obj = {2347size: snapshot.length,2348string_id: this.string_id,2349time,2350wall: time,2351is_snapshot: true,2352snapshot,2353user_id: x.user_id,2354seq_info,2355};2356// also set snapshot in the this.patch_list, which which saves a little time.2357// and ensures that "(x.snapshot != null)" above works if snapshot is called again.2358this.patch_list.add([obj]);2359this.patches_table.set(obj);2360await this.patches_table.save();2361if (this.state != "ready") {2362return;2363}23642365const last_seq = seq_info.seq;2366await this.set_syncstring_table({2367last_snapshot: time,2368last_seq,2369});2370this.setLastSnapshot(time);2371this.last_seq = last_seq;2372});23732374// Have a snapshot every this.snapshot_interval patches, except2375// for the very last interval. Throttle so we don't try to make2376// snapshots too frequently, as making them is always optional and2377// now part of the UI.2378private snapshotIfNecessary = throttle(async (): Promise<void> => {2379if (this.get_state() !== "ready") {2380// especially important due to throttle2381return;2382}2383const dbg = this.dbg("snapshotIfNecessary");2384const max_size = Math.floor(1.2 * MAX_FILE_SIZE_MB * 1000000);2385const interval = this.snapshot_interval;2386dbg("check if we need to make a snapshot:", { interval, max_size });2387assertDefined(this.patch_list);2388const time = this.patch_list.time_of_unmade_periodic_snapshot(2389interval,2390max_size,2391);2392if (time != null) {2393dbg("yes, try to make a snapshot at time", time);2394try {2395await this.snapshot(time);2396} catch (err) {2397// this is expected to happen sometimes, e.g., when sufficient information2398// isn't known about the stream of patches.2399console.log(2400`(expected) WARNING: client temporarily unable to make a snapshot of ${this.path} -- ${err}`,2401);2402}2403} else {2404dbg("no need to make a snapshot yet");2405}2406}, 60000);24072408/*- x - patch object2409- patch: if given will be used as an actual patch2410instead of x.patch, which is a JSON string.2411*/2412private processPatch = ({2413x,2414patch,2415size: size0,2416}: {2417x: Map<string, any>;2418patch?: any;2419size?: number;2420}): Patch => {2421let t = x.get("time");2422if (typeof t != "number") {2423// backwards compat2424t = new Date(t).valueOf();2425}2426const time: number = t;2427const wall = x.get("wall") ?? time;2428const user_id: number = x.get("user_id");2429let parents: number[] = x.get("parents")?.toJS() ?? [];2430let size: number;2431const is_snapshot = x.get("is_snapshot");2432if (is_snapshot) {2433size = x.get("snapshot")?.length ?? 0;2434} else {2435if (patch == null) {2436/* Do **NOT** use misc.from_json, since we definitely2437do not want to unpack ISO timestamps as Date,2438since patch just contains the raw patches from2439user editing. This was done for a while, which2440led to horrific bugs in some edge cases...2441See https://github.com/sagemathinc/cocalc/issues/17712442*/2443if (x.has("patch")) {2444const p: string = x.get("patch");2445patch = JSON.parse(p);2446size = p.length;2447} else {2448patch = [];2449size = 2;2450}2451} else {2452const p = x.get("patch");2453size = p?.length ?? size0 ?? JSON.stringify(patch).length;2454}2455}24562457const obj: Patch = {2458time,2459wall,2460user_id,2461patch,2462size,2463is_snapshot,2464parents,2465version: x.get("version"),2466};2467if (is_snapshot) {2468obj.snapshot = x.get("snapshot"); // this is a string2469obj.seq_info = x.get("seq_info")?.toJS();2470if (obj.snapshot == null || obj.seq_info == null) {2471console.warn("WARNING: message = ", x.toJS());2472throw Error(2473`message with is_snapshot true must also set snapshot and seq_info fields -- time=${time}`,2474);2475}2476}2477return obj;2478};24792480/* Return all patches with time such that2481time0 <= time <= time1;2482If time0 undefined then sets time0 equal to time of last_snapshot.2483If time1 undefined treated as +oo.2484*/2485private get_patches = (): Patch[] => {2486this.assert_table_is_ready("patches");24872488// m below is an immutable map with keys the string that2489// is the JSON version of the primary key2490// [string_id, timestamp, user_number].2491let m: Map<string, any> | undefined = this.patches_table.get();2492if (m == null) {2493// won't happen because of assert above.2494throw Error("patches_table must be initialized");2495}2496if (!Map.isMap(m)) {2497// TODO: this is just for proof of concept NATS!!2498m = fromJS(m);2499}2500const v: Patch[] = [];2501m.forEach((x, _) => {2502const p = this.processPatch({ x });2503if (p != null) {2504return v.push(p);2505}2506});2507v.sort(patch_cmp);2508return v;2509};25102511hasFullHistory = (): boolean => {2512if (this.patch_list == null) {2513return false;2514}2515return this.patch_list.hasFullHistory();2516};25172518// returns true if there may be additional history to load2519// after loading this. return false if definitely done.2520loadMoreHistory = async ({2521all,2522}: {2523// if true, loads all history2524all?: boolean;2525} = {}): Promise<boolean> => {2526if (this.hasFullHistory() || this.ephemeral || this.patch_list == null) {2527return false;2528}2529let start_seq;2530if (all) {2531start_seq = 1;2532} else {2533const seq_info = this.patch_list.getOldestSnapshot()?.seq_info;2534if (seq_info == null) {2535// nothing more to load2536return false;2537}2538start_seq = seq_info.prev_seq ?? 1;2539}2540// Doing this load triggers change events for all the patch info2541// that gets loaded.2542// TODO: right now we load everything, since the seq_info is wrong2543// from the NATS migration. Maybe this is fine since it is very efficient.2544// @ts-ignore2545await this.patches_table.dstream?.load({ start_seq: 0 });25462547// Wait until patch update queue is empty2548while (this.patch_update_queue.length > 0) {2549await once(this, "patch-update-queue-empty");2550}2551return start_seq > 1;2552};25532554legacyHistoryExists = async () => {2555const info = await this.legacy.getInfo();2556return !!info.uuid;2557};25582559private loadedLegacyHistory = false;2560loadLegacyHistory = reuseInFlight(async () => {2561if (this.loadedLegacyHistory) {2562return;2563}2564this.loadedLegacyHistory = true;2565if (!this.hasFullHistory()) {2566throw Error("must first load full history first");2567}2568const { patches, users } = await this.legacy.getPatches();2569if (this.patch_list == null) {2570return;2571}2572// @ts-ignore - cheating here2573const first = this.patch_list.patches[0];2574if ((first?.parents ?? []).length > 0) {2575throw Error("first patch should have no parents");2576}2577for (const patch of patches) {2578// @ts-ignore2579patch.time = new Date(patch.time).valueOf();2580}2581patches.sort(field_cmp("time"));2582const v: Patch[] = [];2583let version = -patches.length;2584let i = 0;2585for (const patch of patches) {2586// @ts-ignore2587patch.version = version;2588version += 1;2589if (i > 0) {2590// @ts-ignore2591patch.parents = [patches[i - 1].time];2592} else {2593// @ts-ignore2594patch.parents = [];2595}25962597// remap the user_id field2598const account_id = users[patch.user_id];2599let user_id = this.users.indexOf(account_id);2600if (user_id == -1) {2601this.users.push(account_id);2602user_id = this.users.length - 1;2603}2604patch.user_id = user_id;26052606const p = this.processPatch({ x: fromJS(patch) });2607i += 1;2608v.push(p);2609}2610if (first != null) {2611// @ts-ignore2612first.parents = [patches[patches.length - 1].time];2613first.is_snapshot = true;2614first.snapshot = this.patch_list.value({ time: first.time }).to_str();2615}2616this.patch_list.add(v);2617this.emit("change");2618});26192620show_history = (opts = {}): void => {2621assertDefined(this.patch_list);2622this.patch_list.show_history(opts);2623};26242625set_snapshot_interval = async (n: number): Promise<void> => {2626await this.set_syncstring_table({2627snapshot_interval: n,2628});2629await this.syncstring_table.save();2630};26312632get_last_save_to_disk_time = (): Date => {2633return this.last_save_to_disk_time;2634};26352636private handle_syncstring_save_state = async (2637state: string,2638time: Date,2639): Promise<void> => {2640// Called when the save state changes.26412642/* this.syncstring_save_state is used to make it possible to emit a2643'save-to-disk' event, whenever the state changes2644to indicate a save completed.26452646NOTE: it is intentional that this.syncstring_save_state is not defined2647the first time this function is called, so that save-to-disk2648with last save time gets emitted on initial load (which, e.g., triggers2649latex compilation properly in case of a .tex file).2650*/2651if (state === "done" && this.syncstring_save_state !== "done") {2652this.last_save_to_disk_time = time;2653this.emit("save-to-disk", time);2654}2655const dbg = this.dbg("handle_syncstring_save_state");2656dbg(2657`state='${state}', this.syncstring_save_state='${this.syncstring_save_state}', this.state='${this.state}'`,2658);2659if (2660this.state === "ready" &&2661(await this.isFileServer()) &&2662this.syncstring_save_state !== "requested" &&2663state === "requested"2664) {2665this.syncstring_save_state = state; // only used in the if above2666dbg("requesting save to disk -- calling save_to_disk");2667// state just changed to requesting a save to disk...2668// so we do it (unless of course syncstring is still2669// being initialized).2670try {2671// Uncomment the following to test simulating a2672// random failure in save_to_disk:2673// if (Math.random() < 0.5) throw Error("CHAOS MONKEY!"); // FOR TESTING ONLY.2674await this.save_to_disk();2675} catch (err) {2676// CRITICAL: we must unset this.syncstring_save_state (and set the save state);2677// otherwise, it stays as "requested" and this if statement would never get2678// run again, thus completely breaking saving this doc to disk.2679// It is normal behavior that *sometimes* this.save_to_disk might2680// throw an exception, e.g., if the file is temporarily deleted2681// or save it called before everything is initialized, or file2682// is temporarily set readonly, or maybe there is a file system error.2683// Of course, the finally below will also take care of this. However,2684// it's nice to record the error here.2685this.syncstring_save_state = "done";2686await this.set_save({ state: "done", error: `${err}` });2687dbg(`ERROR saving to disk in handle_syncstring_save_state-- ${err}`);2688} finally {2689// No matter what, after the above code is run,2690// the save state in the table better be "done".2691// We triple check that here, though of course2692// we believe the logic in save_to_disk and above2693// should always accomplish this.2694dbg("had to set the state to done in finally block");2695if (2696this.state === "ready" &&2697(this.syncstring_save_state != "done" ||2698this.syncstring_table_get_one().getIn(["save", "state"]) != "done")2699) {2700this.syncstring_save_state = "done";2701await this.set_save({ state: "done", error: "" });2702}2703}2704}2705};27062707private handle_syncstring_update = async (): Promise<void> => {2708if (this.state === "closed") {2709return;2710}2711if (this.syncstring_table == null) {2712logger.warn("handle_syncstring_update without syncstring_table", {2713path: this.path,2714state: this.state,2715});2716return;2717}2718const dbg = this.dbg("handle_syncstring_update");2719dbg();27202721const data = this.syncstring_table_get_one();2722const x: any = data != null ? data.toJS() : undefined;27232724if (x != null && x.save != null) {2725this.handle_syncstring_save_state(x.save.state, x.save.time);2726}27272728dbg(JSON.stringify(x));2729if (x == null || x.users == null) {2730dbg("new_document");2731await this.handle_syncstring_update_new_document();2732} else {2733dbg("update_existing");2734await this.handle_syncstring_update_existing_document(x, data);2735}2736};27372738private handle_syncstring_update_new_document = async (): Promise<void> => {2739// Brand new document2740this.emit("load-time-estimate", { type: "new", time: 1 });2741this.setLastSnapshot();2742this.last_seq = undefined;2743this.snapshot_interval =2744schema.SCHEMA.syncstrings.user_query?.get?.fields.snapshot_interval ??2745DEFAULT_SNAPSHOT_INTERVAL;27462747// Brand new syncstring2748// TODO: worry about race condition with everybody making themselves2749// have user_id 0... and also setting doctype.2750this.my_user_id = 0;2751this.users = [this.client.client_id()];2752const obj = {2753string_id: this.string_id,2754project_id: this.project_id,2755path: this.path,2756last_snapshot: this.last_snapshot,2757users: this.users,2758doctype: JSON.stringify(this.doctype),2759last_active: this.client.server_time(),2760};2761this.syncstring_table.set(obj);2762await this.syncstring_table.save();2763this.settings = Map();2764this.emit("metadata-change");2765this.emit("settings-change", this.settings);2766};27672768private handle_syncstring_update_existing_document = async (2769x: any,2770data: Map<string, any>,2771): Promise<void> => {2772if (this.state === "closed") {2773return;2774}2775// Existing document.27762777if (this.path == null) {2778// We just opened the file -- emit a load time estimate.2779this.emit("load-time-estimate", { type: "ready", time: 1 });2780}2781// TODO: handle doctype change here (?)2782this.setLastSnapshot(x.last_snapshot);2783this.last_seq = x.last_seq;2784this.snapshot_interval = x.snapshot_interval ?? DEFAULT_SNAPSHOT_INTERVAL;2785this.users = x.users ?? [];2786if (x.project_id) {2787// @ts-ignore2788this.project_id = x.project_id;2789}2790if (x.path) {2791// @ts-ignore2792this.path = x.path;2793}27942795const settings = data.get("settings", Map());2796if (settings !== this.settings) {2797this.settings = settings;2798this.emit("settings-change", settings);2799}28002801if (this.client != null) {2802// Ensure that this client is in the list of clients2803const client_id: string = this.client_id();2804this.my_user_id = this.users.indexOf(client_id);2805if (this.my_user_id === -1) {2806this.my_user_id = this.users.length;2807this.users.push(client_id);2808await this.set_syncstring_table({2809users: this.users,2810});2811}2812}2813this.emit("metadata-change");2814};28152816private init_watch = async (): Promise<void> => {2817if (!(await this.isFileServer())) {2818// ensures we are NOT watching anything2819await this.update_watch_path();2820return;2821}28222823// If path isn't being properly watched, make it so.2824if (this.watch_path !== this.path) {2825await this.update_watch_path(this.path);2826}28272828await this.pending_save_to_disk();2829};28302831private pending_save_to_disk = async (): Promise<void> => {2832this.assert_table_is_ready("syncstring");2833if (!(await this.isFileServer())) {2834return;2835}28362837const x = this.syncstring_table.get_one();2838// Check if there is a pending save-to-disk that is needed.2839if (x != null && x.getIn(["save", "state"]) === "requested") {2840try {2841await this.save_to_disk();2842} catch (err) {2843const dbg = this.dbg("pending_save_to_disk");2844dbg(`ERROR saving to disk in pending_save_to_disk -- ${err}`);2845}2846}2847};28482849private update_watch_path = async (path?: string): Promise<void> => {2850const dbg = this.dbg("update_watch_path");2851if (this.file_watcher != null) {2852// clean up2853dbg("close");2854this.file_watcher.close();2855delete this.file_watcher;2856delete this.watch_path;2857}2858if (path != null && this.client.is_deleted(path, this.project_id)) {2859dbg(`not setting up watching since "${path}" is explicitly deleted`);2860return;2861}2862if (path == null) {2863dbg("not opening another watcher since path is null");2864this.watch_path = path;2865return;2866}2867if (this.watch_path != null) {2868// this case is impossible since we deleted it above if it is was defined.2869dbg("watch_path already defined");2870return;2871}2872dbg("opening watcher...");2873if (this.state === "closed") {2874throw Error("must not be closed");2875}2876this.watch_path = path;2877try {2878if (!(await callback2(this.client.path_exists, { path }))) {2879if (this.client.is_deleted(path, this.project_id)) {2880dbg(`not setting up watching since "${path}" is explicitly deleted`);2881return;2882}2883// path does not exist2884dbg(2885`write '${path}' to disk from syncstring in-memory database version`,2886);2887const data = this.to_str();2888await callback2(this.client.write_file, { path, data });2889dbg(`wrote '${path}' to disk`);2890}2891} catch (err) {2892// This can happen, e.g, if path is read only.2893dbg(`could NOT write '${path}' to disk -- ${err}`);2894await this.update_if_file_is_read_only();2895// In this case, can't really setup a file watcher.2896return;2897}28982899dbg("now requesting to watch file");2900this.file_watcher = this.client.watch_file({ path });2901this.file_watcher.on("change", this.handle_file_watcher_change);2902this.file_watcher.on("delete", this.handle_file_watcher_delete);2903this.setupReadOnlyTimer();2904};29052906private setupReadOnlyTimer = () => {2907if (this.read_only_timer) {2908clearInterval(this.read_only_timer as any);2909this.read_only_timer = 0;2910}2911this.read_only_timer = <any>(2912setInterval(this.update_if_file_is_read_only, READ_ONLY_CHECK_INTERVAL_MS)2913);2914};29152916private handle_file_watcher_change = async (ctime: Date): Promise<void> => {2917const dbg = this.dbg("handle_file_watcher_change");2918const time: number = ctime.valueOf();2919dbg(2920`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}`,2921);2922if (2923this.save_to_disk_start_ctime == null ||2924(this.save_to_disk_end_ctime != null &&2925time - this.save_to_disk_end_ctime >= RECENT_SAVE_TO_DISK_MS)2926) {2927// Either we never saved to disk, or the last attempt2928// to save was at least RECENT_SAVE_TO_DISK_MS ago, and it finished,2929// so definitely this change event was not caused by it.2930dbg("load_from_disk since no recent save to disk");2931await this.load_from_disk();2932return;2933}2934};29352936private handle_file_watcher_delete = async (): Promise<void> => {2937this.assert_is_ready("handle_file_watcher_delete");2938const dbg = this.dbg("handle_file_watcher_delete");2939dbg("delete: set_deleted and closing");2940await this.client.set_deleted(this.path, this.project_id);2941this.close();2942};29432944private load_from_disk = async (): Promise<number> => {2945const path = this.path;2946const dbg = this.dbg("load_from_disk");2947dbg();2948const exists: boolean = await callback2(this.client.path_exists, { path });2949let size: number;2950if (!exists) {2951dbg("file no longer exists -- setting to blank");2952size = 0;2953this.from_str("");2954} else {2955dbg("file exists");2956await this.update_if_file_is_read_only();29572958const data = await callback2<string>(this.client.path_read, {2959path,2960maxsize_MB: MAX_FILE_SIZE_MB,2961});29622963size = data.length;2964dbg(`got it -- length=${size}`);2965this.from_str(data);2966this.commit();2967// we also know that this is the version on disk, so we update the hash2968await this.set_save({2969state: "done",2970error: "",2971hash: hash_string(data),2972});2973}2974// save new version to database, which we just set via from_str.2975await this.save();2976return size;2977};29782979private set_save = async (save: {2980state: string;2981error: string;2982hash?: number;2983expected_hash?: number;2984time?: number;2985}): Promise<void> => {2986this.assert_table_is_ready("syncstring");2987// set timestamp of when the save happened; this can be useful2988// for coordinating running code, etc.... and is just generally useful.2989const cur = this.syncstring_table_get_one().toJS()?.save;2990if (cur != null) {2991if (2992cur.state == save.state &&2993cur.error == save.error &&2994cur.hash == (save.hash ?? cur.hash) &&2995cur.expected_hash == (save.expected_hash ?? cur.expected_hash) &&2996cur.time == (save.time ?? cur.time)2997) {2998// no genuine change, so no point in wasting cycles on updating.2999return;3000}3001}3002if (!save.time) {3003save.time = Date.now();3004}3005await this.set_syncstring_table({ save });3006};30073008private set_read_only = async (read_only: boolean): Promise<void> => {3009this.assert_table_is_ready("syncstring");3010await this.set_syncstring_table({ read_only });3011};30123013is_read_only = (): boolean => {3014this.assert_table_is_ready("syncstring");3015return this.syncstring_table_get_one().get("read_only");3016};30173018wait_until_read_only_known = async (): Promise<void> => {3019await this.wait_until_ready();3020function read_only_defined(t: SyncTable): boolean {3021const x = t.get_one();3022if (x == null) {3023return false;3024}3025return x.get("read_only") != null;3026}3027await this.syncstring_table.wait(read_only_defined, 5 * 60);3028};30293030/* Returns true if the current live version of this document has3031a different hash than the version mostly recently saved to disk.3032I.e., if there are changes that have not yet been **saved to3033disk**. See the other function has_uncommitted_changes below3034for determining whether there are changes that haven't been3035commited to the database yet. Returns *undefined* if3036initialization not even done yet. */3037has_unsaved_changes = (): boolean | undefined => {3038if (this.state !== "ready") {3039return;3040}3041const dbg = this.dbg("has_unsaved_changes");3042try {3043return this.hash_of_saved_version() !== this.hash_of_live_version();3044} catch (err) {3045dbg(3046"exception computing hash_of_saved_version and hash_of_live_version",3047err,3048);3049// This could happen, e.g. when syncstring_table isn't connected3050// in some edge case. Better to just say we don't know then crash3051// everything. See https://github.com/sagemathinc/cocalc/issues/35773052return;3053}3054};30553056// Returns hash of last version saved to disk (as far as we know).3057hash_of_saved_version = (): number | undefined => {3058if (this.state !== "ready") {3059return;3060}3061return this.syncstring_table_get_one().getIn(["save", "hash"]) as3062| number3063| undefined;3064};30653066/* Return hash of the live version of the document,3067or undefined if the document isn't loaded yet.3068(TODO: write faster version of this for syncdb, which3069avoids converting to a string, which is a waste of time.) */3070hash_of_live_version = (): number | undefined => {3071if (this.state !== "ready") {3072return;3073}3074return hash_string(this.doc.to_str());3075};30763077/* Return true if there are changes to this syncstring that3078have not been committed to the database (with the commit3079acknowledged). This does not mean the file has been3080written to disk; however, it does mean that it safe for3081the user to close their browser.3082*/3083has_uncommitted_changes = (): boolean => {3084if (this.state !== "ready") {3085return false;3086}3087return this.patches_table.has_uncommitted_changes();3088};30893090// Commit any changes to the live document to3091// history as a new patch. Returns true if there3092// were changes and false otherwise. This works3093// fine offline, and does not wait until anything3094// is saved to the network, etc.3095commit = (emitChangeImmediately = false): boolean => {3096if (this.last == null || this.doc == null || this.last.is_equal(this.doc)) {3097return false;3098}3099// console.trace('commit');31003101if (emitChangeImmediately) {3102// used for local clients. NOTE: don't do this without explicit3103// request, since it could in some cases cause serious trouble.3104// E.g., for the jupyter backend doing this by default causes3105// an infinite recurse. Having this as an option is important, e.g.,3106// to avoid flicker/delay in the UI.3107this.emit_change();3108}31093110// Now save to backend as a new patch:3111this.emit("user-change");3112const patch = this.last.make_patch(this.doc); // must be nontrivial3113this.last = this.doc;3114// ... and save that to patches table3115const time = this.next_patch_time();3116this.commit_patch(time, patch);3117this.save(); // so eventually also gets sent out.3118this.touchProject();3119return true;3120};31213122/* Initiates a save of file to disk, then waits for the3123state to change. */3124save_to_disk = async (): Promise<void> => {3125if (this.state != "ready") {3126// We just make save_to_disk a successful3127// no operation, if the document is either3128// closed or hasn't finished opening, since3129// there's a lot of code that tries to save3130// on exit/close or automatically, and it3131// is difficult to ensure it all checks state3132// properly.3133return;3134}3135const dbg = this.dbg("save_to_disk");3136if (this.client.is_deleted(this.path, this.project_id)) {3137dbg("not saving to disk because deleted");3138await this.set_save({ state: "done", error: "" });3139return;3140}31413142// Make sure to include changes to the live document.3143// A side effect of save if we didn't do this is potentially3144// discarding them, which is obviously not good.3145this.commit();31463147dbg("initiating the save");3148if (!this.has_unsaved_changes()) {3149dbg("no unsaved changes, so don't save");3150// CRITICAL: this optimization is assumed by3151// autosave, etc.3152await this.set_save({ state: "done", error: "" });3153return;3154}31553156if (this.is_read_only()) {3157dbg("read only, so can't save to disk");3158// save should fail if file is read only and there are changes3159throw Error("can't save readonly file with changes to disk");3160}31613162// First make sure any changes are saved to the database.3163// One subtle case where this matters is that loading a file3164// with \r's into codemirror changes them to \n...3165if (!(await this.isFileServer())) {3166dbg("browser client -- sending any changes over network");3167await this.save();3168dbg("save done; now do actual save to the *disk*.");3169this.assert_is_ready("save_to_disk - after save");3170}31713172try {3173await this.save_to_disk_aux();3174} catch (err) {3175if (this.state != "ready") return;3176const error = `save to disk failed -- ${err}`;3177dbg(error);3178if (await this.isFileServer()) {3179this.set_save({ error, state: "done" });3180}3181}3182if (this.state != "ready") return;31833184if (!(await this.isFileServer())) {3185dbg("now wait for the save to disk to finish");3186this.assert_is_ready("save_to_disk - waiting to finish");3187await this.wait_for_save_to_disk_done();3188}3189this.update_has_unsaved_changes();3190};31913192/* Export the (currently loaded) history of editing of this3193document to a simple JSON-able object. */3194export_history = (options: HistoryExportOptions = {}): HistoryEntry[] => {3195this.assert_is_ready("export_history");3196const info = this.syncstring_table.get_one();3197if (info == null || !info.has("users")) {3198throw Error("syncstring table must be defined and users initialized");3199}3200const account_ids: string[] = info.get("users").toJS();3201assertDefined(this.patch_list);3202return export_history(account_ids, this.patch_list, options);3203};32043205private update_has_unsaved_changes = (): void => {3206if (this.state != "ready") {3207// This can happen, since this is called by a debounced function.3208// Make it a no-op in case we're not ready.3209// See https://github.com/sagemathinc/cocalc/issues/35773210return;3211}3212const cur = this.has_unsaved_changes();3213if (cur !== this.last_has_unsaved_changes) {3214this.emit("has-unsaved-changes", cur);3215this.last_has_unsaved_changes = cur;3216}3217};32183219// wait for save.state to change state.3220private wait_for_save_to_disk_done = async (): Promise<void> => {3221const dbg = this.dbg("wait_for_save_to_disk_done");3222dbg();3223function until(table): boolean {3224const done = table.get_one().getIn(["save", "state"]) === "done";3225dbg("checking... done=", done);3226return done;3227}32283229let last_err: string | undefined = undefined;3230const f = async () => {3231dbg("f");3232if (3233this.state != "ready" ||3234this.client.is_deleted(this.path, this.project_id)3235) {3236dbg("not ready or deleted - no longer trying to save.");3237return;3238}3239try {3240dbg("waiting until done...");3241await this.syncstring_table.wait(until, 15);3242} catch (err) {3243dbg("timed out after 15s");3244throw Error("timed out");3245}3246if (3247this.state != "ready" ||3248this.client.is_deleted(this.path, this.project_id)3249) {3250dbg("not ready or deleted - no longer trying to save.");3251return;3252}3253const err = this.syncstring_table_get_one().getIn(["save", "error"]) as3254| string3255| undefined;3256if (err) {3257dbg("error", err);3258last_err = err;3259throw Error(err);3260}3261dbg("done, with no error.");3262last_err = undefined;3263return;3264};3265await retry_until_success({3266f,3267max_tries: 8,3268desc: "wait_for_save_to_disk_done",3269});3270if (3271this.state != "ready" ||3272this.client.is_deleted(this.path, this.project_id)3273) {3274return;3275}3276if (last_err && typeof this.client.log_error != null) {3277this.client.log_error?.({3278string_id: this.string_id,3279path: this.path,3280project_id: this.project_id,3281error: `Error saving file -- ${last_err}`,3282});3283}3284};32853286/* Auxiliary function 2 for saving to disk:3287If this is associated with3288a project and has a filename.3289A user (web browsers) sets the save state to requested.3290The project sets the state to saving, does the save3291to disk, then sets the state to done.3292*/3293private save_to_disk_aux = async (): Promise<void> => {3294this.assert_is_ready("save_to_disk_aux");32953296if (!(await this.isFileServer())) {3297return await this.save_to_disk_non_filesystem_owner();3298}32993300try {3301return await this.save_to_disk_filesystem_owner();3302} catch (err) {3303this.emit("save_to_disk_filesystem_owner", err);3304throw err;3305}3306};33073308private save_to_disk_non_filesystem_owner = async (): Promise<void> => {3309this.assert_is_ready("save_to_disk_non_filesystem_owner");33103311if (!this.has_unsaved_changes()) {3312/* Browser client has no unsaved changes,3313so don't need to save --3314CRITICAL: this optimization is assumed by autosave.3315*/3316return;3317}3318const x = this.syncstring_table.get_one();3319if (x != null && x.getIn(["save", "state"]) === "requested") {3320// Nothing to do -- save already requested, which is3321// all the browser client has to do.3322return;3323}33243325// string version of this doc3326const data: string = this.to_str();3327const expected_hash = hash_string(data);3328await this.set_save({ state: "requested", error: "", expected_hash });3329};33303331private save_to_disk_filesystem_owner = async (): Promise<void> => {3332this.assert_is_ready("save_to_disk_filesystem_owner");3333const dbg = this.dbg("save_to_disk_filesystem_owner");33343335// check if on-disk version is same as in memory, in3336// which case no save is needed.3337const data = this.to_str(); // string version of this doc3338const hash = hash_string(data);3339dbg("hash = ", hash);33403341/*3342// TODO: put this consistency check back in (?).3343const expected_hash = this.syncstring_table3344.get_one()3345.getIn(["save", "expected_hash"]);3346*/33473348if (hash === this.hash_of_saved_version()) {3349// No actual save to disk needed; still we better3350// record this fact in table in case it3351// isn't already recorded3352this.set_save({ state: "done", error: "", hash });3353return;3354}33553356const path = this.path;3357if (!path) {3358const err = "cannot save without path";3359this.set_save({ state: "done", error: err });3360throw Error(err);3361}33623363dbg("project - write to disk file", path);3364// set window to slightly earlier to account for clock3365// imprecision.3366// Over an sshfs mount, all stats info is **rounded down3367// to the nearest second**, which this also takes care of.3368this.save_to_disk_start_ctime = Date.now() - 1500;3369this.save_to_disk_end_ctime = undefined;3370try {3371await callback2(this.client.write_file, { path, data });3372this.assert_is_ready("save_to_disk_filesystem_owner -- after write_file");3373const stat = await callback2(this.client.path_stat, { path });3374this.assert_is_ready("save_to_disk_filesystem_owner -- after path_state");3375this.save_to_disk_end_ctime = stat.ctime.valueOf() + 1500;3376this.set_save({3377state: "done",3378error: "",3379hash: hash_string(data),3380});3381} catch (err) {3382this.set_save({ state: "done", error: JSON.stringify(err) });3383throw err;3384}3385};33863387/*3388When the underlying synctable that defines the state3389of the document changes due to new remote patches, this3390function is called.3391It handles update of the remote version, updating our3392live version as a result.3393*/3394private handle_patch_update = async (changed_keys): Promise<void> => {3395// console.log("handle_patch_update", { changed_keys });3396if (changed_keys == null || changed_keys.length === 0) {3397// this happens right now when we do a save.3398return;3399}34003401const dbg = this.dbg("handle_patch_update");3402//dbg(changed_keys);3403if (this.patch_update_queue == null) {3404this.patch_update_queue = [];3405}3406for (const key of changed_keys) {3407this.patch_update_queue.push(key);3408}34093410dbg("Clear patch update_queue in a later event loop...");3411await delay(1);3412await this.handle_patch_update_queue();3413dbg("done");3414};34153416/*3417Whenever new patches are added to this.patches_table,3418their timestamp gets added to this.patch_update_queue.3419*/3420private handle_patch_update_queue = async (): Promise<void> => {3421const dbg = this.dbg("handle_patch_update_queue");3422try {3423this.handle_patch_update_queue_running = true;3424while (this.state != "closed" && this.patch_update_queue.length > 0) {3425dbg("queue size = ", this.patch_update_queue.length);3426const v: Patch[] = [];3427for (const key of this.patch_update_queue) {3428let x = this.patches_table.get(key);3429if (x == null) {3430continue;3431}3432if (!Map.isMap(x)) {3433// TODO: my NATS synctable-stream doesn't convert to immutable on get.3434x = fromJS(x);3435}3436// may be null, e.g., when deleted.3437const t = x.get("time");3438// Optimization: only need to process patches that we didn't3439// create ourselves during this session.3440if (t && !this.my_patches[t.valueOf()]) {3441const p = this.processPatch({ x });3442//dbg(`patch=${JSON.stringify(p)}`);3443if (p != null) {3444v.push(p);3445}3446}3447}3448this.patch_update_queue = [];3449this.emit("patch-update-queue-empty");3450assertDefined(this.patch_list);3451this.patch_list.add(v);34523453dbg("waiting for remote and doc to sync...");3454this.sync_remote_and_doc(v.length > 0);3455await this.patches_table.save();3456if (this.state === ("closed" as State)) return; // closed during await; nothing further to do3457dbg("remote and doc now synced");34583459if (this.patch_update_queue.length > 0) {3460// It is very important that next loop happen in a later3461// event loop to avoid the this.sync_remote_and_doc call3462// in this.handle_patch_update_queue above from causing3463// sync_remote_and_doc to get called from within itself,3464// due to synctable changes being emited on save.3465dbg("wait for next event loop");3466await delay(1);3467}3468}3469} finally {3470if (this.state == "closed") return; // got closed, so nothing further to do34713472// OK, done and nothing in the queue3473// Notify save() to try again -- it may have3474// paused waiting for this to clear.3475dbg("done");3476this.handle_patch_update_queue_running = false;3477this.emit("handle_patch_update_queue_done");3478}3479};34803481/* Disable and enable sync. When disabled we still3482collect patches from upstream (but do not apply them3483locally), and changes we make are broadcast into3484the patch stream. When we re-enable sync, all3485patches are put together in the stream and3486everything is synced as normal. This is useful, e.g.,3487to make it so a user **actively** editing a document is3488not interrupted by being forced to sync (in particular,3489by the 'before-change' event that they use to update3490the live document).34913492Also, delay_sync will delay syncing local with upstream3493for the given number of ms. Calling it regularly while3494user is actively editing to avoid them being bothered3495by upstream patches getting merged in.34963497IMPORTANT: I implemented this, but it is NOT used anywhere3498else in the codebase, so don't trust that it works.3499*/35003501disable_sync = (): void => {3502this.sync_is_disabled = true;3503};35043505enable_sync = (): void => {3506this.sync_is_disabled = false;3507this.sync_remote_and_doc(true);3508};35093510delay_sync = (timeout_ms = 2000): void => {3511clearTimeout(this.delay_sync_timer);3512this.disable_sync();3513this.delay_sync_timer = setTimeout(() => {3514this.enable_sync();3515}, timeout_ms);3516};35173518/*3519Merge remote patches and live version to create new live version,3520which is equal to result of applying all patches.3521*/3522private sync_remote_and_doc = (upstreamPatches: boolean): void => {3523if (this.last == null || this.doc == null || this.sync_is_disabled) {3524return;3525}35263527// Critical to save what we have now so it doesn't get overwritten during3528// before-change or setting this.doc below. This caused3529// https://github.com/sagemathinc/cocalc/issues/58713530this.commit();35313532if (upstreamPatches && this.state == "ready") {3533// First save any unsaved changes from the live document, which this3534// sync-doc doesn't acutally know the state of. E.g., this is some3535// rapidly changing live editor with changes not yet saved here.3536this.emit("before-change");3537// As a result of the emit in the previous line, all kinds of3538// nontrivial listener code probably just ran, and it should3539// have updated this.doc. We commit this.doc, so that the3540// upstream patches get applied against the correct live this.doc.3541this.commit();3542}35433544// Compute the global current state of the document,3545// which is got by applying all patches in order.3546// It is VERY important to do this, even if the3547// document is not yet ready, since it is critical3548// to properly set the state of this.doc to the value3549// of the patch list (e.g., not doing this 100% breaks3550// opening a file for the first time on cocalc-docker).3551assertDefined(this.patch_list);3552const new_remote = this.patch_list.value();3553if (!this.doc.is_equal(new_remote)) {3554// There is a possibility that live document changed, so3555// set to new version.3556this.last = this.doc = new_remote;3557if (this.state == "ready") {3558this.emit("after-change");3559this.emit_change();3560}3561}3562};35633564// Immediately alert all watchers of all changes since3565// last time.3566private emit_change = (): void => {3567this.emit("change", this.doc?.changes(this.before_change));3568this.before_change = this.doc;3569};35703571// Alert to changes soon, but debounced in case there are a large3572// number of calls in a group. This is called by default.3573// The debounce param is 0, since the idea is that this just waits3574// until the next "render loop" to avoid huge performance issues3575// with a nested for loop of sets. Doing it this way, massively3576// simplifies client code.3577emit_change_debounced: typeof this.emit_change = debounce(3578this.emit_change,35790,3580);35813582private set_syncstring_table = async (obj, save = true) => {3583const value0 = this.syncstring_table_get_one();3584const value = mergeDeep(value0, fromJS(obj));3585if (value0.equals(value)) {3586return;3587}3588this.syncstring_table.set(value);3589if (save) {3590await this.syncstring_table.save();3591}3592};35933594// this keeps the project from idle timing out -- it happens3595// whenever there is an edit to the file by a browser, and3596// keeps the project from stopping.3597private touchProject = throttle(() => {3598if (this.client?.is_browser()) {3599this.client.touch_project?.(this.project_id);3600}3601}, 60000);36023603private initInterestLoop = async () => {3604if (!this.client.is_browser()) {3605// only browser clients -- so actual humans3606return;3607}3608const touch = async () => {3609if (this.state == "closed" || this.client?.touchOpenFile == null) return;3610await this.client.touchOpenFile({3611path: this.path,3612project_id: this.project_id,3613doctype: this.doctype,3614});3615};3616// then every CONAT_OPEN_FILE_TOUCH_INTERVAL (30 seconds).3617await until(3618async () => {3619if (this.state == "closed") {3620return true;3621}3622await touch();3623return false;3624},3625{3626start: CONAT_OPEN_FILE_TOUCH_INTERVAL,3627max: CONAT_OPEN_FILE_TOUCH_INTERVAL,3628},3629);3630};3631}36323633function isCompletePatchStream(dstream) {3634if (dstream.length == 0) {3635return false;3636}3637const first = dstream[0];3638if (first.is_snapshot) {3639return false;3640}3641if (first.parents == null) {3642// first ever commit3643return true;3644}3645for (let i = 1; i < dstream.length; i++) {3646if (dstream[i].is_snapshot && dstream[i].time == first.time) {3647return true;3648}3649}3650return false;3651}365236533654