reactronic
Version:
Reactronic - Transactional Reactive State Management
368 lines (367 loc) • 16.1 kB
JavaScript
import { Utils, UNDEF } from "../util/Utils.js";
import { Log, misuse } from "../util/Dbg.js";
import { Sealant } from "../util/Sealant.js";
import { SealedArray } from "../util/SealedArray.js";
import { SealedMap } from "../util/SealedMap.js";
import { SealedSet } from "../util/SealedSet.js";
import { Isolation } from "../Enums.js";
import { ObjectVersion, ObjectHandle, ContentFootprint, Meta } from "./Data.js";
export const MAX_REVISION = Number.MAX_SAFE_INTEGER;
export const UNDEFINED_REVISION = MAX_REVISION - 1;
Object.defineProperty(ObjectHandle.prototype, "#this#", {
configurable: false, enumerable: false,
get() {
const result = {};
const data = Changeset.current().getObjectVersion(this, "#this#").data;
for (const fk in data) {
const v = data[fk];
if (v instanceof ContentFootprint)
result[fk] = v.content;
else if (v === Meta.Raw)
result[fk] = this.data[fk];
else
result[fk] = v;
}
return result;
},
});
const EMPTY_ARRAY = Object.freeze([]);
const EMPTY_MAP = Utils.freezeMap(new Map());
export class Changeset {
get hint() { var _a; return (_a = this.options.hint) !== null && _a !== void 0 ? _a : "noname"; }
get timestamp() { return this.revision; }
constructor(options, parent) {
this.id = ++Changeset.idGen;
this.options = options !== null && options !== void 0 ? options : DefaultSnapshotOptions;
this.parent = parent;
this.revision = UNDEFINED_REVISION;
this.bumper = 100;
this.items = new Map();
this.obsolete = [];
this.sealed = false;
}
lookupObjectVersion(h, fk, editing) {
let ov = h.editing;
if (ov && ov.changeset !== this) {
ov = this.items.get(h);
if (ov)
h.editing = ov;
}
const parent = this.parent;
if (!ov) {
if (!parent) {
ov = h.applied;
while (ov !== EMPTY_OBJECT_VERSION && ov.changeset.timestamp > this.timestamp)
ov = ov.former.objectVersion;
}
else
ov = parent.lookupObjectVersion(h, fk, editing);
}
else if (!editing && parent && !ov.changes.has(fk) && ov.former.objectVersion !== EMPTY_OBJECT_VERSION)
ov = parent.lookupObjectVersion(h, fk, editing);
return ov;
}
getObjectVersion(h, fk) {
const r = this.lookupObjectVersion(h, fk, false);
if (r === EMPTY_OBJECT_VERSION)
throw misuse(`${Dump.obj(h, fk)} is not yet available for T${this.id}[${this.hint}] because ${h.editing ? `T${h.editing.changeset.id}[${h.editing.changeset.hint}]` : ""} is not yet applied (last applied T${h.applied.changeset.id}[${h.applied.changeset.hint}])`);
return r;
}
getEditableObjectVersion(h, fk, value, token) {
let ov = this.lookupObjectVersion(h, fk, true);
const existing = ov.data[fk];
if (existing !== Meta.Raw) {
if (this.isNewObjectVersionRequired(h, ov, fk, existing, value, token)) {
this.bumpBy(ov.changeset.timestamp);
const revision = fk === Meta.Handle ? 1 : ov.revision + 1;
const data = Object.assign({}, fk === Meta.Handle ? value : ov.data);
Meta.set(data, Meta.Handle, h);
Meta.set(data, Meta.Revision, new ContentFootprint(revision, this.id));
ov = new ObjectVersion(this, ov, data);
this.items.set(h, ov);
h.editing = ov;
h.editors++;
if (Log.isOn && Log.opt.write)
Log.write("║", " ++", `${Dump.obj(h)} - new snapshot is created (revision ${revision})`);
}
}
else
ov = EMPTY_OBJECT_VERSION;
return ov;
}
setFieldContent(h, fk, ov, content, receiver, sensitivity) {
let existing = ov.data[fk];
if (existing !== undefined || (ov.former.objectVersion.changeset === EMPTY_OBJECT_VERSION.changeset && (fk in h.data) === false)) {
if (existing === undefined || existing.content !== content || sensitivity) {
const existingContent = existing === null || existing === void 0 ? void 0 : existing.content;
if (ov.former.objectVersion.data[fk] === existing) {
existing = ov.data[fk] = new ContentFootprint(content, this.id);
Changeset.markEdited(existingContent, content, true, ov, fk, h);
}
else {
existing.content = content;
existing.lastEditorChangesetId = this.id;
Changeset.markEdited(existingContent, content, true, ov, fk, h);
}
}
}
else
Reflect.set(h.data, fk, content, receiver);
}
static takeSnapshot(obj) {
return obj[Meta.Handle]["#this#"];
}
static dispose(obj) {
const ctx = Changeset.edit();
const h = Meta.get(obj, Meta.Handle);
if (h !== undefined)
Changeset.doDispose(ctx, h);
}
static doDispose(ctx, h) {
const ov = ctx.getEditableObjectVersion(h, Meta.Revision, Meta.Undefined);
if (ov !== EMPTY_OBJECT_VERSION)
ov.disposed = true;
return ov;
}
isNewObjectVersionRequired(h, ov, fk, existing, value, token) {
if (this.sealed && ov.changeset !== EMPTY_OBJECT_VERSION.changeset)
throw misuse(`signal property ${Dump.obj(h, fk)} can only be modified inside transaction`);
if (fk !== Meta.Handle) {
if (value !== Meta.Handle) {
if (ov.changeset !== this || ov.former.objectVersion !== EMPTY_OBJECT_VERSION) {
if (this.options.token !== undefined && token !== this.options.token)
throw misuse(`${this.hint} should not have side effects (trying to change ${Dump.snapshot(ov, fk)})`);
}
}
}
return ov.changeset !== this && !this.sealed;
}
acquire(outer) {
const result = !this.sealed && this.revision === UNDEFINED_REVISION;
if (result) {
const ahead = this.options.token === undefined || outer.revision === UNDEFINED_REVISION;
this.revision = ahead ? Changeset.stampGen : outer.revision;
Changeset.pending.push(this);
if (Changeset.oldest === undefined)
Changeset.oldest = this;
if (Log.isOn && Log.opt.transaction)
Log.write("╔══", `s${this.revision}`, `${this.hint}`);
}
return result;
}
bumpBy(timestamp) {
if (timestamp > this.bumper)
this.bumper = timestamp;
}
rebase() {
let conflicts = undefined;
if (this.items.size > 0) {
this.items.forEach((ov, h) => {
const theirs = this.parent ? this.parent.lookupObjectVersion(h, Meta.Handle, true) : h.applied;
if (ov.former.objectVersion !== theirs) {
const merged = this.merge(h, ov, theirs);
if (ov.conflicts.size > 0) {
if (!conflicts)
conflicts = [];
conflicts.push(ov);
}
if (Log.isOn && Log.opt.transaction)
Log.write("╠╝", "", `${Dump.snapshot2(h, ov.changeset)} is merged with ${Dump.snapshot2(h, theirs.changeset)} among ${merged} properties with ${ov.conflicts.size} conflicts.`);
}
});
if (this.options.token === undefined) {
if (this.bumper > 100) {
this.bumper = this.revision;
this.revision = ++Changeset.stampGen;
}
else
this.revision = this.bumper + 1;
}
else {
this.revision = this.bumper;
}
}
return conflicts;
}
merge(h, ours, theirs) {
let counter = 0;
const theirsDisposed = theirs.disposed;
const oursDisposed = ours.disposed;
const merged = Object.assign({}, theirs.data);
ours.changes.forEach((o, fk) => {
counter++;
const ourContentFootprint = ours.data[fk];
merged[fk] = ourContentFootprint;
if (theirsDisposed || oursDisposed) {
if (theirsDisposed !== oursDisposed) {
if (theirsDisposed || this.options.isolation !== Isolation.disjoinForInternalDisposal) {
if (Log.isOn && Log.opt.change)
Log.write("║╠", "", `${Dump.snapshot2(h, ours.changeset, fk)} <> ${Dump.snapshot2(h, theirs.changeset, fk)}`, 0, " *** CONFLICT ***");
ours.conflicts.set(fk, theirs);
}
}
}
else {
const theirValue = theirs.data[fk];
const ourFormerValue = ours.former.objectVersion.data[fk];
const { isResolved, resolvedValue } = Changeset.tryResolveConflict(theirValue, ourFormerValue, ourContentFootprint);
if (!isResolved)
ours.conflicts.set(fk, theirs);
else if (resolvedValue.isComputed)
merged[fk] = resolvedValue;
if (Log.isOn && Log.opt.change)
Log.write("║╠", "", `${Dump.snapshot2(h, ours.changeset, fk)} ${!isResolved ? "<>" : "=="} ${Dump.snapshot2(h, theirs.changeset, fk)}`, 0, !isResolved ? " *** CONFLICT ***" : undefined);
}
});
Utils.copyAllMembers(merged, ours.data);
ours.former.objectVersion = theirs;
return counter;
}
seal() {
this.sealed = true;
}
sealObjectVersion(h, ov) {
if (!this.parent) {
if (!ov.disposed)
ov.changes.forEach((o, fk) => Changeset.sealFieldVersion(ov.data[fk], fk, h.proxy.constructor.name));
else
for (const fk in ov.former.objectVersion.data)
ov.data[fk] = Meta.Undefined;
if (Log.isOn)
Changeset.freezeObjectVersion(ov);
}
h.editors--;
if (h.editors === 0)
h.editing = undefined;
}
static sealFieldVersion(cf, fk, typeName) {
if (cf instanceof ContentFootprint) {
const content = cf.content;
if (content !== undefined && content !== null) {
const sealedType = Object.getPrototypeOf(content)[Sealant.SealedType];
if (sealedType)
cf.content = Sealant.seal(content, sealedType, typeName, fk);
}
}
}
static freezeObjectVersion(ov) {
Object.freeze(ov.data);
Utils.freezeSet(ov.changes);
Utils.freezeMap(ov.conflicts);
return ov;
}
triggerGarbageCollection() {
if (this.revision !== 0) {
if (this === Changeset.oldest) {
const p = Changeset.pending;
p.sort((a, b) => a.revision - b.revision);
let i = 0;
while (i < p.length && p[i].sealed) {
p[i].unlinkHistory();
i++;
}
Changeset.pending = p.slice(i);
Changeset.oldest = Changeset.pending[0];
const now = Date.now();
if (now - Changeset.lastGarbageCollectionSummaryTimestamp > Changeset.garbageCollectionSummaryInterval) {
Log.write("", "[G]", `Total object/snapshot count: ${Changeset.totalObjectHandleCount}/${Changeset.totalObjectSnapshotCount}`);
Changeset.lastGarbageCollectionSummaryTimestamp = now;
}
}
}
}
unlinkHistory() {
if (Log.isOn && Log.opt.gc)
Log.write("", "[G]", `Dismiss history below t${this.id}s${this.revision} (${this.hint})`);
this.items.forEach((ov, h) => {
if (Log.isOn && Log.opt.gc && ov.former.objectVersion !== EMPTY_OBJECT_VERSION)
Log.write(" ", " ", `${Dump.snapshot2(h, ov.former.objectVersion.changeset)} is ready for GC because overwritten by ${Dump.snapshot2(h, ov.changeset)}`);
if (Changeset.garbageCollectionSummaryInterval < Number.MAX_SAFE_INTEGER) {
if (ov.former.objectVersion !== EMPTY_OBJECT_VERSION)
Changeset.totalObjectSnapshotCount--;
if (ov.disposed)
Changeset.totalObjectHandleCount--;
}
ov.former.objectVersion = EMPTY_OBJECT_VERSION;
});
this.items = EMPTY_MAP;
this.obsolete = EMPTY_ARRAY;
if (Log.isOn)
Object.freeze(this);
}
static _init() {
const boot = EMPTY_OBJECT_VERSION.changeset;
boot.acquire(boot);
boot.seal();
boot.triggerGarbageCollection();
Changeset.freezeObjectVersion(EMPTY_OBJECT_VERSION);
Changeset.idGen = 100;
Changeset.stampGen = 101;
Changeset.oldest = undefined;
SealedArray.prototype;
SealedMap.prototype;
SealedSet.prototype;
}
}
Changeset.idGen = -1;
Changeset.stampGen = 1;
Changeset.pending = [];
Changeset.oldest = undefined;
Changeset.garbageCollectionSummaryInterval = Number.MAX_SAFE_INTEGER;
Changeset.lastGarbageCollectionSummaryTimestamp = Date.now();
Changeset.totalObjectHandleCount = 0;
Changeset.totalObjectSnapshotCount = 0;
Changeset.current = UNDEF;
Changeset.edit = UNDEF;
Changeset.markUsed = UNDEF;
Changeset.markEdited = UNDEF;
Changeset.tryResolveConflict = UNDEF;
Changeset.propagateAllChangesToListeners = (changeset) => { };
Changeset.discardAllListeners = (changeset) => { };
Changeset.enqueueReactionsToRun = (reactions) => { };
export class Dump {
static obj(h, fk, stamp, changesetId, lastEditorChangesetId, value) {
const member = fk !== undefined ? `.${fk.toString()}` : "";
let result;
if (h !== undefined) {
const v = value !== undefined && value !== Meta.Undefined ? `[=${Dump.valueHint(value)}]` : "";
if (stamp === undefined)
result = `${h.hint}${member}${v} #${h.id}`;
else
result = `${h.hint}${member}${v} #${h.id}t${changesetId}s${stamp}${lastEditorChangesetId !== undefined ? `e${lastEditorChangesetId}` : ""}`;
}
else
result = `boot${member}`;
return result;
}
static snapshot2(h, s, fk, cf) {
var _a;
return Dump.obj(h, fk, s.timestamp, s.id, cf === null || cf === void 0 ? void 0 : cf.lastEditorChangesetId, (_a = cf === null || cf === void 0 ? void 0 : cf.content) !== null && _a !== void 0 ? _a : Meta.Undefined);
}
static snapshot(ov, fk) {
const h = Meta.get(ov.data, Meta.Handle);
const cf = fk !== undefined ? ov.data[fk] : undefined;
return Dump.obj(h, fk, ov.changeset.timestamp, ov.changeset.id, cf === null || cf === void 0 ? void 0 : cf.lastEditorChangesetId);
}
static conflicts(conflicts) {
return conflicts.map(ours => {
const items = [];
ours.conflicts.forEach((theirs, fk) => {
items.push(Dump.conflictingMemberHint(fk, ours, theirs));
});
return items.join(", ");
}).join(", ");
}
static conflictingMemberHint(fk, ours, theirs) {
return `${theirs.changeset.hint} (${Dump.snapshot(theirs, fk)})`;
}
}
Dump.valueHint = (value) => "???";
export const EMPTY_OBJECT_VERSION = new ObjectVersion(new Changeset({ hint: "<boot>" }), undefined, {});
export const DefaultSnapshotOptions = Object.freeze({
hint: "noname",
isolation: Isolation.joinToCurrentTransaction,
journal: undefined,
logging: undefined,
token: undefined,
});