From 226a6eef0abb063239239f0f6c4094a24632ed0b Mon Sep 17 00:00:00 2001 From: Ryan Di Date: Mon, 13 Apr 2026 23:11:09 +1000 Subject: [PATCH] feat(editor): add ledger-backed transaction manager with isolated history lane --- dev-docs/docs/codebase/transactions.mdx | 121 ++++++++ packages/element/src/index.ts | 2 + packages/element/src/store.ts | 101 ++++++ packages/element/src/transaction/ledger.ts | 345 +++++++++++++++++++++ packages/element/src/transaction/types.ts | 25 ++ packages/excalidraw/components/App.tsx | 59 ++++ packages/excalidraw/transaction/manager.ts | 241 ++++++++++++++ packages/excalidraw/transaction/types.ts | 27 ++ packages/excalidraw/types.ts | 2 + 9 files changed, 923 insertions(+) create mode 100644 dev-docs/docs/codebase/transactions.mdx create mode 100644 packages/element/src/transaction/ledger.ts create mode 100644 packages/element/src/transaction/types.ts create mode 100644 packages/excalidraw/transaction/manager.ts create mode 100644 packages/excalidraw/transaction/types.ts diff --git a/dev-docs/docs/codebase/transactions.mdx b/dev-docs/docs/codebase/transactions.mdx new file mode 100644 index 0000000000..f6d4e60f9e --- /dev/null +++ b/dev-docs/docs/codebase/transactions.mdx @@ -0,0 +1,121 @@ +# Transactions + +## Transaction + Ledger System + +This system lets many canvas mutations over a time window (for example AI streaming patch/action/create) render immediately, while still producing a single undoable durable history entry. + +- AI streaming typically emits many `NEVER` updates (for real-time visual feedback). +- `NEVER` updates store snapshots, but does not write undo/redo history. +- If we only do a final "one IMMEDIATELY at the end", we often miss the intended delta because snapshots may already be aligned. +- Transaction + Ledger creates synthetic logical before/after snapshots, then commits one durable increment with correct undo semantics. + +## Key Files + +### `packages/excalidraw/transaction/manager.ts` + +- `TransactionManager`: session entry point. +- `TransactionSession`: + - `apply()`: runs a mutation and records before/after diff automatically. + - `capture()`: records caller-provided before/after snapshots. + - `setAppStatePatch()`: optional appState before/after patch for synthetic history. + - `commit()` / `cancel()`: finalize session and return an idempotent summary. + +### `packages/element/src/transaction/ledger.ts` + +- `TransactionLedger` keeps net mutation state across steps. +- `recordStep()`: folds multiple updates into per-element baseline/target/touchedProps. +- `buildSyntheticSnapshots()`: reconciles ledger state with live scene under merge policy and returns `logicalBefore` / `logicalAfter` for history commit. + +### `packages/excalidraw/components/App.tsx` + +- `commitSyntheticHistoryEntry()` converts `logicalBefore/logicalAfter` into `StoreSnapshot -> StoreChange -> StoreDelta`. +- It does not mutate the live scene; it only commits a synthetic durable increment. +- `appStateBefore/After` are overlaid on top of current observed appState baseline, so only provided keys participate in synthetic diff. + +### `packages/element/src/store.ts` + +- `enqueueIsolatedIncrement()` / `flushIsolatedIncrements()` / `requestIsolatedIncrementFlush()`. +- Adds an isolated lane/queue for synthetic durable increments outside regular micro/macro capture flow. +- Flush runs only when store is idle (`isIdle()`); otherwise it defers and retries next frame. + +### `packages/excalidraw/types.ts` + `packages/element/src/transaction/types.ts` + `packages/excalidraw/transaction/types.ts` + +- App/type wiring and merge-policy types used by the transaction system. + +## How They Work Together + +1. Caller opens a session (`app.transactionManager.open()`). +2. AI steps keep rendering in real time (commonly via `NEVER`). +3. Session records diffs continuously via `apply()` / `capture()` into ledger. +4. On session `commit()`: + - ledger builds `logicalBefore/logicalAfter` + - `App.commitSyntheticHistoryEntry()` computes change/delta + - `store.enqueueIsolatedIncrement()` pushes to isolated queue +5. Store flushes isolated queue when idle and emits durable increment to undo stack. + +Result: + +- streaming visuals stay immediate; +- history remains one entry per transaction; +- synthetic durable commit stays isolated from ongoing regular capture batches. + +## Why Isolated Lane/Queue Matters + +Without isolated lane, synthetic increments may interleave with normal editor flows (pointer interactions, macro captures, pending micro actions), causing: + +- polluted undo boundaries, +- unstable commit timing, +- non-deterministic behavior due to shared capture channel contention. + +Core value of isolated lane: + +- structural isolation from regular capture flow, +- timing control (flush only on idle), +- deferred retry when not idle. + +## Sample Usage + +### AI Short Session (compact multi-step) + +```ts +const { summary } = await app.transactionManager.run(async (tx) => { + // run() auto-commits on success and auto-cancels on thrown errors. + // Short session can still include multiple steps. + await tx.apply(async () => executeUpdateStepA()); + await tx.apply(async () => executeUpdateStepB()); +}); +``` + +### AI Long Session (multi-step mixed flow) + +```ts +const session = app.transactionManager.open(); + +try { + // step 1 + await session.apply(async () => executeUpdateStep1()); + + // step 2 + await session.apply(async () => executeCreateStep2()); + + // step 3 + await session.apply(async () => executeUpdateStep3()); + + // Optional: include selected appState keys in synthetic history + session.setAppStatePatch({ + before: { selectedElementIds: prevSelectedIds }, + after: { selectedElementIds: nextSelectedIds }, + }); + + session.commit(); +} catch (error) { + session.cancel(); + throw error; +} +``` + +## Notes + +- `commit()` and `cancel()` are idempotent. +- merge policy currently uses `conflictWinner` + `conflictScope`. +- current implementation prioritizes semantic correctness and readability; fine-grained performance optimizations can be added later. diff --git a/packages/element/src/index.ts b/packages/element/src/index.ts index c55537d451..15ac188aa3 100644 --- a/packages/element/src/index.ts +++ b/packages/element/src/index.ts @@ -95,6 +95,8 @@ export * from "./textMeasurements"; export * from "./textWrapping"; export * from "./transform"; export * from "./transformHandles"; +export * from "./transaction/ledger"; +export * from "./transaction/types"; export * from "./typeChecks"; export * from "./utils"; export * from "./zindex"; diff --git a/packages/element/src/store.ts b/packages/element/src/store.ts index 38235e752c..2356bc8e0e 100644 --- a/packages/element/src/store.ts +++ b/packages/element/src/store.ts @@ -71,6 +71,12 @@ export const CaptureUpdateAction = { export type CaptureUpdateActionType = ValueOf; type MicroActionsQueue = (() => void)[]; +// Dedicated queue for synthetic durable increments that must commit +// outside regular micro/macro capture batches. +type IsolatedIncrementsQueue = Array<{ + change: StoreChange; + delta: StoreDelta; +}>; /** * Store which captures the observed changes and emits them as `StoreIncrement` events. @@ -85,6 +91,9 @@ export class Store { private scheduledMacroActions: Set = new Set(); private scheduledMicroActions: MicroActionsQueue = []; + private isolatedIncrementsQueue: IsolatedIncrementsQueue = []; + private isFlushingIsolatedIncrements = false; + private cancelIsolatedFlushRetry: (() => void) | null = null; private _snapshot = StoreSnapshot.empty(); @@ -197,6 +206,91 @@ export class Store { this.satisfiesScheduledActionsInvariant(); // defensively reset all scheduled "macro" actions, possibly cleans up other runtime garbage this.scheduledMacroActions = new Set(); + if (this.hasPendingIsolatedIncrements()) { + this.requestIsolatedIncrementFlush(); + } + } + } + + /** + * Returns true when no micro/macro actions are currently pending. + */ + public isIdle() { + return ( + this.scheduledMicroActions.length === 0 && + this.scheduledMacroActions.size === 0 + ); + } + + /** Queues a durable increment to be committed through the isolated lane. */ + public enqueueIsolatedIncrement(params: { + change: StoreChange; + delta: StoreDelta; + }) { + this.isolatedIncrementsQueue.push(params); + this.requestIsolatedIncrementFlush(); + } + + public hasPendingIsolatedIncrements() { + return this.isolatedIncrementsQueue.length > 0; + } + + /** + * Flushes isolated increments only when the regular store queue is idle. + * Returns whether at least one isolated increment was committed. + */ + public flushIsolatedIncrements() { + if (this.isFlushingIsolatedIncrements) { + return false; + } + if (!this.hasPendingIsolatedIncrements() || !this.isIdle()) { + return false; + } + + this.isFlushingIsolatedIncrements = true; + let flushedAny = false; + try { + while (this.hasPendingIsolatedIncrements() && this.isIdle()) { + const entry = this.isolatedIncrementsQueue.shift()!; + this.scheduleMicroAction({ + action: CaptureUpdateAction.IMMEDIATELY, + change: entry.change, + delta: entry.delta, + }); + this.commit( + this.app.scene.getElementsMapIncludingDeleted(), + this.app.state, + ); + flushedAny = true; + } + } finally { + this.isFlushingIsolatedIncrements = false; + } + + return flushedAny; + } + + /** + * Retries isolated queue flushing once store queues are idle again. + * This keeps isolated durable commits out of active micro/macro batches. + */ + private scheduleDeferredIsolatedIncrementFlush() { + if (this.cancelIsolatedFlushRetry) { + return; + } + + // Store is used in the browser editor runtime; defer one frame and retry. + const rafId = requestAnimationFrame(() => { + this.cancelIsolatedFlushRetry = null; + this.requestIsolatedIncrementFlush(); + }); + this.cancelIsolatedFlushRetry = () => cancelAnimationFrame(rafId); + } + + private requestIsolatedIncrementFlush() { + const flushed = this.flushIsolatedIncrements(); + if (!flushed && this.hasPendingIsolatedIncrements()) { + this.scheduleDeferredIsolatedIncrementFlush(); } } @@ -204,8 +298,15 @@ export class Store { * Clears the store instance. */ public clear(): void { + if (this.cancelIsolatedFlushRetry) { + this.cancelIsolatedFlushRetry(); + this.cancelIsolatedFlushRetry = null; + } this.snapshot = StoreSnapshot.empty(); this.scheduledMacroActions = new Set(); + this.scheduledMicroActions = []; + this.isolatedIncrementsQueue = []; + this.isFlushingIsolatedIncrements = false; } /** diff --git a/packages/element/src/transaction/ledger.ts b/packages/element/src/transaction/ledger.ts new file mode 100644 index 0000000000..76946057cc --- /dev/null +++ b/packages/element/src/transaction/ledger.ts @@ -0,0 +1,345 @@ +/** + * Transaction-scoped mutation ledger: + * - records net scene mutations across streamed steps + * - reconstructs synthetic logical before/after snapshots + * - feeds a single durable history entry at transaction end + */ +import type { Mutable } from "@excalidraw/common/utility-types"; + +import { deepCopyElement } from "../duplicate"; + +import type { + ExcalidrawElement, + OrderedExcalidrawElement, + SceneElementsMap, +} from "../types"; +import type { TransactionLedgerEntry, TransactionMergePolicy } from "./types"; + +const LEDGER_IGNORED_PROPS = new Set([ + "version", + "versionNonce", + "seed", + "updated", + "index", +]); + +type ElementRecord = Record; + +const isPlainObject = (value: unknown): value is Record => + !!value && typeof value === "object" && !Array.isArray(value); + +const getElementProp = (element: ExcalidrawElement, prop: string): unknown => + (element as ElementRecord)[prop]; + +const setOrderedElementProp = ( + element: Mutable, + prop: string, + value: unknown, +) => { + (element as ElementRecord)[prop] = value; +}; + +/** Deep equality used by ledger conflict/touched-prop detection. */ +const isLedgerValueEqual = (left: unknown, right: unknown): boolean => { + if (Object.is(left, right)) { + return true; + } + + if (Array.isArray(left) && Array.isArray(right)) { + if (left.length !== right.length) { + return false; + } + for (let index = 0; index < left.length; index += 1) { + if (!isLedgerValueEqual(left[index], right[index])) { + return false; + } + } + return true; + } + + if (isPlainObject(left) && isPlainObject(right)) { + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + if (leftKeys.length !== rightKeys.length) { + return false; + } + for (const key of leftKeys) { + if (!Object.prototype.hasOwnProperty.call(right, key)) { + return false; + } + if (!isLedgerValueEqual(left[key], right[key])) { + return false; + } + } + return true; + } + + return false; +}; + +/** Clones a scene map so synthetic before/after edits never mutate live elements. */ +const cloneSceneElementsMap = ( + elements: ReadonlyMap, +): SceneElementsMap => + new Map( + [...elements.entries()].map(([id, element]) => [ + id, + deepCopyElement(element) as OrderedExcalidrawElement, + ]), + ) as SceneElementsMap; + +/** Returns changed property names between two element snapshots. */ +const collectTouchedProps = ( + before: ExcalidrawElement | null, + after: ExcalidrawElement | null, +) => { + if (!before || !after) { + return new Set(["*"]); + } + + const touchedProps = new Set(); + const keys = new Set([...Object.keys(before), ...Object.keys(after)]); + + for (const key of keys) { + if (LEDGER_IGNORED_PROPS.has(key)) { + continue; + } + if ( + !isLedgerValueEqual(getElementProp(before, key), getElementProp(after, key)) + ) { + touchedProps.add(key); + } + } + + return touchedProps; +}; + +/** Returns ids whose element snapshot changed between two points in time. */ +export const collectChangedElementIds = ( + before: ReadonlyMap, + after: ReadonlyMap, +) => { + const changedIds = new Set(); + const candidateIds = new Set([...before.keys(), ...after.keys()]); + + for (const id of candidateIds) { + const beforeElement = before.get(id) ?? null; + const afterElement = after.get(id) ?? null; + if (collectTouchedProps(beforeElement, afterElement).size > 0) { + changedIds.add(id); + } + } + + return [...changedIds]; +}; + +/** Determines whether live conflicts should skip the whole updated element. */ +const shouldSkipUpdateElementOnLiveConflict = ( + entry: TransactionLedgerEntry, + liveElement: ExcalidrawElement, + policy: TransactionMergePolicy, +) => { + if (policy.conflictWinner === "transaction") { + return false; + } + if (entry.touchedProps.has("*")) { + return true; + } + return policy.conflictScope === "element"; +}; + +/** + * Keeps transaction-level scene mutations and materializes synthetic snapshots + * for a single durable history commit. + */ +export class TransactionLedger { + private readonly entries = new Map(); + + /** Whether the transaction has any net mutations to commit. */ + hasEntries() { + return this.entries.size > 0; + } + + /** Records one mutation step into a net per-element ledger entry. */ + recordStep( + before: ReadonlyMap, + after: ReadonlyMap, + ) { + for (const elementId of collectChangedElementIds(before, after)) { + const beforeElement = before.get(elementId) ?? null; + const afterElement = after.get(elementId) ?? null; + const touchedProps = collectTouchedProps(beforeElement, afterElement); + + if (touchedProps.size === 0) { + continue; + } + + const existing = this.entries.get(elementId); + if (!existing) { + this.entries.set(elementId, { + baselineElement: beforeElement + ? deepCopyElement(beforeElement) + : null, + targetElement: afterElement ? deepCopyElement(afterElement) : null, + touchedProps, + }); + continue; + } + + existing.targetElement = afterElement + ? deepCopyElement(afterElement) + : null; + if (existing.touchedProps.has("*") || touchedProps.has("*")) { + existing.touchedProps = new Set(["*"]); + } else { + for (const prop of touchedProps) { + existing.touchedProps.add(prop); + } + } + + // Created then deleted inside one transaction leaves no durable footprint. + if (!existing.baselineElement && !existing.targetElement) { + this.entries.delete(elementId); + continue; + } + if (!existing.baselineElement && existing.targetElement?.isDeleted) { + this.entries.delete(elementId); + } + } + } + + /** + * Builds logical before/after snapshots by reconciling transaction targets + * with current live scene state under the selected merge policy. + */ + buildSyntheticSnapshots( + live: ReadonlyMap, + mergePolicy: TransactionMergePolicy, + ) { + const logicalBefore = cloneSceneElementsMap(live); + const logicalAfter = cloneSceneElementsMap(live); + + for (const [elementId, entry] of this.entries) { + if (!entry.baselineElement) { + const liveElement = live.get(elementId) ?? null; + const targetElement = entry.targetElement; + if (!targetElement) { + continue; + } + if ( + mergePolicy.conflictWinner === "live" && + (!liveElement || + liveElement.isDeleted || + collectTouchedProps(targetElement, liveElement).size > 0) + ) { + continue; + } + logicalBefore.delete(elementId); + logicalAfter.set( + elementId, + deepCopyElement(targetElement) as OrderedExcalidrawElement, + ); + continue; + } + + if (!entry.targetElement) { + const liveElement = live.get(elementId) ?? null; + if ( + mergePolicy.conflictWinner === "live" && + liveElement && + !liveElement.isDeleted + ) { + continue; + } + logicalBefore.set( + elementId, + deepCopyElement(entry.baselineElement) as OrderedExcalidrawElement, + ); + logicalAfter.delete(elementId); + continue; + } + + const liveElement = live.get(elementId) ?? null; + const targetElement = entry.targetElement; + const baselineElement = entry.baselineElement; + const logicalBeforeElement = logicalBefore.get(elementId); + const logicalAfterElement = logicalAfter.get(elementId); + + if ( + !liveElement || + !baselineElement || + !targetElement || + !logicalBeforeElement || + !logicalAfterElement + ) { + continue; + } + + if (entry.touchedProps.has("*")) { + const hasLiveConflict = + collectTouchedProps(targetElement, liveElement).size > 0; + if (mergePolicy.conflictWinner === "live" && hasLiveConflict) { + continue; + } + logicalBefore.set( + elementId, + deepCopyElement(baselineElement) as OrderedExcalidrawElement, + ); + logicalAfter.set( + elementId, + deepCopyElement(targetElement) as OrderedExcalidrawElement, + ); + continue; + } + + if ( + shouldSkipUpdateElementOnLiveConflict(entry, liveElement, mergePolicy) + ) { + let hasConflict = false; + for (const prop of entry.touchedProps) { + const liveValue = getElementProp(liveElement, prop); + const targetValue = getElementProp(targetElement, prop); + if (!isLedgerValueEqual(liveValue, targetValue)) { + hasConflict = true; + break; + } + } + if (hasConflict) { + continue; + } + } + + const mutableBefore = + logicalBeforeElement as Mutable; + const mutableAfter = + logicalAfterElement as Mutable; + + let appliedProps = 0; + for (const prop of entry.touchedProps) { + const liveValue = getElementProp(liveElement, prop); + const targetValue = getElementProp(targetElement, prop); + const hasConflict = !isLedgerValueEqual(liveValue, targetValue); + if (mergePolicy.conflictWinner === "live" && hasConflict) { + continue; + } + + setOrderedElementProp( + mutableBefore, + prop, + getElementProp(baselineElement, prop), + ); + setOrderedElementProp(mutableAfter, prop, targetValue); + appliedProps += 1; + } + + if (appliedProps > 0) { + mutableBefore.version = baselineElement.version; + mutableBefore.versionNonce = baselineElement.versionNonce; + mutableAfter.version = targetElement.version; + mutableAfter.versionNonce = targetElement.versionNonce; + } + } + + return { logicalBefore, logicalAfter }; + } +} diff --git a/packages/element/src/transaction/types.ts b/packages/element/src/transaction/types.ts new file mode 100644 index 0000000000..d987c66079 --- /dev/null +++ b/packages/element/src/transaction/types.ts @@ -0,0 +1,25 @@ +import type { ExcalidrawElement } from "../types"; + +/** Which side wins when transaction output and live scene diverge. */ +export type ConflictWinner = "live" | "transaction"; + +/** Conflict granularity used by the merge policy. */ +export type ConflictScope = "prop" | "element"; + +/** Merge policy used when building synthetic before/after snapshots. */ +export type TransactionMergePolicy = { + conflictWinner: ConflictWinner; + conflictScope: ConflictScope; +}; + +export const DEFAULT_TRANSACTION_MERGE_POLICY: TransactionMergePolicy = { + conflictWinner: "live", + conflictScope: "prop", +}; + +/** Per-element ledger record captured during a transaction session. */ +export type TransactionLedgerEntry = { + baselineElement: ExcalidrawElement | null; + targetElement: ExcalidrawElement | null; + touchedProps: Set; +}; diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 4db823c2d4..6d040e9c91 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -239,7 +239,9 @@ import { hitElementBoundingBox, isLineElement, isSimpleArrow, + StoreChange, StoreDelta, + StoreSnapshot, type ApplyToOptions, positionElementsOnGrid, calculateFixedPointForNonElbowArrowBinding, @@ -361,6 +363,7 @@ import { restoreAppState, restoreElements } from "../data/restore"; import { getCenter, getDistance } from "../gesture"; import { History } from "../history"; import { defaultLang, getLanguage, languages, setLanguage, t } from "../i18n"; +import { TransactionManager } from "../transaction/manager"; import { calculateScrollCenter, @@ -488,6 +491,7 @@ import type { CollaboratorPointer, ToolType, OnUserFollowedPayload, + ObservedAppState, UnsubscribeCallback, EmbedsValidationStatus, ElementsPendingErasure, @@ -637,6 +641,7 @@ class App extends React.Component { public library: AppClassProperties["library"]; public libraryItemsFromStorage: LibraryItems | undefined; public id: string; + public transactionManager: TransactionManager; private store: Store; private history: History; public excalidrawContainerValue: { @@ -832,6 +837,7 @@ class App extends React.Component { this.store = new Store(this); this.history = new History(this.store); + this.transactionManager = new TransactionManager(this); this.excalidrawContainerValue = { container: this.excalidrawContainerRef.current, @@ -4602,6 +4608,59 @@ class App extends React.Component { ); }; + /** + * Records a synthetic durable history entry without changing the live scene. + * Useful for batching external streams into a single undoable entry after + * their visual state has already been applied with NEVER. + * + * appState patches are merged on top of the current observed appState + * baseline. This keeps unspecified observed fields stable while only the + * provided appState keys participate in the synthetic before/after diff. + */ + public commitSyntheticHistoryEntry = (params: { + logicalBefore: SceneElementsMap; + logicalAfter: SceneElementsMap; + appStateBefore?: Partial; + appStateAfter?: Partial; + }): boolean => { + const { logicalBefore, logicalAfter, appStateBefore, appStateAfter } = + params; + const observedAppStateBaseline = this.store.snapshot.appState; + const syntheticAppStateBefore = appStateBefore + ? { ...observedAppStateBaseline, ...appStateBefore } + : observedAppStateBaseline; + const syntheticAppStateAfter = appStateAfter + ? { ...observedAppStateBaseline, ...appStateAfter } + : observedAppStateBaseline; + const didAppStateChange = Boolean(appStateBefore || appStateAfter); + const prevSnapshot = StoreSnapshot.create( + logicalBefore, + syntheticAppStateBefore, + { + didElementsChange: true, + didAppStateChange, + }, + ); + const nextSnapshot = StoreSnapshot.create( + logicalAfter, + syntheticAppStateAfter, + { + didElementsChange: true, + didAppStateChange, + }, + ); + const change = StoreChange.create(prevSnapshot, nextSnapshot); + const delta = StoreDelta.calculate(prevSnapshot, nextSnapshot); + + if (delta.isEmpty()) { + return false; + } + + this.store.enqueueIsolatedIncrement({ change, delta }); + + return true; + }; + public mutateElement = >( element: TElement, updates: ElementUpdate, diff --git a/packages/excalidraw/transaction/manager.ts b/packages/excalidraw/transaction/manager.ts new file mode 100644 index 0000000000..87ec64b908 --- /dev/null +++ b/packages/excalidraw/transaction/manager.ts @@ -0,0 +1,241 @@ +import { randomId } from "@excalidraw/common"; +import { + DEFAULT_TRANSACTION_MERGE_POLICY, + TransactionLedger, + collectChangedElementIds, +} from "@excalidraw/element"; +import { deepCopyElement } from "@excalidraw/element"; + +import type { TransactionMergePolicy } from "@excalidraw/element"; +import type { ExcalidrawElement } from "@excalidraw/element/types"; + +import type { AppClassProperties } from "../types"; +import type { + TransactionAppStateSnapshot, + TransactionMutationResult, + TransactionStatus, + TransactionSummary, +} from "./types"; + +type ApplyOptions = { + isApplied?: (value: T, changedElementIds: readonly string[]) => boolean; + reasonWhenSkipped?: (value: T) => string | undefined; +}; + +type TransactionFinalState = "finalized" | "canceled"; + +/** Takes a point-in-time deep snapshot so later mutation diffing stays stable. */ +const snapshotSceneElementsById = (app: AppClassProperties) => { + const snapshot = new Map(); + for (const element of app.scene.getElementsIncludingDeleted()) { + snapshot.set(element.id, deepCopyElement(element)); + } + return snapshot; +}; + +/** App-level transaction session that records mutations and commits one synthetic history entry. */ +export class TransactionSession { + public readonly id = `tx-${randomId()}`; + + private readonly app: AppClassProperties; + private readonly mergePolicy: TransactionMergePolicy; + private readonly ledger = new TransactionLedger(); + private readonly touchedElementIds = new Set(); + + private appStateSnapshot: TransactionAppStateSnapshot | null = null; + private appliedMutations = 0; + private historyCommitted = false; + private statusValue: TransactionStatus = "active"; + private cachedSummary: TransactionSummary | null = null; + + constructor( + app: AppClassProperties, + mergePolicy?: Partial, + ) { + this.app = app; + this.mergePolicy = { + ...DEFAULT_TRANSACTION_MERGE_POLICY, + ...mergePolicy, + }; + } + + get status() { + return this.statusValue; + } + + /** Returns a standardized skipped result once the session is no longer active. */ + private rejectIfInactive(): TransactionMutationResult | null { + if (this.statusValue === "active") { + return null; + } + return { + applied: false, + changedElementIds: [], + reason: `Transaction already ${this.statusValue}.`, + }; + } + + /** Computes changed element ids between two snapshots. */ + private getChangedElementIds( + before: ReadonlyMap, + after: ReadonlyMap, + ) { + return collectChangedElementIds(before, after); + } + + /** Persists one mutation into ledger state and session-level counters. */ + private recordMutation( + before: ReadonlyMap, + after: ReadonlyMap, + changedElementIds: readonly string[], + applied: boolean, + ) { + if (applied) { + for (const changedId of changedElementIds) { + this.touchedElementIds.add(changedId); + } + this.ledger.recordStep(before, after); + this.appliedMutations += 1; + } + } + + /** Executes one mutation function and records the resulting scene diff. */ + async apply( + mutate: () => T | Promise, + options?: ApplyOptions, + ): Promise> { + const inactiveResult = this.rejectIfInactive(); + if (inactiveResult) { + return inactiveResult; + } + + const before = snapshotSceneElementsById(this.app); + const value = await mutate(); + const after = this.app.scene.getElementsMapIncludingDeleted(); + const changedElementIds = this.getChangedElementIds(before, after); + const applied = options?.isApplied + ? options.isApplied(value, changedElementIds) + : changedElementIds.length > 0; + + this.recordMutation(before, after, changedElementIds, applied); + + return { + value, + applied, + changedElementIds, + reason: !applied ? options?.reasonWhenSkipped?.(value) : undefined, + }; + } + + /** Records a caller-provided before/after snapshot pair into the session ledger. */ + capture( + before: ReadonlyMap, + after: ReadonlyMap, + ): TransactionMutationResult { + const inactiveResult = this.rejectIfInactive(); + if (inactiveResult) { + return inactiveResult; + } + + const changedElementIds = this.getChangedElementIds(before, after); + this.recordMutation( + before, + after, + changedElementIds, + changedElementIds.length > 0, + ); + + return { + applied: changedElementIds.length > 0, + changedElementIds, + }; + } + + /** Stores optional observed appState before/after for synthetic history commit. */ + setAppStatePatch(snapshot: TransactionAppStateSnapshot | null) { + this.appStateSnapshot = snapshot; + } + + /** Finalizes once and memoizes the resulting summary for idempotent calls. */ + private complete(state: TransactionFinalState): TransactionSummary { + if (this.cachedSummary) { + return this.cachedSummary; + } + + if (this.ledger.hasEntries()) { + const liveMap = this.app.scene.getElementsMapIncludingDeleted(); + const { logicalBefore, logicalAfter } = + this.ledger.buildSyntheticSnapshots(liveMap, this.mergePolicy); + this.historyCommitted = this.app.commitSyntheticHistoryEntry({ + logicalBefore, + logicalAfter, + appStateBefore: this.appStateSnapshot?.before, + appStateAfter: this.appStateSnapshot?.after, + }); + } + + this.statusValue = state; + this.cachedSummary = { + id: this.id, + state, + appliedMutations: this.appliedMutations, + touchedElementIds: [...this.touchedElementIds], + historyCommitted: this.historyCommitted, + }; + return this.cachedSummary; + } + + /** Commits the session and returns a stable summary on repeated calls. */ + commit() { + if (this.statusValue === "active") { + return this.complete("finalized"); + } + return this.complete(this.statusValue); + } + + /** Cancels the session while preserving already-applied visual mutations. */ + cancel() { + if (this.statusValue === "active") { + return this.complete("canceled"); + } + return this.complete(this.statusValue); + } +} + +/** Factory/executor facade used by app features to open and run transaction sessions. */ +export class TransactionManager { + private readonly app: AppClassProperties; + + constructor(app: AppClassProperties) { + this.app = app; + } + + /** Opens a new transaction session with optional merge-policy overrides. */ + open(input?: { mergePolicy?: Partial }) { + return new TransactionSession(this.app, input?.mergePolicy); + } + + /** Runs work inside one session and always attempts cleanup on failure. */ + async run( + work: (session: TransactionSession) => TResult | Promise, + input?: { mergePolicy?: Partial }, + ) { + const session = this.open(input); + try { + const result = await work(session); + const summary = session.commit(); + return { result, summary }; + } catch (error) { + // Preserve the original work() failure even if cancel path fails. + try { + session.cancel(); + } catch (cancelError) { + console.error("Failed to cancel transaction after run() failure.", { + error, + cancelError, + }); + } + throw error; + } + } +} diff --git a/packages/excalidraw/transaction/types.ts b/packages/excalidraw/transaction/types.ts new file mode 100644 index 0000000000..301faab8a6 --- /dev/null +++ b/packages/excalidraw/transaction/types.ts @@ -0,0 +1,27 @@ +import type { ObservedAppState } from "../types"; + +/** Lifecycle state of a transaction session. */ +export type TransactionStatus = "active" | "finalized" | "canceled"; + +/** Optional appState patch to include in synthetic history before/after snapshots. */ +export type TransactionAppStateSnapshot = { + before: Partial; + after: Partial; +}; + +/** Result returned by one recorded mutation attempt inside a transaction. */ +export type TransactionMutationResult = { + value?: T; + applied: boolean; + changedElementIds: readonly string[]; + reason?: string; +}; + +/** Final summary returned when a transaction is committed or canceled. */ +export type TransactionSummary = { + id: string; + state: "finalized" | "canceled"; + appliedMutations: number; + touchedElementIds: readonly string[]; + historyCommitted: boolean; +}; diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index a41512fe64..dcc56fea92 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -796,7 +796,9 @@ export type AppClassProperties = { files: BinaryFiles; editorInterface: App["editorInterface"]; scene: App["scene"]; + transactionManager: App["transactionManager"]; syncActionResult: App["syncActionResult"]; + commitSyntheticHistoryEntry: App["commitSyntheticHistoryEntry"]; fonts: App["fonts"]; pasteFromClipboard: App["pasteFromClipboard"]; id: App["id"];