INTEGRATION CONTRACT

Connect your backend

Keep workbook data and compute in your product while the grid stays bounded.

DECISION

Use this route when your application already owns the workbook, storage, permissions, or compute runtime. If you only need a working Solid grid, start with the Solid quick start instead.

What you must implement first

The UI does not request a full workbook. A minimal editable backend has three responsibilities:

Visible projectionGiven a window, return only its display cells.
Range projectionGiven an explicit finite range, return only that range.
Cell inputPersist an edit, then acknowledge the bounded affected range.
import type { SpreadsheetBackend } from '@einfach/spreadsheet-ui-core'

type MinimumEditableBackend = Pick<
  SpreadsheetBackend,
  'readVisibleProjection' | 'readRangeProjection' | 'setCellInput'
>

Every other method in the contract is optional capability surface. Add it only when the product exposes its matching UI feature; omitted capabilities degrade by hiding or disabling that feature.

Implement in this order

  1. Map the requested window or range to your storage query; never hydrate an entire sheet for a scroll.
  2. Return display-ready cells with their row, column, displayed value, and any formula or format facts your renderer needs.
  3. On setCellInput, make the authoritative write first. Resolve only after it lands, then return the changed range and revision.

Grow capability by capability

NeedAdd to the portDo not do
Large-sheet scrollingreadVisibleProjectionSend a sparse-sheet dump to the browser.
Selection and clipboardreadRangeProjection, then export/import methods as neededTurn selection into an unbounded read.
Editing and formulassetCellInputAcknowledge a write that your backend rejected.
Workbook featuresAdd the relevant optional methodImplement every optional capability before shipping.

THE BOUNDARY

The backend owns authoritative workbook facts. The UI owns only interaction state such as selection, editing, and viewport. Keeping this boundary intact is what allows static, Worker, remote, and product-owned backends to share one grid.

Use the source as the exact reference

The full interface below is extracted at build time fromexcel/spreadsheet-ui-core/src/backend/types.ts, so it cannot drift from the implementation. The bundledstatic backend is the smallest concrete reference.

