json-crdt-repo
Version:
JSON CRDT server and syncing local-first browser client
290 lines (289 loc) • 12.5 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.EditSession = void 0;
const tslib_1 = require("tslib");
const Log_1 = require("json-joy/lib/json-crdt/log/Log");
const json_crdt_1 = require("json-joy/lib/json-crdt");
const concurrencyDecorator_1 = require("thingies/lib/concurrencyDecorator");
const createRace_1 = require("thingies/lib/createRace");
const constants_1 = require("json-joy/lib/json-crdt-patch/constants");
const rxjs_1 = require("rxjs");
const operators_1 = require("rxjs/operators");
let EditSession = (() => {
var _a;
let _instanceExtraInitializers = [];
let _sync_decorators;
return _a = class EditSession {
get model() {
return this.log.end;
}
constructor(repo, id, start, cursor = undefined, session = Math.floor(Math.random() * 0x7fffffff)) {
this.repo = (tslib_1.__runInitializers(this, _instanceExtraInitializers), repo);
this.id = id;
this.start = start;
this.cursor = cursor;
this.session = session;
this._stopped = false;
this._stop$ = new rxjs_1.Subject();
this._syncRace = (0, createRace_1.createRace)();
this.saveInProgress = false;
// ------------------------------------------------ reactive event processing
this.events = [];
this.onEvent = (event) => {
if (this._stopped)
return;
if (event.merge) {
const cursor = event.cursor;
if (cursor !== undefined) {
this.cursor = cursor;
}
}
if (event.rebase) {
if (event.session === this.session)
return;
}
this.events.push(event);
this.drainEvents();
};
this.log = new Log_1.Log(() => this.start.clone());
const api = this.log.end.api;
const flushUnsubscribe = api.onFlush.listen(() => {
this.syncLog();
});
const patchUnsubscribe = api.onPatch.listen((patch) => {
const id = patch.getId();
if (!id)
return;
const clock = this.model.clock;
if (id.sid === clock.sid && clock.time >= id.time) {
this.syncLog();
}
});
this._stop$.pipe((0, operators_1.first)()).subscribe(() => {
flushUnsubscribe();
patchUnsubscribe();
});
this.repo.change$(this.id).pipe((0, operators_1.takeUntil)(this._stop$)).subscribe(this.onEvent);
}
dispose() {
if (this._stopped)
return;
this._stopped = true;
this._stop$.next();
}
clear() {
const { start, log } = this;
const empty = json_crdt_1.Model.create(undefined, start.clock.sid);
start.reset(empty);
log.patches.clear();
log.end.reset(empty);
}
/**
* Push (persist) any in-memory changes and pull (load) the latest state
* from the local repo.
*/
async sync() {
if (this._stopped)
return null;
const log = this.log;
const api = log.end.api;
api.flush();
this.saveInProgress = true;
try {
const patches = [];
// biome-ignore lint: for loop not possible here
log.patches.forEach((patch) => {
patches.push(patch.v);
});
const length = patches.length;
// TODO: After async call check that sync state is still valid. New patches, might have been added.
if (length || this.cursor === undefined) {
const time = this.start.clock.time - 1;
const res = await this.repo.sync({
id: this.id,
patches,
time,
cursor: this.cursor,
session: this.session,
});
if (this._stopped)
return null;
// TODO: After sync call succeeds, remove the patches from the log.
if (length) {
const last = patches[length - 1];
const lastId = last.getId();
if (lastId)
this.log.advanceTo(lastId);
this.start.applyBatch(patches);
}
if (res.model) {
this._syncRace(() => {
this.reset(res.model);
});
}
else if (res.merge && res.merge) {
this._syncRace(() => {
this.merge(res.merge);
});
}
const cursorBehind = this.cursor !== res.cursor;
if (cursorBehind) {
setTimeout(async () => {
if (this._stopped)
return;
const get = await this.repo.getIf({
id: this.id,
cursor: this.cursor,
});
if (this._stopped)
return;
if (!get)
return;
this.reset(get.model);
}, 50);
}
return { remote: res.remote };
}
else {
const res = await this.repo.getIf({ id: this.id, time: this.model.clock.time - 1, cursor: this.cursor });
if (this._stopped)
return null;
if (res) {
this.reset(res.model);
this.cursor = res.cursor;
}
return null;
}
}
finally {
this.saveInProgress = false;
this.drainEvents();
}
}
syncLog() {
if (!this.log.patches.size())
return;
this._syncRace(() => {
this.sync()
.then((error) => {
this.onsyncerror?.(error);
})
.catch((error) => {
this.onsyncerror?.(error);
});
});
}
async del() {
this.clear();
this.dispose();
await this.repo.del(this.id);
}
/**
* Load latest state from the local repo.
*/
async load() {
const { model } = await this.repo.get({ id: this.id });
if (model.clock.time > this.start.clock.time)
this.reset(model);
}
loadSilent() {
this.load().catch(() => { });
}
// ------------------------------------------------------- change integration
reset(model) {
this.start = model.clone();
const log = this.log;
const end = log.end;
if (end.api.builder.patch.ops.length)
end.api.flush();
end.reset(model);
// biome-ignore lint: for loop not possible here
log.patches.forEach((patch) => end.applyPatch(patch.v));
}
rebase(patches) {
if (patches.length === 0)
return;
const log = this.log;
const end = log.end;
if (end.api.builder.patch.ops.length)
end.api.flush();
if (log.patches.size() === 0) {
this.merge(patches);
return;
}
this.start.applyBatch(patches);
const newEnd = this.start.clone();
const lastPatch = patches[patches.length - 1];
let nextTick = lastPatch.getId().time + lastPatch.span();
const rebased = [];
// biome-ignore lint: for loop not possible here
log.patches.forEach(({ v }) => {
const patch = v.rebase(nextTick);
rebased.push(patch);
newEnd.applyPatch(patch);
nextTick += patch.span();
});
log.patches.clear();
for (const patch of rebased)
log.patches.set(patch.getId(), patch);
log.end.reset(newEnd);
}
merge(patches) {
if (!patches.length)
return;
const start = this.start;
const log = this.log;
const end = log.end;
const sid = end.clock.sid;
for (const patch of patches) {
const patchId = patch.getId();
if (!patchId)
continue;
const patchSid = patchId.sid;
if (patchSid === constants_1.SESSION.GLOBAL)
continue;
if (patchSid === sid && patchId.time < end.clock.time)
continue;
end.applyPatch(patch);
start.applyPatch(patch);
log.patches.del(patchId);
}
}
drainEvents() {
if (this.saveInProgress || this._stopped)
return;
this._syncRace(() => {
const events = this.events;
const length = events.length;
for (let i = 0; i < length; i++) {
const event = events[i];
try {
if (event.reset)
this.reset(event.reset);
else if (event.rebase)
this.rebase(event.rebase);
else if (event.merge)
this.merge(event.merge);
else if (event.del) {
this.clear();
break;
}
}
catch (error) {
// tslint:disable-next-line no-console
console.error('Failed to apply event', event, error);
}
}
this.events = [];
});
}
},
(() => {
const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(null) : void 0;
_sync_decorators = [(0, concurrencyDecorator_1.concurrency)(1)];
tslib_1.__esDecorate(_a, null, _sync_decorators, { kind: "method", name: "sync", static: false, private: false, access: { has: obj => "sync" in obj, get: obj => obj.sync }, metadata: _metadata }, null, _instanceExtraInitializers);
if (_metadata) Object.defineProperty(_a, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
})(),
_a;
})();
exports.EditSession = EditSession;