From 73cb52a4bbbc6e7fe084111ef9e0f5e5132bc92d Mon Sep 17 00:00:00 2001 From: chh-ay Date: Fri, 9 Oct 2026 00:00:55 +0700 Subject: [PATCH 1/2] perf(core): prepare compressed restores once per application --- .changeset/prepare-restore-once.md | 5 + bench/src/core-paths-bench.ts | 59 ++++- packages/core/src/document-controller.ts | 14 +- packages/core/src/document-protocol.ts | 23 +- packages/core/src/rebase.ts | 76 +++++-- packages/core/src/restore-block.ts | 18 +- packages/core/src/store.ts | 213 ++++++++++--------- packages/core/src/transaction-preparation.ts | 111 ++++++++++ packages/core/test/rebase.test.ts | 53 +++++ packages/core/test/row-bridge.test.ts | 44 ++++ packages/core/test/store.test.ts | 65 ++++++ 11 files changed, 538 insertions(+), 143 deletions(-) create mode 100644 .changeset/prepare-restore-once.md create mode 100644 packages/core/src/transaction-preparation.ts diff --git a/.changeset/prepare-restore-once.md b/.changeset/prepare-restore-once.md new file mode 100644 index 00000000..db55a9cf --- /dev/null +++ b/.changeset/prepare-restore-once.md @@ -0,0 +1,5 @@ +--- +"@sheetwrite/core": minor +--- + +Reuse decoded compressed restore blocks during synchronous transaction application and row projection, while revalidating payloads changed by host callbacks. Large-clear undo uses its already materialized block without decompressing it again. Rebase decodes each restore once across structural edits and serializes it once when complete. Document operations and change events retain their existing wire format. diff --git a/bench/src/core-paths-bench.ts b/bench/src/core-paths-bench.ts index 4815f6ed..825f7898 100644 --- a/bench/src/core-paths-bench.ts +++ b/bench/src/core-paths-bench.ts @@ -60,6 +60,7 @@ import { import { createGridController, type GridControllerHandlers } from "@sheetwrite/core/adapter"; import { installCanvasTestStubs } from "@sheetwrite/core/testing"; import { initSync } from "@sheetwrite/wasm"; +import { encodeRestoreBlock } from "../../packages/core/src/restore-block.js"; import { assertFiniteNonNegative, type BenchmarkMode, validateRawStat } from "./gate-protocol.js"; import { type ProtocolCaptureMeta, protocolCaptureMeta } from "./protocol-meta.js"; @@ -108,6 +109,7 @@ export const CORE_PATH_SCENARIOS = [ "validation-sparse", "csv-import", "undo-large-clear", + "restore-near-ceiling", "paged-evicted-revisit", "node-cold-init", ] as const; @@ -153,11 +155,9 @@ const FULL_SCALE: CorePathScale = { validationRules: 100, csvRows: 200_000, csvColumns: 10, - // Undo restores the cleared numbers as one transaction. 500,000 cells stay - // under the default 8 MiB payload limit; 1,000,000 cells are just above it, - // and the Grid then rejects that undo by design. + // A million-cell clear exceeds ordinary wire admission and must compact on undo. clearRows: 1_000, - clearColumns: 500, + clearColumns: 1_000, pagedRows: 200_000, }; @@ -208,6 +208,7 @@ function samplePlan(id: CorePathScenarioId, mode: BenchmarkMode): SamplePlan { case "csv-import": return { warmup: 1, iters: smoke ? 3 : 2, gcBetween: true }; case "undo-large-clear": + case "restore-near-ceiling": return { warmup: 1, iters: 3, gcBetween: true }; case "paged-evicted-revisit": return { warmup: 1, iters: 3, gcBetween: false }; @@ -1049,6 +1050,38 @@ function importCsvText(scale: CorePathScale, plan: SamplePlan): CorePathScenario ); } +// A repetitive 60 MiB decoded payload is valid under both restore ceilings. +// Encoding and initial sheet construction are deliberately outside the samples. +function restoreNearCeiling(scale: CorePathScale, plan: SamplePlan): CorePathScenarioResult { + const rows = scale === FULL_SCALE ? 4_000 : 100; + const columns = scale === FULL_SCALE ? 1_000 : 100; + const value = "restore-text"; + const operation = encodeRestoreBlock(sheetRange(rows, columns), { + rowCount: rows, + colCount: columns, + values: new Array(rows * columns).fill(value), + }); + const mounted = mountGrid({ workbook: workbook(rows, numberColumns(columns)) }); + const samplesMs = collect(() => { + assertApplied(mounted.grid.applyRemoteOperations([operation]).status, "restoreBlock"); + }, plan); + if (resolvedCell(mounted.grid, rows - 1, columns - 1) !== value) { + fail("near-ceiling restore did not apply the final cell"); + } + mounted.destroy(); + return scenarioResult( + "restore-near-ceiling", + `one restoreBlock with ${operation.decodedBytes} decoded bytes`, + samplesMs, + { + restoredCells: rows * columns, + decodedBytes: operation.decodedBytes, + peakRssBytes: process.resourceUsage().maxRSS * 1024, + }, + "a valid near-ceiling compressed restore applies its complete block", + ); +} + // ── undo-large-clear ──────────────────────────────────────────────────────── function undoLargeClear(scale: CorePathScale, plan: SamplePlan): CorePathScenarioResult { @@ -1067,6 +1100,14 @@ function undoLargeClear(scale: CorePathScale, plan: SamplePlan): CorePathScenari data, }); const { grid } = mounted; + let compactRestores = 0; + grid.on("change", (event) => { + if (event.commitReason === "undo") { + compactRestores += event.transaction.patches.filter( + (operation) => operation.op === "restoreBlock", + ).length; + } + }); const clearOperation: DocumentOp = { op: "clearRange", range: sheetRange(scale.clearRows, scale.clearColumns), @@ -1105,13 +1146,20 @@ function undoLargeClear(scale: CorePathScale, plan: SamplePlan): CorePathScenari // Every cell is compared against its original value after timing: a partial // restore fails here even though each undo returned normally. assertWindowedValues(grid, scale.clearRows, scale.clearColumns, 0, "undo restore"); + if (scale === FULL_SCALE && compactRestores === 0) fail("large-clear undo did not compact"); mounted.destroy(); return scenarioResult( "undo-large-clear", `one undo of a ${cellCount.toLocaleString("en-US")}-cell clearRange`, samplesMs, - { clearedCells: cellCount, clears, restoredCells: cellCount }, + { + clearedCells: cellCount, + clears, + restoredCells: cellCount, + compactRestores, + peakRssBytes: process.resourceUsage().maxRSS * 1024, + }, "the clear empties the block and the undos restore every original cell value", ); } @@ -1398,6 +1446,7 @@ export const CORE_PATH_SCENARIO_RUNNERS: Readonly< "validation-sparse": writeSparseRangeWithRules, "csv-import": importCsvText, "undo-large-clear": undoLargeClear, + "restore-near-ceiling": restoreNearCeiling, "paged-evicted-revisit": revisitEvictedBand, "node-cold-init": (_scale, plan) => loadWasmInFreshNode(plan), }; diff --git a/packages/core/src/document-controller.ts b/packages/core/src/document-controller.ts index a481166f..06820e40 100644 --- a/packages/core/src/document-controller.ts +++ b/packages/core/src/document-controller.ts @@ -23,6 +23,7 @@ import { cloneCellHyperlink } from "./hyperlink.js"; import { encodeRestoreBlock, estimateRestoreBlockBytes } from "./restore-block.js"; import type { SheetwriteStore } from "./store.js"; import type { GridTransactionAdmissionDecision } from "./transaction-admission.js"; +import { TransactionPreparation } from "./transaction-preparation.js"; import type { CellValue, Column } from "./types/cell.js"; import type { CellAddress, MergeRange, Range, SheetId } from "./types/coordinates.js"; import type { @@ -845,10 +846,12 @@ export class DocumentController { this.applyingHistory = true; let outcome: ApplyTransactionResult; + const preparation = new TransactionPreparation(); try { - const fitted = this.fitHistoryPatches(patches); + const fitted = this.fitHistoryPatches(patches, preparation); outcome = this.commit(fitted.patches, reason, fitted.admitted); } finally { + preparation.dispose(); this.applyingHistory = false; } if (outcome.status !== "applied") return outcome; @@ -867,7 +870,10 @@ export class DocumentController { * multi-version commit in sync. The returned measurement lets the commit * skip a second walk of the payload. */ - private fitHistoryPatches(patches: DocumentOp[]): { + private fitHistoryPatches( + patches: DocumentOp[], + preparation: TransactionPreparation, + ): { patches: DocumentOp[]; admitted?: AdmittedTransactionResources; } { @@ -881,7 +887,9 @@ export class DocumentController { const compact = patches.map((patch) => { if (patch.op !== "setBlock") return patch; try { - return encodeRestoreBlock(patch.range, patch.block); + const operation = encodeRestoreBlock(patch.range, patch.block); + preparation.seed(operation, patch.block); + return operation; } catch (error) { // Above the restore block caps the block stays a candidate for splitting. if (!(error instanceof RangeError)) throw error; diff --git a/packages/core/src/document-protocol.ts b/packages/core/src/document-protocol.ts index 9efdd4a8..a5bb6b37 100644 --- a/packages/core/src/document-protocol.ts +++ b/packages/core/src/document-protocol.ts @@ -11,7 +11,7 @@ import { MAX_HYPERLINK_DISPLAY_LENGTH, MAX_HYPERLINKS_PER_SHEET, } from "./hyperlink.js"; -import { decodeRestoreBlockPayload } from "./restore-block-codec.js"; +import { preparedRestore } from "./transaction-preparation.js"; import type { MergeRange, Range } from "./types/coordinates.js"; import type { DocumentOp, @@ -1996,15 +1996,26 @@ export function validateDocumentOperationShape( } if (errors.length) break; try { - const block = decodeRestoreBlockPayload( + const prepared = preparedRestore( operation as unknown as Extract, + validateDocumentOperationShape, ); errors.push( - ...validateDocumentOperationShape( - { op: "setBlock", range: ownValue(operation, "range"), block }, - path, - ), + ...prepared.errors.map((error) => ({ + ...error, + path: path + error.path.slice("operation".length), + })), ); + if (prepared.errors.length === 0) { + const range = operation.range as Range; + if ( + prepared.block.rowCount !== Math.abs(range.end.row - range.start.row) + 1 || + prepared.block.colCount !== Math.abs(range.end.col - range.start.col) + 1 || + (prepared.block.styleIds !== undefined && prepared.block.styleTable === undefined) + ) { + invalid(errors, path, "Invalid restore block"); + } + } } catch (error) { invalid(errors, path, error instanceof Error ? error.message : "Invalid restore block"); } diff --git a/packages/core/src/rebase.ts b/packages/core/src/rebase.ts index db70255d..f8ed62aa 100644 --- a/packages/core/src/rebase.ts +++ b/packages/core/src/rebase.ts @@ -28,6 +28,9 @@ export type DocumentRebaseResult = type Axis = "row" | "column"; +type RestoreOperation = Extract; +type RestoreBlockReader = (operation: RestoreOperation) => PackedCellBlock; + type AxisChange = | { kind: "insert"; axis: Axis; sheet: string; at: number; count: number } | { kind: "delete"; axis: Axis; sheet: string; at: number; count: number } @@ -50,14 +53,30 @@ export function rebaseDocumentOperations( remoteOperations: readonly DocumentOp[], ): DocumentRebaseResult { const operations: DocumentOp[] = cloneJsonValue([...localOperations]); + // Only private clones enter this scope; transformed refs remain decoded until + // every remote operation succeeds, without retaining state across rebases. + const restoreBlocks = new Map(); + const restoreBlock: RestoreBlockReader = (operation) => { + let block = restoreBlocks.get(operation); + if (!block) { + block = decodeRestoreBlock(operation); + restoreBlocks.set(operation, block); + } + return block; + }; + let hasStructuralTransforms = false; for (let remoteIndex = 0; remoteIndex < remoteOperations.length; remoteIndex++) { const remote = remoteOperations[remoteIndex]!; const axisChange = structuralChange(remote); + if (axisChange && axisChange.kind !== "move") hasStructuralTransforms = true; for (let localIndex = 0; localIndex < operations.length; localIndex++) { const local = operations[localIndex]!; if (axisChange) { - if (axisChange.kind === "move" && operationTouchesSheet(local, axisChange.sheet)) { + if ( + axisChange.kind === "move" && + operationTouchesSheet(local, axisChange.sheet, restoreBlock) + ) { return conflict( "unsupported-structural", localIndex, @@ -65,7 +84,7 @@ export function rebaseDocumentOperations( "Concurrent row/column moves require host review; index intent is ambiguous", ); } - if (operationContainsFormula(local)) { + if (operationContainsFormula(local, restoreBlock)) { return conflict( "formula-structural", localIndex, @@ -73,7 +92,7 @@ export function rebaseDocumentOperations( "Formula source cannot be rebased across a concurrent structural edit without parsing sheet-aware references", ); } - const transformed = transformForAxis(local, axisChange); + const transformed = transformForAxis(local, axisChange, restoreBlock); if ("code" in transformed) { return conflict(transformed.code, localIndex, remoteIndex, transformed.message); } @@ -81,7 +100,7 @@ export function rebaseDocumentOperations( continue; } - const lifecycle = lifecycleConflict(local, remote); + const lifecycle = lifecycleConflict(local, remote, restoreBlock); if (lifecycle) { return conflict(lifecycle.code, localIndex, remoteIndex, lifecycle.message); } @@ -96,6 +115,12 @@ export function rebaseDocumentOperations( } } + if (hasStructuralTransforms) { + for (const [operation, block] of restoreBlocks) { + Object.assign(operation, encodeRestoreBlock(operation.range, block)); + } + } + return { status: "rebased", operations }; } @@ -155,18 +180,19 @@ function structuralChange(operation: DocumentOp): AxisChange | null { } /** - * Transforms one caller-owned operation in place. `rebaseDocumentOperations` + * Transforms one privately owned operation in place. `rebaseDocumentOperations` * already works on its own deep clone of the caller's operations, so no copy * is needed here; the original array the caller passed in is never touched. */ function transformForAxis( input: DocumentOp, change: AxisChange, + restoreBlock: RestoreBlockReader, ): { operation: DocumentOp } | TransformFailure { if (change.kind === "move") return { operation: input }; const direct = transformDirectTarget(input, change); if (direct) return direct; - const embedded = transformEmbeddedReferences(input, change); + const embedded = transformEmbeddedReferences(input, change, restoreBlock); return embedded ? embedded : { operation: input }; } @@ -362,6 +388,7 @@ function transformDirectTarget( function transformEmbeddedReferences( operation: DocumentOp, change: Exclude, + restoreBlock: RestoreBlockReader, ): TransformFailure | null { switch (operation.op) { case "set": { @@ -374,13 +401,8 @@ function transformEmbeddedReferences( return transformSnapshotCells(operation.cells, change); case "setBlock": return transformPackedBlock(operation.block, change); - case "restoreBlock": { - const block = decodeRestoreBlock(operation); - const failure = transformPackedBlock(block, change); - if (failure) return failure; - Object.assign(operation, encodeRestoreBlock(operation.range, block)); - return null; - } + case "restoreBlock": + return transformPackedBlock(restoreBlock(operation), change); case "addSheet": return transformSheetSnapshot(operation.sheet, change); default: @@ -518,7 +540,10 @@ function transformPosition( return change.at; } -function operationContainsFormula(operation: DocumentOp): boolean { +function operationContainsFormula( + operation: DocumentOp, + restoreBlock: RestoreBlockReader, +): boolean { switch (operation.op) { case "set": return operation.value.kind === "formula"; @@ -527,7 +552,7 @@ function operationContainsFormula(operation: DocumentOp): boolean { case "setBlock": return (operation.block.formulas?.length ?? 0) > 0; case "restoreBlock": - return (decodeRestoreBlock(operation).formulas?.length ?? 0) > 0; + return (restoreBlock(operation).formulas?.length ?? 0) > 0; case "addSheet": return operation.sheet.cells.some((block) => block.cells.some((cell) => cell.value.kind === "formula"), @@ -537,9 +562,16 @@ function operationContainsFormula(operation: DocumentOp): boolean { } } -function lifecycleConflict(local: DocumentOp, remote: DocumentOp): TransformFailure | null { +function lifecycleConflict( + local: DocumentOp, + remote: DocumentOp, + restoreBlock: RestoreBlockReader, +): TransformFailure | null { if (remote.op === "removeSheet") { - if (operationTouchesSheet(local, remote.sheet) || operationContainsFormula(local)) { + if ( + operationTouchesSheet(local, remote.sheet, restoreBlock) || + operationContainsFormula(local, restoreBlock) + ) { return { code: "sheet-removed", message: "Pending work targets or may reference a removed sheet", @@ -556,7 +588,7 @@ function lifecycleConflict(local: DocumentOp, remote: DocumentOp): TransformFail if (remote.op === "renameSheet") { if ( (local.op === "renameSheet" && local.sheet === remote.sheet) || - operationContainsFormula(local) + operationContainsFormula(local, restoreBlock) ) { return { code: "sheet-lifecycle", @@ -591,7 +623,11 @@ function lifecycleConflict(local: DocumentOp, remote: DocumentOp): TransformFail return null; } -function operationTouchesSheet(operation: DocumentOp, sheet: string): boolean { +function operationTouchesSheet( + operation: DocumentOp, + sheet: string, + restoreBlock: RestoreBlockReader, +): boolean { switch (operation.op) { case "set": return ( @@ -615,7 +651,7 @@ function operationTouchesSheet(operation: DocumentOp, sheet: string): boolean { case "restoreBlock": return ( operation.range.sheet === sheet || - (decodeRestoreBlock(operation).refs?.some((tuple) => tuple[1].sheet === sheet) ?? false) + (restoreBlock(operation).refs?.some((tuple) => tuple[1].sheet === sheet) ?? false) ); case "setRangeStyle": case "clearRange": diff --git a/packages/core/src/restore-block.ts b/packages/core/src/restore-block.ts index 6c5fb56e..707fb8ab 100644 --- a/packages/core/src/restore-block.ts +++ b/packages/core/src/restore-block.ts @@ -1,11 +1,8 @@ import { deflateSync } from "fflate"; import { validateDocumentOperationShape } from "./document-protocol.js"; import { boundedJsonByteLength, JsonByteLengthError } from "./errors.js"; -import { - decodeRestoreBlockPayload, - MAX_RESTORE_BLOCK_CELLS, - MAX_RESTORE_BLOCK_DECODED_BYTES, -} from "./restore-block-codec.js"; +import { MAX_RESTORE_BLOCK_CELLS, MAX_RESTORE_BLOCK_DECODED_BYTES } from "./restore-block-codec.js"; +import { preparedRestore } from "./transaction-preparation.js"; import type { Range } from "./types/coordinates.js"; import type { DocumentOp, PackedCellBlock } from "./types/document.js"; @@ -98,7 +95,14 @@ export function estimateRestoreBlockBytes(block: PackedCellBlock): number { } export function decodeRestoreBlock(operation: RestoreBlock): PackedCellBlock { - const block = decodeRestoreBlockPayload(operation); - assertBlock(operation.range, block); + const prepared = preparedRestore(operation, validateDocumentOperationShape); + if (prepared.errors.length > 0) invalid(); + const block = prepared.block; + if ( + block.rowCount !== Math.abs(operation.range.end.row - operation.range.start.row) + 1 || + block.colCount !== Math.abs(operation.range.end.col - operation.range.start.col) + 1 || + (block.styleIds !== undefined && block.styleTable === undefined) + ) + invalid(); return block; } diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 26be5434..b18171c1 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -17,7 +17,6 @@ import type { RuntimeResourceSnapshot, TransientResourcePeak, } from "./resource-accounting.js"; -import { decodeRestoreBlock } from "./restore-block.js"; import { applySheetLifecycleOperation, createSheetLifecycleState } from "./sheet-lifecycle.js"; import { type CompactRangeHistory, @@ -31,6 +30,7 @@ import { StoreMutationPolicy } from "./store/mutation-policy.js"; import { patchSheetId } from "./store/ranges.js"; import { decodeWorkbookSnapshot } from "./store/snapshot-codec.js"; import { setTransactionStorageRevision } from "./transaction-admission.js"; +import { preparedRestore, TransactionPreparation } from "./transaction-preparation.js"; import type { CellScalar, Column } from "./types/cell.js"; import type { CellAddress, Range, SheetId } from "./types/coordinates.js"; import type { AggregateOp, ColumnarData, DataSourceColumnBand, RowData } from "./types/data.js"; @@ -488,97 +488,116 @@ export class SheetwriteStore implements Store { tx: Transaction, reasonOrOptions: CommitReason | TransactionApplicationOptions = {}, ): ApplyTransactionResult { - // A transaction built and measured by an engine layer carries its resource - // record; without one, direct callers pay the full payload walk. An - // engine-built atomic batch is bounded by the batch ceilings instead. - const limits = isAtomicBatch(tx.patches) - ? atomicBatchLimits(this.transactionResourceLimits) - : this.transactionResourceLimits; - const resourceValidation = resolveTransactionResourceValidation( - tx.patches, - limits, - takeAdmittedTransactionResources(tx), - ).result; - if (!resourceValidation.ok) { - return { status: "rejected", epoch: this.epoch, issues: [resourceValidation.issue] }; - } - const invalidOperations = this.validateTransactionOperations(tx); - if (invalidOperations) return invalidOperations; - const options = - typeof reasonOrOptions === "string" ? { commitReason: reasonOrOptions } : reasonOrOptions; - const commitReason = options.commitReason ?? "api"; - const source = options.source ?? "local"; - if (tx.epoch !== undefined && tx.epoch !== this.epoch) { - return { status: "conflict", expectedEpoch: tx.epoch, actualEpoch: this.epoch }; - } - - let effectiveTx = tx; - let policyWarnings: MutationIssue[] = []; - let policyRejections: MutationIssue[] = []; - if (source === "local") { - const policy = this.policy.evaluate( + const preparation = new TransactionPreparation(tx.patches); + try { + // A transaction built and measured by an engine layer carries its resource + // record; without one, direct callers pay the full payload walk. An + // engine-built atomic batch is bounded by the batch ceilings instead. + const limits = isAtomicBatch(tx.patches) + ? atomicBatchLimits(this.transactionResourceLimits) + : this.transactionResourceLimits; + const resourceValidation = resolveTransactionResourceValidation( tx.patches, - commitReason, - this.protectionResolver, - this.mutationPolicy, - ); - policyWarnings = policy.warnings; - policyRejections = policy.rejections; - if (policy.rejections.length > 0 && policy.patches.length === 0) { - return { status: "rejected", epoch: this.epoch, issues: policy.rejections }; + limits, + takeAdmittedTransactionResources(tx), + ).result; + if (!resourceValidation.ok) { + return { status: "rejected", epoch: this.epoch, issues: [resourceValidation.issue] }; } - if (policy.patches.length !== tx.patches.length) { - effectiveTx = { ...tx, patches: policy.patches }; + const invalidOperations = this.validateTransactionOperations(tx); + if (invalidOperations) return invalidOperations; + const options = + typeof reasonOrOptions === "string" ? { commitReason: reasonOrOptions } : reasonOrOptions; + const commitReason = options.commitReason ?? "api"; + const source = options.source ?? "local"; + if (tx.epoch !== undefined && tx.epoch !== this.epoch) { + return { status: "conflict", expectedEpoch: tx.epoch, actualEpoch: this.epoch }; } - if (this.protectionResolver) { - // Permission callbacks receive live operations and may edit nested - // payloads after ingress validation. Do not reuse their measurements. - const callbackResources = resolveTransactionResourceValidation( - effectiveTx.patches, - limits, - ).result; - if (!callbackResources.ok) { - return { status: "rejected", epoch: this.epoch, issues: [callbackResources.issue] }; + + let effectiveTx = tx; + let policyWarnings: MutationIssue[] = []; + let policyRejections: MutationIssue[] = []; + if (source === "local") { + const policy = this.policy.evaluate( + tx.patches, + commitReason, + this.protectionResolver, + this.mutationPolicy, + ); + policyWarnings = policy.warnings; + policyRejections = policy.rejections; + if (policy.rejections.length > 0 && policy.patches.length === 0) { + return { status: "rejected", epoch: this.epoch, issues: policy.rejections }; + } + if (policy.patches.length !== tx.patches.length) { + effectiveTx = { ...tx, patches: policy.patches }; + } + if (this.protectionResolver) { + // Permission callbacks receive live operations and may edit nested + // payloads after ingress validation. Do not reuse their measurements. + const callbackResources = resolveTransactionResourceValidation( + effectiveTx.patches, + limits, + ).result; + if (!callbackResources.ok) { + return { status: "rejected", epoch: this.epoch, issues: [callbackResources.issue] }; + } + const callbackOperations = this.validateTransactionOperations(effectiveTx); + if (callbackOperations) return callbackOperations; } - const callbackOperations = this.validateTransactionOperations(effectiveTx); - if (callbackOperations) return callbackOperations; } - } - if (source === "local" || options.localReplay === true) { - const dirtyCapacityIssue = this.engine.pagedDirtyCapacityIssue(effectiveTx.patches); - if (dirtyCapacityIssue) { - return { status: "rejected", epoch: this.epoch, issues: [dirtyCapacityIssue] }; + if (source === "local" || options.localReplay === true) { + const dirtyCapacityIssue = this.engine.pagedDirtyCapacityIssue(effectiveTx.patches); + if (dirtyCapacityIssue) { + return { status: "rejected", epoch: this.epoch, issues: [dirtyCapacityIssue] }; + } } - } - if ( - source === "local" && - effectiveTx.patches.some((patch) => !this.engine.canApplyLocally(patch)) - ) { - return { status: "noop", epoch: this.epoch, reason: "incomplete-data" }; - } - const hasListeners = this.listeners.size > 0; - const effects = this.engine.applyPatches( - effectiveTx.patches, - source === "remote" && options.localReplay !== true, - hasListeners, - this.detailedChangeCapture, - ); - if (effects.appliedPatches.length === 0) { - return { - status: "noop", + if ( + source === "local" && + effectiveTx.patches.some((patch) => !this.engine.canApplyLocally(patch)) + ) { + return { status: "noop", epoch: this.epoch, reason: "incomplete-data" }; + } + const hasListeners = this.listeners.size > 0; + const effects = this.engine.applyPatches( + effectiveTx.patches, + source === "remote" && options.localReplay !== true, + hasListeners, + this.detailedChangeCapture, + ); + if (effects.appliedPatches.length === 0) { + return { + status: "noop", + epoch: this.epoch, + reason: effectiveTx.patches.length === 0 ? "empty" : "out-of-bounds", + }; + } + + this.epoch += 1; + const transaction = + effects.appliedPatches.length === effectiveTx.patches.length + ? effectiveTx + : { ...effectiveTx, patches: effects.appliedPatches }; + setTransactionStorageRevision(transaction, effects.storageRevision); + if (!hasListeners) { + return { + status: "applied", + epoch: this.epoch, + transaction, + ...(policyWarnings.length > 0 ? { warnings: policyWarnings } : {}), + ...(policyRejections.length > 0 ? { rejections: policyRejections } : {}), + }; + } + + const event: ChangeEvent = { + transaction, + changes: effects.changes ?? [], + commitReason, + source, epoch: this.epoch, - reason: effectiveTx.patches.length === 0 ? "empty" : "out-of-bounds", }; - } - - this.epoch += 1; - const transaction = - effects.appliedPatches.length === effectiveTx.patches.length - ? effectiveTx - : { ...effectiveTx, patches: effects.appliedPatches }; - setTransactionStorageRevision(transaction, effects.storageRevision); - if (!hasListeners) { + for (const listener of this.listeners) listener(event); return { status: "applied", epoch: this.epoch, @@ -586,23 +605,9 @@ export class SheetwriteStore implements Store { ...(policyWarnings.length > 0 ? { warnings: policyWarnings } : {}), ...(policyRejections.length > 0 ? { rejections: policyRejections } : {}), }; + } finally { + preparation.dispose(); } - - const event: ChangeEvent = { - transaction, - changes: effects.changes ?? [], - commitReason, - source, - epoch: this.epoch, - }; - for (const listener of this.listeners) listener(event); - return { - status: "applied", - epoch: this.epoch, - transaction, - ...(policyWarnings.length > 0 ? { warnings: policyWarnings } : {}), - ...(policyRejections.length > 0 ? { rejections: policyRejections } : {}), - }; } on(_evt: "change", fn: ChangeListener): () => void { @@ -719,9 +724,13 @@ export class SheetwriteStore implements Store { if (cell.value.kind === "ref") requiredSheets.push(cell.value.target.sheet); } } else if (operation.op === "setBlock" || operation.op === "restoreBlock") { - const block = - operation.op === "restoreBlock" ? decodeRestoreBlock(operation) : operation.block; - for (const [, target] of block.refs ?? []) requiredSheets.push(target.sheet); + if (operation.op === "restoreBlock") { + requiredSheets.push( + ...preparedRestore(operation, validateDocumentOperationShape).requiredSheets, + ); + } else { + for (const [, target] of operation.block.refs ?? []) requiredSheets.push(target.sheet); + } } else if (operation.op === "setNamedRange") { requiredSheets.push(operation.namedRange.range.sheet); if (operation.namedRange.scope !== undefined) { diff --git a/packages/core/src/transaction-preparation.ts b/packages/core/src/transaction-preparation.ts new file mode 100644 index 00000000..874e040f --- /dev/null +++ b/packages/core/src/transaction-preparation.ts @@ -0,0 +1,111 @@ +import { decodeRestoreBlockPayload } from "./restore-block-codec.js"; +import type { DocumentOp, PackedCellBlock } from "./types/document.js"; + +type Restore = Extract; + +interface PreparedRestore { + stamp: readonly unknown[]; + block: PackedCellBlock; + errors?: readonly unknown[]; + requiredSheets: readonly string[]; + containsFormula: boolean; +} + +// Sidecars are visible only while their owning synchronous application is live. +// Nested applications restore the previous owner rather than sharing its lifetime. +const owners = new WeakMap(); + +function own(value: unknown, key: string): unknown { + if (value === null || typeof value !== "object") return undefined; + return Object.getOwnPropertyDescriptor(value, key)?.value; +} + +function stamp(operation: Restore): readonly unknown[] { + const range = own(operation, "range"); + const start = own(range, "start"); + const end = own(range, "end"); + return [ + own(operation, "encoding"), + own(operation, "decodedBytes"), + own(operation, "data"), + own(range, "sheet"), + own(start, "row"), + own(start, "col"), + own(end, "row"), + own(end, "col"), + ]; +} + +function prepare( + operation: Restore, + revision = stamp(operation), + block = decodeRestoreBlockPayload(operation), +): PreparedRestore { + return { + stamp: revision, + block, + requiredSheets: [], + containsFormula: false, + }; +} + +export class TransactionPreparation { + private readonly entries = new Map(); + private readonly previous = new Map(); + + constructor(operations: readonly DocumentOp[] = []) { + this.bind(operations); + } + + bind(operations: readonly DocumentOp[]): void { + for (const operation of operations) { + if (own(operation, "op") !== "restoreBlock" || this.previous.has(operation as Restore)) + continue; + const restore = operation as Restore; + this.previous.set(restore, owners.get(restore)); + owners.set(restore, this); + } + } + + seed(operation: Restore, block: PackedCellBlock): void { + this.bind([operation]); + this.entries.set(operation, prepare(operation, stamp(operation), block)); + } + + restore(operation: Restore): PreparedRestore { + const revision = stamp(operation); + const entry = this.entries.get(operation); + if (entry && revision.every((value, index) => Object.is(value, entry.stamp[index]))) + return entry; + const prepared = + this.previous.get(operation)?.restore(operation) ?? prepare(operation, revision); + this.entries.set(operation, prepared); + return prepared; + } + + dispose(): void { + for (const [operation, previous] of this.previous) { + if (previous) owners.set(operation, previous); + else owners.delete(operation); + } + this.entries.clear(); + this.previous.clear(); + } +} + +export function preparedRestore( + operation: Restore, + validate: (value: unknown) => readonly E[], +): Omit & { errors: readonly E[] } { + const prepared = owners.get(operation)?.restore(operation) ?? prepare(operation); + if (!prepared.errors) { + prepared.errors = validate({ op: "setBlock", range: operation.range, block: prepared.block }); + if (prepared.errors.length === 0) { + prepared.requiredSheets = [ + ...new Set((prepared.block.refs ?? []).map(([, target]) => target.sheet)), + ]; + prepared.containsFormula = (prepared.block.formulas?.length ?? 0) > 0; + } + } + return prepared as Omit & { errors: readonly E[] }; +} diff --git a/packages/core/test/rebase.test.ts b/packages/core/test/rebase.test.ts index c3a6c789..c6b01b87 100644 --- a/packages/core/test/rebase.test.ts +++ b/packages/core/test/rebase.test.ts @@ -874,6 +874,59 @@ describe("compressed restore rebasing", () => { expectConflict([original], [structure("row", "delete", 9, 1)], "structural-overlap"); }); + it("composes remote transforms into independent serialized restores and uses the latest refs for later conflicts", () => { + const local = [ + encodeRestoreBlock(target, block), + encodeRestoreBlock({ ...target, sheet: OTHER_SHEET }, block), + ]; + const remote: DocumentOp[] = [ + structure("row", "insert", 4, 2), + structure("column", "insert", 0, 2), + structure("row", "delete", 0, 1), + structure("row", "insert", 0, 3, "unrelated"), + { op: "renameSheet", sheet: OTHER_SHEET, name: "Renamed" }, + { op: "removeSheet", sheet: "unrelated" }, + ]; + const localBefore = JSON.stringify(local); + const remoteBefore = JSON.stringify(remote); + deepFreeze(local); + deepFreeze(remote); + + const result = rebaseDocumentOperations(local, remote); + if (result.status !== "rebased") throw new Error("Expected safe multitransform rebase"); + // A durable wire round trip must not depend on the live decoded scope. + const serialized: DocumentOp[] = JSON.parse(JSON.stringify(result.operations)); + expect(serialized).toHaveLength(2); + for (const [index, restored] of serialized.entries()) { + if (restored.op !== "restoreBlock") throw new Error("Expected serialized restore operation"); + expect(restored.encoding).toBe("deflate-json-v1"); + expect(restored.range).toEqual( + index === 0 + ? { + sheet: SHEET, + start: { row: 9, col: 3 }, + end: { row: 9, col: 3 }, + } + : { ...target, sheet: OTHER_SHEET }, + ); + expect(decodeRestoreBlock(restored)).toEqual({ + ...block, + refs: [[0, { sheet: SHEET, row: 10, col: 3 }]], + }); + } + + // Row 10 only contains the reference after all preceding transforms. + expectConflict( + local, + [...remote, structure("row", "delete", 10, 1)], + "structural-overlap", + 0, + remote.length, + ); + expect(JSON.stringify(local)).toBe(localBefore); + expect(JSON.stringify(remote)).toBe(remoteBefore); + }); + it("keeps conservative formula conflicts and detects restore overlap from either side", () => { const formula = encodeRestoreBlock(target, { rowCount: 1, diff --git a/packages/core/test/row-bridge.test.ts b/packages/core/test/row-bridge.test.ts index 798be182..82083571 100644 --- a/packages/core/test/row-bridge.test.ts +++ b/packages/core/test/row-bridge.test.ts @@ -1,5 +1,6 @@ import { beforeAll, describe, expect, it } from "bun:test"; import { initSheetwrite } from "../src/grid.js"; +import { encodeRestoreBlock } from "../src/restore-block.js"; import { createRowBridge } from "../src/row-bridge.js"; import { SheetwriteStore } from "../src/store.js"; import type { CellValue } from "../src/types/cell.js"; @@ -43,6 +44,49 @@ beforeAll(async () => { }); describe("RowBridge", () => { + it("projects revised restores during nested callbacks and after the event lifetime", () => { + const workbook: Workbook = { + activeSheet: "sheet1", + sheets: [ + { + id: "sheet1", + name: "Sheet 1", + rowCount: 3, + columns: [ + { key: "name", header: "Name", width: 100, type: "text" }, + { key: "amount", header: "Amount", width: 100, type: "number" }, + ], + }, + ], + }; + const store = new SheetwriteStore(workbook); + const bridge = createRowBridge({ columns, defaultRows: rows, getRowId: (row) => row.id }); + const range = { sheet: "sheet1", start: { row: 0, col: 1 }, end: { row: 0, col: 1 } }; + const restore = encodeRestoreBlock(range, { rowCount: 1, colCount: 1, values: [10] }); + let retained: ChangeEvent | undefined; + let projected: unknown; + store.on("change", (change) => { + if (change.transaction.patches[0]?.op !== "restoreBlock") return; + retained = change; + Object.assign(restore, encodeRestoreBlock(range, { rowCount: 1, colCount: 1, values: [20] })); + store.applyTransaction({ + patches: [{ op: "set", addr: { sheet: "sheet1", row: 2, col: 1 }, value: literal(99) }], + }); + projected = bridge.project(change).deltas[0]?.next; + }); + try { + expect(store.applyTransaction({ patches: [restore] }).status).toBe("applied"); + expect(projected).toEqual([literal(20)]); + expect(store.getCell({ sheet: "sheet1", row: 0, col: 1 }).resolved).toBe(10); + if (!retained) throw new Error("restore event was not emitted"); + Object.assign(restore, encodeRestoreBlock(range, { rowCount: 1, colCount: 1, values: [30] })); + const delayed = createRowBridge({ columns, defaultRows: rows, getRowId: (row) => row.id }); + expect(delayed.project(retained).deltas[0]?.next).toEqual([literal(30)]); + } finally { + store.dispose(); + } + }); + it("keeps data-space identities through insert, move, and delete", () => { const bridge = createRowBridge({ columns, diff --git a/packages/core/test/store.test.ts b/packages/core/test/store.test.ts index 8aad2467..a9cdf3bc 100644 --- a/packages/core/test/store.test.ts +++ b/packages/core/test/store.test.ts @@ -1,10 +1,12 @@ import { beforeAll, describe, expect, it } from "bun:test"; +import { deflateSync } from "fflate"; import { resolveTransactionResourceLimits, validateTransactionResources, validateWorkbookSnapshot, } from "../src/document-protocol.js"; import { initSheetwrite } from "../src/grid.js"; +import { encodeRestoreBlock } from "../src/restore-block.js"; import { IncompleteDataError, SheetwriteStore } from "../src/store.js"; import type { ChangeEvent, @@ -1908,6 +1910,69 @@ describe("validation, protection, and notes metadata", () => { } }); + it("uses the callback-revised restore payload and rejects revised invalid envelopes atomically", () => { + for (const mutation of ["replacement", "bytes", "sheet"] as const) { + const workbook = makeWorkbook(3); + const sheet = workbook.sheets[0]; + if (!sheet) throw new Error("fixture is missing its sheet"); + const range = { sheet: sheet.id, start: { row: 0, col: 0 }, end: { row: 0, col: 0 } }; + sheet.protectedRanges = [{ id: "locked", range }]; + const restore = encodeRestoreBlock(range, { rowCount: 1, colCount: 1, values: [11] }); + const replacement = encodeRestoreBlock(range, { rowCount: 1, colCount: 1, values: [29] }); + const store = new SheetwriteStore(workbook); + store.setProtectionResolver(({ operation }) => { + if (operation.op === "restoreBlock") { + if (mutation === "replacement") Object.assign(operation, replacement); + else if (mutation === "bytes") + Object.assign(operation, { decodedBytes: operation.decodedBytes + 1 }); + else operation.range.sheet = "missing-sheet"; + } + return "allow"; + }); + try { + const result = store.applyTransaction({ patches: [restore] }); + expect(result.status).toBe(mutation === "replacement" ? "applied" : "rejected"); + expect(store.getCell(addr(0, 0)).resolved).toBe(mutation === "replacement" ? 29 : null); + } finally { + store.dispose(); + } + } + }); + + it("rejects restore dimension mismatches and missing style tables before any mutation", () => { + for (const malformed of ["dimensions", "styles"] as const) { + const range = { sheet: "s1", start: { row: 0, col: 0 }, end: { row: 0, col: 0 } }; + const block = { + rowCount: 1, + colCount: 1, + values: [11], + ...(malformed === "styles" ? { styleIds: [0] } : {}), + }; + const decoded = new TextEncoder().encode(JSON.stringify(block)); + const restore: DocumentOp = { + op: "restoreBlock", + range: malformed === "dimensions" ? { ...range, end: { row: 1, col: 0 } } : range, + encoding: "deflate-json-v1", + decodedBytes: decoded.byteLength, + data: Buffer.from(deflateSync(decoded)).toString("base64"), + }; + const store = new SheetwriteStore(makeWorkbook(3)); + try { + const outcome = store.applyTransaction({ + patches: [ + { op: "set", addr: addr(0, 1), value: { kind: "literal", value: 42 } }, + restore, + ], + }); + expect(outcome.status).toBe("rejected"); + expect(store.getCell(addr(0, 0)).resolved).toBeNull(); + expect(store.getCell(addr(0, 1)).resolved).toBeNull(); + } finally { + store.dispose(); + } + } + }); + it("denies protected local mutations by default and delegates permission to the host", () => { const workbook = makeWorkbook(3); workbook.sheets[0]!.protectedRanges = [ From faaf1b700ed41964459afde0c852c11ffa347dcd Mon Sep 17 00:00:00 2001 From: chh-ay Date: Fri, 9 Oct 2026 03:23:54 +0700 Subject: [PATCH 2/2] perf(core): share prepared restore ranges across consumers --- .../docs/api/core/document-op-target.md | 2 +- .../api/core/rebase-document-operations.md | 2 +- .../api/core/validate-workbook-snapshot.md | 2 +- docs/src/generated/docs-contract.json | 2 +- docs/src/generated/public-api.json | 6 +++--- packages/core/src/restore-block.ts | 9 +++++++-- packages/core/src/row-bridge.ts | 8 ++++---- packages/core/src/store.ts | 1 + packages/core/src/store/data-engine.ts | 7 ++++--- packages/core/src/store/mutation-policy.ts | 7 ++++--- .../core/src/store/paged-dirty-preflight.ts | 7 ++++--- packages/core/src/transaction-preparation.ts | 18 +++++++++++++++++- scripts/public-api-baseline.json | 2 +- 13 files changed, 49 insertions(+), 24 deletions(-) diff --git a/docs/src/content/docs/api/core/document-op-target.md b/docs/src/content/docs/api/core/document-op-target.md index cf254355..66353757 100644 --- a/docs/src/content/docs/api/core/document-op-target.md +++ b/docs/src/content/docs/api/core/document-op-target.md @@ -8,7 +8,7 @@ description: "Exhaustive stable target identity used by persistence/logging laye Exhaustive stable target identity used by persistence/logging layers. ## Declaration diff --git a/docs/src/content/docs/api/core/rebase-document-operations.md b/docs/src/content/docs/api/core/rebase-document-operations.md index c2456f82..cd99aeb8 100644 --- a/docs/src/content/docs/api/core/rebase-document-operations.md +++ b/docs/src/content/docs/api/core/rebase-document-operations.md @@ -12,7 +12,7 @@ conflicts instead of lossy guesses. This is the collaboration design gate; no CRDT dependency is required for the supported cases. ## Declaration diff --git a/docs/src/content/docs/api/core/validate-workbook-snapshot.md b/docs/src/content/docs/api/core/validate-workbook-snapshot.md index a163c366..9608aeb5 100644 --- a/docs/src/content/docs/api/core/validate-workbook-snapshot.md +++ b/docs/src/content/docs/api/core/validate-workbook-snapshot.md @@ -8,7 +8,7 @@ description: "Validate and canonically order a schema-1 snapshot without hydrati Validate and canonically order a schema-1 snapshot without hydrating runtime state. ## Declaration diff --git a/docs/src/generated/docs-contract.json b/docs/src/generated/docs-contract.json index 1b0b7b22..a84e9233 100644 --- a/docs/src/generated/docs-contract.json +++ b/docs/src/generated/docs-contract.json @@ -25,7 +25,7 @@ "entryPointCount": 20, "exportCount": 484, "symbolPageCount": 484, - "apiSha256": "b830162c29ff8e7b08ca0d184c0e03d49074ed44ae687f65ec6254bcfbcac5c1", + "apiSha256": "38e5e4423f268070fdb127cbbfa5844f0742af1a0b49ff59e2673e53c8db2a6c", "compatibilitySha256": "0e2d1e8db45a097ab437bdcb85591c8fddabd60cd6dcfcf2bc2098588e2966f6", "formulaContractSha256": "daec72c46c939824c564fe3fe0758858f67c298a7c888f18fd4a060f467bd3b0" } diff --git a/docs/src/generated/public-api.json b/docs/src/generated/public-api.json index 95636c4e..82267a4f 100644 --- a/docs/src/generated/public-api.json +++ b/docs/src/generated/public-api.json @@ -1323,7 +1323,7 @@ "owners": [ "src/document-protocol.ts" ], - "source": "src/document-protocol.ts#L2955", + "source": "src/document-protocol.ts#L2966", "jsDocTags": [], "documentation": "Exhaustive stable target identity used by persistence/logging layers.", "memberDocs": [] @@ -2767,7 +2767,7 @@ "owners": [ "src/rebase.ts" ], - "source": "src/rebase.ts#L48", + "source": "src/rebase.ts#L51", "jsDocTags": [], "documentation": "Conservative server-ordered rebase for pending offline work. Non-overlapping\nliteral edits are shifted across row/column insertion and deletion. Ambiguous\nformula, overlapping, sheet-lifecycle, and move cases become explicit\nconflicts instead of lossy guesses. This is the collaboration design gate;\nno CRDT dependency is required for the supported cases.", "memberDocs": [] @@ -4871,7 +4871,7 @@ "owners": [ "src/document-protocol.ts" ], - "source": "src/document-protocol.ts#L2917", + "source": "src/document-protocol.ts#L2928", "jsDocTags": [], "documentation": "Validate and canonically order a schema-1 snapshot without hydrating runtime state.", "memberDocs": [] diff --git a/packages/core/src/restore-block.ts b/packages/core/src/restore-block.ts index 707fb8ab..9a9a4f40 100644 --- a/packages/core/src/restore-block.ts +++ b/packages/core/src/restore-block.ts @@ -94,7 +94,7 @@ export function estimateRestoreBlockBytes(block: PackedCellBlock): number { return Math.ceil(((compressed * block.rowCount) / sampleRows) * BASE64_EXPANSION); } -export function decodeRestoreBlock(operation: RestoreBlock): PackedCellBlock { +export function restoreBlockFacts(operation: RestoreBlock) { const prepared = preparedRestore(operation, validateDocumentOperationShape); if (prepared.errors.length > 0) invalid(); const block = prepared.block; @@ -104,5 +104,10 @@ export function decodeRestoreBlock(operation: RestoreBlock): PackedCellBlock { (block.styleIds !== undefined && block.styleTable === undefined) ) invalid(); - return block; + return prepared; +} + +/** Return a decoded block for codec consumers and privately owned rebase transforms. */ +export function decodeRestoreBlock(operation: RestoreBlock): PackedCellBlock { + return restoreBlockFacts(operation).block; } diff --git a/packages/core/src/row-bridge.ts b/packages/core/src/row-bridge.ts index b7e14111..e8426afb 100644 --- a/packages/core/src/row-bridge.ts +++ b/packages/core/src/row-bridge.ts @@ -1,4 +1,4 @@ -import { decodeRestoreBlock } from "./restore-block.js"; +import { restoreBlockFacts } from "./restore-block.js"; import type { CellScalar, CellValue } from "./types/cell.js"; import type { CellAddress, Range, SheetId } from "./types/coordinates.js"; import type { CommitReason, DocumentOp, SheetSnapshot } from "./types/document.js"; @@ -599,9 +599,9 @@ export class RowBridge< values.set(cellAddressKey(addr), cell.value); } } else if (operation.op === "setBlock" || operation.op === "restoreBlock") { - const block = - operation.op === "restoreBlock" ? decodeRestoreBlock(operation) : operation.block; - const range = operation.range; + const prepared = operation.op === "restoreBlock" ? restoreBlockFacts(operation) : undefined; + const block = prepared?.block ?? (operation as Extract).block; + const range = prepared?.range ?? operation.range; const startRow = Math.min(range.start.row, range.end.row); const startCol = Math.min(range.start.col, range.end.col); const formulas = new Map(block.formulas ?? []); diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index b18171c1..0d9f9b72 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -533,6 +533,7 @@ export class SheetwriteStore implements Store { effectiveTx = { ...tx, patches: policy.patches }; } if (this.protectionResolver) { + preparation.bind(effectiveTx.patches); // Permission callbacks receive live operations and may edit nested // payloads after ingress validation. Do not reuse their measurements. const callbackResources = resolveTransactionResourceValidation( diff --git a/packages/core/src/store/data-engine.ts b/packages/core/src/store/data-engine.ts index 781d3b70..1868e939 100644 --- a/packages/core/src/store/data-engine.ts +++ b/packages/core/src/store/data-engine.ts @@ -27,7 +27,7 @@ import { type StoreMemoryBreakdown, type TransientResourcePeak, } from "../resource-accounting.js"; -import { decodeRestoreBlock } from "../restore-block.js"; +import { restoreBlockFacts } from "../restore-block.js"; import { canAddSheetSnapshot } from "../sheet-lifecycle.js"; import { validateSheetName } from "../sheet-name.js"; import { StyleDictionary } from "../style-dictionary.js"; @@ -1602,12 +1602,13 @@ export class StoreDataEngine { } case "setBlock": case "restoreBlock": { - const bounds = normalizedRange(patch.range); + const prepared = patch.op === "restoreBlock" ? restoreBlockFacts(patch) : undefined; + const bounds = prepared?.range ?? normalizedRange(patch.range); const sheet = this.sheetMeta(bounds.sheet); const rows = bounds.end.row - bounds.start.row + 1; const cols = bounds.end.col - bounds.start.col + 1; const cellCount = rows * cols; - const block = patch.op === "restoreBlock" ? decodeRestoreBlock(patch) : patch.block; + const block = prepared?.block ?? (patch as Extract).block; const styleTable = block.styleTable ?? []; const styleIds = block.styleIds; const exceptions = [...(block.formulas ?? []), ...(block.refs ?? [])]; diff --git a/packages/core/src/store/mutation-policy.ts b/packages/core/src/store/mutation-policy.ts index 20f309a6..39386b9c 100644 --- a/packages/core/src/store/mutation-policy.ts +++ b/packages/core/src/store/mutation-policy.ts @@ -1,4 +1,4 @@ -import { decodeRestoreBlock } from "../restore-block.js"; +import { restoreBlockFacts } from "../restore-block.js"; import type { CellScalar, CellValue } from "../types/cell.js"; import type { Range, SheetId } from "../types/coordinates.js"; import type { @@ -361,8 +361,9 @@ export class StoreMutationPolicy { } if (patch.op !== "setBlock" && patch.op !== "restoreBlock") return issues; - const range = normalizedRange(patch.range); - const block = patch.op === "restoreBlock" ? decodeRestoreBlock(patch) : patch.block; + const prepared = patch.op === "restoreBlock" ? restoreBlockFacts(patch) : undefined; + const range = prepared?.range ?? normalizedRange(patch.range); + const block = prepared?.block ?? (patch as Extract).block; const formulaOffsets = new Set((block.formulas ?? []).map(([offset]) => offset)); const refOffsets = new Set((block.refs ?? []).map(([offset]) => offset)); for (const rule of rules) { diff --git a/packages/core/src/store/paged-dirty-preflight.ts b/packages/core/src/store/paged-dirty-preflight.ts index 0a20d104..67970d3a 100644 --- a/packages/core/src/store/paged-dirty-preflight.ts +++ b/packages/core/src/store/paged-dirty-preflight.ts @@ -3,7 +3,7 @@ import type { BoundaryResourceAccounting, RuntimeResourceOperation, } from "../resource-accounting.js"; -import { decodeRestoreBlock } from "../restore-block.js"; +import { restoreBlockFacts } from "../restore-block.js"; import { applySheetLifecycleOperation, createSheetLifecycleState } from "../sheet-lifecycle.js"; import type { CellAddress, SheetId } from "../types/coordinates.js"; import type { DocumentOp, MutationIssue, Workbook } from "../types/document.js"; @@ -590,8 +590,9 @@ export class PagedDirtyPreflight { if (referenceSimulationExceeded) return referenceSimulationIssue(); } } else if (patch.op === "setBlock" || patch.op === "restoreBlock") { - const block = patch.op === "restoreBlock" ? decodeRestoreBlock(patch) : patch.block; - const range = normalizedRange(patch.range); + const prepared = patch.op === "restoreBlock" ? restoreBlockFacts(patch) : undefined; + const block = prepared?.block ?? (patch as Extract).block; + const range = prepared?.range ?? normalizedRange(patch.range); rejection = addRectangle( range.sheet, range.start.row, diff --git a/packages/core/src/transaction-preparation.ts b/packages/core/src/transaction-preparation.ts index 874e040f..ac79fe28 100644 --- a/packages/core/src/transaction-preparation.ts +++ b/packages/core/src/transaction-preparation.ts @@ -1,14 +1,17 @@ import { decodeRestoreBlockPayload } from "./restore-block-codec.js"; +import type { Range } from "./types/coordinates.js"; import type { DocumentOp, PackedCellBlock } from "./types/document.js"; type Restore = Extract; -interface PreparedRestore { +export interface PreparedRestore { stamp: readonly unknown[]; block: PackedCellBlock; + range: Range; errors?: readonly unknown[]; requiredSheets: readonly string[]; containsFormula: boolean; + containsReference: boolean; } // Sidecars are visible only while their owning synchronous application is live. @@ -44,8 +47,20 @@ function prepare( return { stamp: revision, block, + range: { + sheet: operation.range.sheet, + start: { + row: Math.min(operation.range.start.row, operation.range.end.row), + col: Math.min(operation.range.start.col, operation.range.end.col), + }, + end: { + row: Math.max(operation.range.start.row, operation.range.end.row), + col: Math.max(operation.range.start.col, operation.range.end.col), + }, + }, requiredSheets: [], containsFormula: false, + containsReference: false, }; } @@ -105,6 +120,7 @@ export function preparedRestore( ...new Set((prepared.block.refs ?? []).map(([, target]) => target.sheet)), ]; prepared.containsFormula = (prepared.block.formulas?.length ?? 0) > 0; + prepared.containsReference = (prepared.block.refs?.length ?? 0) > 0; } } return prepared as Omit & { errors: readonly E[] }; diff --git a/scripts/public-api-baseline.json b/scripts/public-api-baseline.json index c6c7f621..ff6dc3ce 100644 --- a/scripts/public-api-baseline.json +++ b/scripts/public-api-baseline.json @@ -1,7 +1,7 @@ { "schemaVersion": 2, "manifestFormatVersion": 2, - "sha256": "b830162c29ff8e7b08ca0d184c0e03d49074ed44ae687f65ec6254bcfbcac5c1", + "sha256": "38e5e4423f268070fdb127cbbfa5844f0742af1a0b49ff59e2673e53c8db2a6c", "intentionalExports": [ { "package": "@sheetwrite/core",