Open the complete SpreadsheetBackend interface
export interface SpreadsheetBackend {
  listSheets?(): Promise<SheetListResult>
  readVisibleProjection(request: VisibleProjectionRequest): Promise<VisibleProjectionResult>
  readRangeProjection(request: RangeProjectionRequest): Promise<RangeProjectionResult>
  exportRangeTsv?(request: RangeTsvExportRequest): Promise<RangeTsvExportResult>
  /**
   * Wave 8.4 — range screenshot. Optional capability; UI core hides the
   * Copy-as-PNG surfaces and `encodeSelectionAsImage` returns `null`
   * when this method is omitted.
   */
  exportRangeAsImage?(request: RangeImageExportRequest): Promise<RangeImageExportResult>
  consumeExportRangeTsvChunks?(
    request: RangeTsvExportRequest,
    onChunk: RangeTsvChunkConsumer,
  ): Promise<RangeTsvChunkExportResult>
  readViewportSizeProjection?(
    request: ViewportSizeProjectionRequest,
  ): Promise<ViewportSizeProjectionResult>
  /**
   * Commit one cell's raw input. Resolving a `BackendMutationResult` means
   * the value really landed — a host that cannot apply the write MUST
   * reject, never resolve a success-shaped ACK, or the user's keystrokes
   * disappear while UI core records history and bumps the revision.
   * `runEditingCommitAtom` turns the rejection into a `rejected` editing
   * lifecycle carrying the Error's `message`, so the reason belongs there
   * (any structured `code` / `detail` is host-private).
   */
  setCellInput(request: SetCellInputRequest): Promise<BackendMutationResult>
  setCellRichValue?(request: SetCellRichValueRequest): Promise<BackendMutationResult>
  importCells?(request: ImportCellsRequest): Promise<BackendMutationResult>
  importCellChunks?(request: ImportCellChunksRequest): Promise<BackendMutationResult>
  clearRange?(request: ClearRangeRequest): Promise<BackendMutationResult>
  insertRows?(request: InsertRowsRequest): Promise<BackendMutationResult>
  deleteRows?(request: DeleteRowsRequest): Promise<BackendMutationResult>
  insertColumns?(request: InsertColumnsRequest): Promise<BackendMutationResult>
  deleteColumns?(request: DeleteColumnsRequest): Promise<BackendMutationResult>
  setFormatRange?(request: SetFormatRangeRequest): Promise<BackendMutationResult>
  setRowHeight?(request: SetRowHeightRequest): Promise<BackendMutationResult>
  setColumnWidth?(request: SetColumnWidthRequest): Promise<BackendMutationResult>
  fillRange?(request: FillRangeRequest): Promise<AutoFillMutationResult>
  resolveDataEdge?(request: ResolveDataEdgeRequest): Promise<ResolveDataEdgeResult>
  addSheet?(request: AddSheetRequest): Promise<SheetMutationResult>
  renameSheet?(request: RenameSheetRequest): Promise<SheetMutationResult>
  deleteSheet?(request: DeleteSheetRequest): Promise<SheetMutationResult>
  reorderSheet?(request: ReorderSheetRequest): Promise<SheetMutationResult>
  undoTransaction?(request: UndoTransactionRequest): Promise<HistoryTransactionResult>
  redoTransaction?(request: RedoTransactionRequest): Promise<HistoryTransactionResult>
  listNamedRanges?(request: ListNamedRangesRequest): Promise<NamedRangeListResult>
  setNamedRange?(request: SetNamedRangeRequest): Promise<NamedRangeMutationResult>
  deleteNamedRange?(request: DeleteNamedRangeRequest): Promise<NamedRangeMutationResult>
  mergeRange?(request: MergeRangeRequest): Promise<BackendMutationResult>
  unmergeRange?(request: UnmergeRangeRequest): Promise<BackendMutationResult>
  readFreezeConfig?(request: ReadFreezeConfigRequest): Promise<ReadFreezeConfigResult>
  setFreezeConfig?(request: SetFreezeConfigRequest): Promise<BackendMutationResult>
  hideRows?(request: HideRowsRequest): Promise<BackendMutationResult>
  unhideRows?(request: UnhideRowsRequest): Promise<BackendMutationResult>
  // Read-back of the engine-owned hidden state (rows + filter), for hydrating
  // UI-core render caches on sheet activation / restore. Optional: a backend
  // whose engine does not OWN this state (the TS worker) omits the port and
  // UI-core keeps its own canonical view fact instead of hydrating from a
  // source that cannot answer authoritatively.
  readSheetHiddenState?(request: SheetHiddenStateRequest): Promise<SheetHiddenStateResult>
  hideColumns?(request: HideColumnsRequest): Promise<BackendMutationResult>
  unhideColumns?(request: UnhideColumnsRequest): Promise<BackendMutationResult>
  // engine hidden-row eval input (parity #23). Optional, fire-and-forget
  // whole-set REPLACE (no exact ACK / undo): mirrors the UI-core canonical
  // hidden-row VIEW fact into the engine so SUBTOTAL 101-111 can exclude
  // manually hidden data rows. A backend whose engine models no such input
  // omits the port; UI core silently skips the push and SUBTOTAL 101-111
  // degrades to "does not exclude" without disturbing any other feature.
  setEvalHiddenRows?(request: SetEvalHiddenRowsRequest): Promise<void> | void
  fillSeries?(request: FillSeriesRequest): Promise<AutoFillMutationResult>
  // comments & notes
  setNote?(request: SetNoteRequest): Promise<BackendMutationResult>
  clearNote?(request: ClearNoteRequest): Promise<BackendMutationResult>
  postComment?(request: PostCommentRequest): Promise<BackendMutationResult>
  resolveCommentThread?(request: ResolveCommentThreadRequest): Promise<BackendMutationResult>
  deleteComment?(request: DeleteCommentRequest): Promise<BackendMutationResult>
  // data validation
  setValidationRule?(request: SetValidationRuleRequest): Promise<BackendMutationResult>
  clearValidationRule?(request: ClearValidationRuleRequest): Promise<BackendMutationResult>
  // conditional formatting
  setConditionalFormatRule?(
    request: SetConditionalFormatRuleRequest,
  ): Promise<BackendMutationResult>
  removeConditionalFormatRule?(
    request: RemoveConditionalFormatRuleRequest,
  ): Promise<BackendMutationResult>
  listConditionalFormatRules?(
    request: ListConditionalFormatRulesRequest,
  ): Promise<ConditionalFormatRulesResult>
  // print config
  readPrintConfig?(request: ReadPrintConfigRequest): Promise<ReadPrintConfigResult>
  setPrintConfig?(request: SetPrintConfigRequest): Promise<SetPrintConfigResult>
  // find-replace
  /**
   * Match offsets are UTF-16 code-unit indexes into the selected target and use half-open
   * `[matchStart, matchEnd)` intervals. Every emitted span satisfies
   * `0 <= matchStart < matchEnd`; zero-width regex results are omitted.
   */
  searchRange?(request: SearchRangeRequest): Promise<SearchRangeResult>
  /**
   * Replacement spans use the same UTF-16 code-unit, half-open, non-empty contract as search
   * results. Zero-width and reversed spans are rejected before any mutation.
   */
  replaceMatches?(request: ReplaceMatchesRequest): Promise<ReplaceMatchesResponse>
  // presence
  subscribePresence?(handler: (update: PresenceUpdate) => void): SubscribePresenceUnsubscribe
  publishLocalPresence?(request: PublishLocalPresenceRequest): Promise<void>
  // filter-sort
  // Resolves the ordinary mutation ACK PLUS the filter-hidden row set the
  // rule scan produced (`SetFilterSortResult.hiddenRowIndices`). Widening the
  // result is backward compatible: a host that returns a bare
  // `BackendMutationResult` still satisfies this signature, and UI core reads
  // the missing field as "cannot compute visibility" and hides nothing.
  setFilterSort?(request: SetFilterSortRequest): Promise<SetFilterSortResult>
  // engine physical sort — design-engine-sort. Optional capability: host
  // adapters whose runtime cannot physically reorder workbook data omit
  // this port (the TS worker declares `sortRange: false`), which hides
  // the physical-sort entry through the standard degradation contract.
  // Unlike `setFilterSort` (a UI-core VIEW fact / display permutation),
  // this reorders engine DATA; undo is host-orchestrated (one bounded
  // range-snapshot transaction). Resolves an applied report OR a
  // structured not-applied result — a gated request does NOT reject the
  // promise.
  sortRange?(request: SortRangeRequest): Promise<SortRangeResult>
  // protection — UI-core canonical (#40). These ports are an optional
  // persistence hook: `setSheetProtection` / `setRangeLock` receive a
  // fire-and-forget mirror of local commits and `readSheetProtection`
  // seeds a one-shot hydration. Backends that omit them keep the full
  // protection feature; enforcement runs in the UI-core mutation gateway.
  setSheetProtection?(request: SetSheetProtectionRequest): Promise<BackendMutationResult>
  setRangeLock?(request: SetRangeLockRequest): Promise<BackendMutationResult>
  readSheetProtection?(request: ReadSheetProtectionRequest): Promise<ReadSheetProtectionResult>
  // paste-special — Wave 7.3. Optional capability: host adapters that
  // omit this method cause the menu entry + dialog to hide via
  // `pasteSpecialSupportedAtom` so the surface degrades cleanly.
  pasteRange?(request: PasteRangeRequest): Promise<PasteRangeResult>
  // Optional fail-closed subdivision of the pasteRange capability (see
  // `PasteSpecialControllerPort.pasteRangeSupportedKinds`): a backend
  // that cannot apply the format leg declares only the value-leg kinds
  // and Core blocks the rest pre-dispatch. Absent → full trust.
  readonly pasteRangeSupportedKinds?: readonly PasteSpecialKind[]
  // remove-duplicates — Wave 7.5. Optional capability; host adapters that
  // omit this method cause the Data > Remove Duplicates menu entry to hide
  // via `removeDuplicatesSupportedAtom` so the surface degrades cleanly.
  removeRows?(request: RemoveRowsRequest): Promise<RemoveRowsResult>
  // custom-formulas — Wave 8. Optional capability; host adapters that
  // omit these methods make the `customFormulaRegistryAtom` inert
  // (writes succeed but no worker side-effect runs). `source` is the
  // body of a function whose argument is bound to `args` (Array) — the
  // adapter is expected to `new Function('args', source)` it inside
  // whichever runtime owns the formula engine. Errors thrown during
  // evaluation surface as `#ERROR!` cells. Wave 8.2: pass
  // `options.isAsync` to compile through the AsyncFunction constructor —
  // the cell holds `#BUSY!` until the returned Promise settles, and the
  // result is memoized per (name, args) until the next registry change.
  registerCustomFormula?(
    name: string,
    source: string,
    options?: { isAsync?: boolean },
  ): Promise<void>
  unregisterCustomFormula?(name: string): Promise<void>
  // tables — Excel Table CRUD (parity #32, design-excel-table.md §10).
  // Optional capability family: host adapters whose engine has no Table
  // model omit these ports (the TS worker declares `structuredTables:
  // false`, the static backend implements none), which hides every Table
  // entry through the standard method-presence degradation contract. The
  // engine registry is canonical (CANONICAL_OWNERSHIP §3 #32) — these are
  // the only path UI core reads a table's geometry; it stores no second
  // copy. `createTable` resolves the engine-assigned canonical name OR a
  // structured `TableMutationRejectedResult` (name conflict / range
  // overlap / cap 256 / …) rather than rejecting the promise; rename and
  // delete follow the same applied-or-structured-reject convention.
  // Table-definition undo is NOT wired in this slice (design §11/§12
  // known gap — persistence and the snapshot primitive do not carry the
  // table registry).
  createTable?(request: CreateTableRequest): Promise<CreateTableResult>
  renameTable?(request: RenameTableRequest): Promise<TableMutationResult>
  renameTableColumn?(request: RenameTableColumnRequest): Promise<TableMutationResult>
  deleteTable?(request: DeleteTableRequest): Promise<TableMutationResult>
  listTables?(request: ListTablesRequest): Promise<ListTablesResult>
  getTable?(request: GetTableRequest): Promise<GetTableResult>
  // Totals row (parity #32 T6, design-excel-table.md §7). Optional
  // subdivision of the Table capability family — the same
  // `structuredTables` witness gates them, so a backend whose engine has no
  // Table model omits them alongside the CRUD ports and UI core hides the
  // totals toggle through the standard method-presence contract. Both
  // resolve the shared applied-or-structured-reject convention: a gated
  // request (row-below occupied → `totals-row-blocked`, function before the
  // row is enabled → `no-totals-row`, unknown aggregate id →
  // `invalid-totals-function`) resolves a `TableMutationRejectedResult`
  // rather than rejecting the promise. Not undoable in this slice (design
  // §11/§12 known gap; the SUBTOTAL cell writes are covered by the existing
  // cell snapshots, the registry geometry change is not).
  setTableTotalsRow?(request: SetTableTotalsRowRequest): Promise<TableMutationResult>
  setTableTotalFunction?(request: SetTableTotalFunctionRequest): Promise<TableMutationResult>
  // spill (dynamic arrays) — ADR 0006 阶段 3。可选能力:引擎里没有动态数组
  // 模型的宿主(静态后端)省掉这个端口,`spillRegionSupportedAtom` 转 false,
  // 溢出边框与投影格标记整体不出现 —— 端口缺席是「功能不存在」,不是错误。
  //
  // 按需查询而不是给 `DisplayCell` 加字段:溢出边框只在选区落进数组时出现,
  // 所以一次选区移动查一次就够。取舍与代价见 `src/spill/README.md`。
  // `region: null` 是明确的「这一格不在任何活动溢出区里」,与端口缺席不同。
  readSpillRegion?(request: SpillRegionRequest): Promise<SpillRegionResult>
  // content-change push — Wave 8.2. Optional capability: backends whose
  // engine can change cell content OUTSIDE a UI-initiated mutation
  // (async custom-formula settles, collaborative edits) invoke the
  // handler after such a change so the host refetches the visible
  // projection. Coarse signal, no payload — hosts must tolerate
  // spurious invocations. Backends whose content only ever changes in
  // response to their own mutation methods may omit it.
  subscribeContentChanges?(handler: () => void): () => void
}
Read the source-level design notes
# backend

