UNPKG

reactronic

Version:

Reactronic - Transactional Reactive State Management

368 lines (367 loc) 16.1 kB
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, });