Defines framework-agnostic ports that host adapters implement.

This folder must not import worker, WASM, DOM, Solid, or React code. It only
defines request/response contracts used by commands in the UI core.

Backend ports are range based. A host adapter can read a visible window or an
explicit user range, and can execute compact range commands such as clear,
format, structural row/column edits, and fill handle copy. The UI core must not
ask for a workbook snapshot, formula cache, dependency graph, or sparse sheet
dump.

Clipboard-style TSV export is also range based. `exportRangeTsv` returns a TSV
body plus origin metadata for the requested range; the body must not include any
clipboard marker line. Host adapters may implement this with worker-side row
chunks or sparse snapshot chunks, but the UI core still sees only the bounded
range command result.

Clipboard paste uses `importCells` when a host adapter supports bulk import.
The UI core still owns only the parsed target coordinates and text/formula
inputs; worker-backed hosts should map those cells into chunked workbook import
sessions instead of issuing one mutation RPC per cell.

Data-navigation ports return a single coordinate, not a row/column projection.
For example, `resolveDataEdge` lets a host adapter answer Ctrl+Arrow movement
from sparse facts without materializing a full row or column in the UI layer.

Point queries return one bounded fact, not a projection. `readSpillRegion`
answers "does this one cell belong to a dynamic array" with the anchor plus the
array's bounding rectangle — the UI core keeps exactly one such answer at a
time (the active cell's). It is deliberately NOT a `DisplayCell` field: the
spill outline only appears while the selection sits inside the array, so a
per-cell wire field would be paid on every visible cell of every scroll for a
decoration that is usually not drawn. See `../spill/README.md`.

Sheet metadata ports return bounded sheet lists only. `reorderSheet` changes the
displayed sheet metadata order and must not imply any cell snapshot or sheet
content materialization.

Viewport size metadata ports are window based. `readViewportSizeProjection`
returns only sparse row heights and column widths for the visible window; resize
mutations write one row or one column at a time. UI-level autofit must measure
only the current visible DOM or an explicit finite range, then persist through
the same one-row/one-column resize mutations.

## State Decision Template

- Source atoms: none.
- Derived atoms: none.
- Commands: port calls only.
- Scale bound: request/response contracts must be window/range based.
- Backend reads: implemented by host adapters.
- Per-cell/per-row/per-col atom risk: not applicable.
- Tests: `test/backend*.test.ts`, `test/projection*.test.ts`.