From 71138d04184b5b9743dc2073c0f25118caf89439 Mon Sep 17 00:00:00 2001 From: chh-ay Date: Thu, 8 Oct 2026 15:58:52 +0700 Subject: [PATCH 01/14] chore(changeset): correct stale 0.5.0 release notes Record the final engine binary sizes measured on the release tree, drop the undo threshold that compact restores made obsolete, and stop naming a superseded Rust toolchain. --- .changeset/full-formula-engine.md | 4 +- .changeset/report-oversized-undo.md | 2 +- .changeset/steady-toolchain-refresh.md | 2 +- docs/src/components/EngineLandingTeaser.tsx | 39 --- docs/src/components/LandingSpreadsheet.tsx | 323 -------------------- docs/src/components/LazyEngineTeaser.tsx | 17 -- docs/test/landing-timeline.test.ts | 14 - 7 files changed, 4 insertions(+), 397 deletions(-) delete mode 100644 docs/src/components/EngineLandingTeaser.tsx delete mode 100644 docs/src/components/LandingSpreadsheet.tsx delete mode 100644 docs/src/components/LazyEngineTeaser.tsx delete mode 100644 docs/test/landing-timeline.test.ts diff --git a/.changeset/full-formula-engine.md b/.changeset/full-formula-engine.md index 902dd75f..5602f9e7 100644 --- a/.changeset/full-formula-engine.md +++ b/.changeset/full-formula-engine.md @@ -3,9 +3,9 @@ "@sheetwrite/core": minor --- -Add `@sheetwrite/formulas`, an optional full formula engine. It is the same Rust engine as `@sheetwrite/wasm`, built with more functions, and it has the same API and loaders. The default `@sheetwrite/wasm` engine does not change: its function set and its binary size stay the same. +Add `@sheetwrite/formulas`, an optional full formula engine. It is the same Rust engine as `@sheetwrite/wasm`, built with more functions, and it has the same API and loaders. The full engine is a separate build: it adds nothing to the function set or binary of the default `@sheetwrite/wasm` engine. - Select the full engine one time, before the app creates a grid or a store: `import * as formulas from "@sheetwrite/formulas"; await initSheetwrite(undefined, formulas);`. A call that selects a different engine after this rejects. Calls without an engine keep the selected engine, so framework adapters work with both engines. - The full engine adds 36 statistical distribution functions: `NORM.DIST`, `NORM.INV`, `NORM.S.DIST`, `NORM.S.INV`, `T.DIST`, `T.DIST.2T`, `T.DIST.RT`, `T.INV`, `T.INV.2T`, `CHISQ.DIST`, `CHISQ.DIST.RT`, `CHISQ.INV`, `CHISQ.INV.RT`, `F.DIST`, `F.DIST.RT`, `F.INV`, `F.INV.RT`, `BINOM.DIST`, `POISSON.DIST`, `EXPON.DIST`, `GAMMA`, `GAMMALN`, `GAMMA.DIST`, `GAMMA.INV`, `BETA.DIST`, `BETA.INV`, `LOGNORM.DIST`, `LOGNORM.INV`, `WEIBULL.DIST`, `CONFIDENCE.NORM`, `CONFIDENCE.T`, `STANDARDIZE`, `FISHER`, `FISHERINV`, `PHI`, and `GAUSS`. They use the Excel argument rules and error values. - Formula assist suggests only the functions of the selected engine. With the default engine, these functions return `#NAME?`. -- The full engine binary is 794,725 bytes (240,690 bytes with Brotli). The default engine binary is 758,385 bytes (228,822 bytes with Brotli). +- In this release, the full engine binary is 1,074,934 bytes (326,278 bytes with Brotli) and the default engine binary is 791,937 bytes (238,071 bytes with Brotli). diff --git a/.changeset/report-oversized-undo.md b/.changeset/report-oversized-undo.md index 22dbbe89..e415b7a6 100644 --- a/.changeset/report-oversized-undo.md +++ b/.changeset/report-oversized-undo.md @@ -2,4 +2,4 @@ "@sheetwrite/core": patch --- -Report an undo that is too large instead of failing silently. When the data that an undo restores is above the transaction limits (`maxEncodedBytes` or `maxOperations`), `grid.undo()` changes nothing. The Grid now emits `mutation-rejected` with the `resource-limit` issue and removes that entry from the undo history, so older edits can still be undone. Before, the Grid sent no event and kept the entry, so every later undo failed the same way. At the default 8 MiB, this applies to undo of a clear larger than about 1,000,000 number cells. +Report an undo that is too large instead of failing silently. When the data that an undo restores is above the transaction limits (`maxEncodedBytes` or `maxOperations`), `grid.undo()` changes nothing. The Grid now emits `mutation-rejected` with the `resource-limit` issue and removes that entry from the undo history, so older edits can still be undone. Before, the Grid sent no event and kept the entry, so every later undo failed the same way. Large restores first use the compact `restoreBlock` encoding and atomic batches, so this applies only to an undo that still exceeds those ceilings. diff --git a/.changeset/steady-toolchain-refresh.md b/.changeset/steady-toolchain-refresh.md index 886ab523..ca287235 100644 --- a/.changeset/steady-toolchain-refresh.md +++ b/.changeset/steady-toolchain-refresh.md @@ -2,4 +2,4 @@ "@sheetwrite/wasm": patch --- -Rebuild the columnar store on the pinned Rust 1.98.1 toolchain. The source now follows the new rustfmt style, and clippy-selected rewrites replace `unwrap` calls after `is_some` in the paged setters, use the newer `is_multiple_of`, `as_chunks`, and `slice::from_ref` standard-library forms, and drop redundant counters and conversions. +Adopt the current rustfmt style and clippy-selected rewrites in the columnar store: replace `unwrap` calls after `is_some` in the paged setters, use the newer `is_multiple_of`, `as_chunks`, and `slice::from_ref` standard-library forms, and drop redundant counters and conversions. diff --git a/docs/src/components/EngineLandingTeaser.tsx b/docs/src/components/EngineLandingTeaser.tsx deleted file mode 100644 index 1302f4b5..00000000 --- a/docs/src/components/EngineLandingTeaser.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { Link } from "@tanstack/react-router"; - -/** Static, engine-free callout for the live engine route. */ -export default function EngineLandingTeaser() { - return ( -
-
-

The live route, reserved below

-

- Watch the Grid, the calculation engine, and your host agree. -

-

- A paged formula sheet makes each request, checked result, drawing choice, and host save - readable. No illustration stands in for the working Grid. -

- - Try the live engine view → - -
-
    -
  1. - 01 - Edit in the Grid -

    Change one actual value and watch its formula update.

    -
  2. -
  3. - 02 - Check the result -

    Jump to a row and see only the needed page arrive.

    -
  4. -
  5. - 03 - Save to your host -

    Read the acknowledgement returned by the host boundary.

    -
  6. -
-
- ); -} diff --git a/docs/src/components/LandingSpreadsheet.tsx b/docs/src/components/LandingSpreadsheet.tsx deleted file mode 100644 index 2bedb222..00000000 --- a/docs/src/components/LandingSpreadsheet.tsx +++ /dev/null @@ -1,323 +0,0 @@ -import { useEffect, useRef } from "react"; - -export const LANDING_TIMELINE = { - enter: { start: 0, end: 420 }, - select: { start: 420, end: 900 }, - dependencies: { start: 900, end: 1_600 }, - request: { start: 1_600, end: 2_300 }, - resolve: { start: 2_300, end: 3_000 }, -} as const; - -export type LandingTimelinePhase = keyof typeof LANDING_TIMELINE; - -export interface LandingTimelineFrame { - readonly phase: LandingTimelinePhase; - readonly selection: number; - readonly dependencies: number; - readonly request: number; - readonly resolved: number; -} - -const TIMELINE_END = LANDING_TIMELINE.resolve.end; -const AMBIENT_CELLS = Array.from({ length: 96 }, (_, index) => index); -const COLUMN_HEADERS = ["A", "B", "C", "D"] as const; -const ROWS = [ - ["Region", "Units", "Rate", "Revenue"], - ["North", "128", "$42", "$5,376"], - ["West", "96", "$48", "$4,608"], - ["South", "112", "$44", "$4,928"], - ["East", "104", "$46", "$4,784"], - ["Total", "440", "", "$19,696"], -] as const; - -function progress(elapsed: number, start: number, end: number): number { - return Math.min(1, Math.max(0, (elapsed - start) / (end - start))); -} - -/** Deterministic visual state. Values are choreography time, never runtime performance. */ -export function sampleLandingTimeline( - elapsedMs: number, - reducedMotion = false, -): LandingTimelineFrame { - if (reducedMotion) { - return { phase: "resolve", selection: 1, dependencies: 1, request: 1, resolved: 1 }; - } - const elapsed = Math.min(TIMELINE_END, Math.max(0, elapsedMs)); - const phase = (Object.entries(LANDING_TIMELINE).find( - ([, interval]) => elapsed >= interval.start && elapsed < interval.end, - )?.[0] ?? "resolve") as LandingTimelinePhase; - return { - phase, - selection: progress(elapsed, LANDING_TIMELINE.select.start, LANDING_TIMELINE.select.end), - dependencies: progress( - elapsed, - LANDING_TIMELINE.dependencies.start, - LANDING_TIMELINE.dependencies.end, - ), - request: progress(elapsed, LANDING_TIMELINE.request.start, LANDING_TIMELINE.request.end), - resolved: progress(elapsed, LANDING_TIMELINE.resolve.start, LANDING_TIMELINE.resolve.end), - }; -} - -function applyFrame(root: HTMLElement, elapsed: number, reducedMotion: boolean): void { - const frame = sampleLandingTimeline(elapsed, reducedMotion); - root.dataset.landingPhase = frame.phase; - root.style.setProperty("--sw-landing-select", String(frame.selection)); - root.style.setProperty("--sw-landing-dependencies", String(frame.dependencies)); - root.style.setProperty("--sw-landing-request", String(frame.request)); - root.style.setProperty("--sw-landing-resolved", String(frame.resolved)); -} - -export function LandingSpreadsheet() { - const rootRef = useRef(null); - - useEffect(() => { - const root = rootRef.current; - if (!root) return; - const media = window.matchMedia("(prefers-reduced-motion: reduce)"); - let reducedMotion = media.matches; - let intersecting = false; - let started = false; - let elapsed = 0; - let previousTime = 0; - let frameRequest = 0; - let pointerRequest = 0; - let pointerBoundsRequest = 0; - - let pointerX = 0.5; - let pointerY = 0.5; - let pointerBounds = root.getBoundingClientRect(); - - const animations = Array.from(root.querySelectorAll("[data-landing-animate]")) - .map((element) => { - const phase = element.dataset.landingAnimate as LandingTimelinePhase | undefined; - const interval = phase ? LANDING_TIMELINE[phase] : undefined; - if (!interval || typeof element.animate !== "function") return null; - const animation = element.animate( - [ - { opacity: 0, transform: "translateY(4px)" }, - { opacity: 1, transform: "translateY(0)" }, - ], - { - duration: Math.max(1, interval.end - interval.start), - delay: interval.start, - easing: "cubic-bezier(0.16, 1, 0.3, 1)", - fill: "both", - }, - ); - animation.pause(); - return animation; - }) - .filter((animation): animation is Animation => animation !== null); - - const seek = (time: number) => { - applyFrame(root, time, reducedMotion); - for (const animation of animations) animation.currentTime = time; - }; - const shouldRun = () => - started && intersecting && document.visibilityState === "visible" && !reducedMotion; - const tick = (time: number) => { - frameRequest = 0; - if (!shouldRun()) return; - if (previousTime !== 0) elapsed = Math.min(TIMELINE_END, elapsed + time - previousTime); - previousTime = time; - seek(elapsed); - if (elapsed < TIMELINE_END) frameRequest = requestAnimationFrame(tick); - else root.dataset.landingRunning = "false"; - }; - const resume = () => { - if (!shouldRun() || frameRequest !== 0 || elapsed >= TIMELINE_END) return; - previousTime = 0; - root.dataset.landingRunning = "true"; - frameRequest = requestAnimationFrame(tick); - }; - const pause = () => { - if (frameRequest !== 0) cancelAnimationFrame(frameRequest); - frameRequest = 0; - previousTime = 0; - root.dataset.landingRunning = "false"; - }; - const replay = () => { - elapsed = reducedMotion ? TIMELINE_END : 0; - started = true; - seek(elapsed); - resume(); - }; - const updatePointerBounds = () => { - pointerBounds = root.getBoundingClientRect(); - }; - const schedulePointerBoundsUpdate = () => { - if (pointerBoundsRequest !== 0) return; - pointerBoundsRequest = requestAnimationFrame(() => { - pointerBoundsRequest = 0; - updatePointerBounds(); - }); - }; - - const onPointerMove = (event: PointerEvent) => { - pointerX = Math.min( - 1, - Math.max(0, (event.clientX - pointerBounds.left) / Math.max(1, pointerBounds.width)), - ); - pointerY = Math.min( - 1, - Math.max(0, (event.clientY - pointerBounds.top) / Math.max(1, pointerBounds.height)), - ); - if (pointerRequest !== 0) return; - pointerRequest = requestAnimationFrame(() => { - pointerRequest = 0; - root.style.setProperty("--sw-pointer-x", `${(pointerX * 100).toFixed(2)}%`); - root.style.setProperty("--sw-pointer-y", `${(pointerY * 100).toFixed(2)}%`); - }); - }; - const onVisibilityChange = () => { - if (document.visibilityState === "visible") resume(); - else pause(); - }; - const onMotionChange = (event: MediaQueryListEvent) => { - reducedMotion = event.matches; - if (reducedMotion) { - pause(); - elapsed = TIMELINE_END; - seek(elapsed); - } else { - elapsed = 0; - seek(elapsed); - resume(); - } - }; - const observer = new IntersectionObserver( - ([entry]) => { - intersecting = entry?.isIntersecting === true && entry.intersectionRatio >= 0.4; - if (intersecting && !started) { - started = true; - elapsed = reducedMotion ? TIMELINE_END : 0; - seek(elapsed); - } - if (intersecting) resume(); - else pause(); - }, - { threshold: [0, 0.4, 1] }, - ); - const resizeObserver = new ResizeObserver(updatePointerBounds); - - root.addEventListener("pointerenter", updatePointerBounds, { passive: true }); - root.addEventListener("pointermove", onPointerMove, { passive: true }); - root - .querySelector("[data-landing-replay]") - ?.addEventListener("click", replay); - document.addEventListener("visibilitychange", onVisibilityChange); - media.addEventListener("change", onMotionChange); - window.addEventListener("scroll", schedulePointerBoundsUpdate, { passive: true }); - observer.observe(root); - resizeObserver.observe(root); - seek(reducedMotion ? TIMELINE_END : 0); - - return () => { - pause(); - if (pointerRequest !== 0) cancelAnimationFrame(pointerRequest); - if (pointerBoundsRequest !== 0) cancelAnimationFrame(pointerBoundsRequest); - observer.disconnect(); - resizeObserver.disconnect(); - root.removeEventListener("pointerenter", updatePointerBounds); - root.removeEventListener("pointermove", onPointerMove); - window.removeEventListener("scroll", schedulePointerBoundsUpdate); - root - .querySelector("[data-landing-replay]") - ?.removeEventListener("click", replay); - document.removeEventListener("visibilitychange", onVisibilityChange); - media.removeEventListener("change", onMotionChange); - for (const animation of animations) animation.cancel(); - }; - }, []); - - return ( -
- -
-
- - Revenue model - Illustrative product view -
-
- - =SUM(D2:D5) -
-
-
- ); -} diff --git a/docs/src/components/LazyEngineTeaser.tsx b/docs/src/components/LazyEngineTeaser.tsx deleted file mode 100644 index 8fcfec90..00000000 --- a/docs/src/components/LazyEngineTeaser.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { lazy, Suspense } from "react"; - -const EngineLandingTeaser = lazy(() => import("./EngineLandingTeaser.js")); - -/** - * Keeps the landing page's engine callout static and engine-free. The live - * Grid and its WASM runtime are loaded only after the visitor follows the CTA. - */ -export function LazyEngineTeaser() { - return ( -
- - - -
- ); -} diff --git a/docs/test/landing-timeline.test.ts b/docs/test/landing-timeline.test.ts deleted file mode 100644 index 3e0d24e6..00000000 --- a/docs/test/landing-timeline.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { sampleLandingTimeline } from "../src/components/LandingSpreadsheet.js"; - -describe("landing visual timeline", () => { - it("returns the complete static frame for reduced motion", () => { - expect(sampleLandingTimeline(0, true)).toEqual({ - phase: "resolve", - selection: 1, - dependencies: 1, - request: 1, - resolved: 1, - }); - }); -}); From 04db92ba13a07e812b5bdc0c01a3d3886ec4ea26 Mon Sep 17 00:00:00 2001 From: chh-ay Date: Thu, 8 Oct 2026 15:58:52 +0700 Subject: [PATCH 02/14] build(docs): cache content-hashed deployment assets as immutable Vercel served hashed assets with max-age=0, so every page view revalidated every script, stylesheet, font and search shard. HTML and stable entry files keep revalidating. --- scripts/prepare-docs-deployment.test.ts | 54 ++++++++++++++++++++++--- scripts/prepare-docs-deployment.ts | 20 +++++++++ 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/scripts/prepare-docs-deployment.test.ts b/scripts/prepare-docs-deployment.test.ts index 443abf9d..dbf6f697 100644 --- a/scripts/prepare-docs-deployment.test.ts +++ b/scripts/prepare-docs-deployment.test.ts @@ -11,6 +11,8 @@ const builtFiles = { "docs/start/installation/index.html": "

Install

", "sitemap.xml": "", "pagefind/pagefind-entry.json": '{"version":"1.4.0"}', + "pagefind/fragment/en_020da39.pf_fragment": "fragment", + "pagefind/pagefind.en_6e131784e9.pf_meta": "meta", "assets/client.wasm": Buffer.from([0, 97, 115, 109, 255]), }; @@ -35,9 +37,20 @@ afterEach(async () => { ); }); -type OutputRoute = { handle: "filesystem" } | { src: string; dest: string }; +type OutputRoute = + | { handle: "filesystem" } + | { src: string; dest: string } + | { src: string; headers: Record; continue: true }; -async function requestPackagedFile(root: string, pathname: string): Promise { +interface PackagedResponse { + body: Buffer; + headers: Record; +} + +async function requestPackagedFile( + root: string, + pathname: string, +): Promise { const outputRoot = join(root, ".vercel/output"); const config: { routes: OutputRoute[] } = JSON.parse( await readFile(join(outputRoot, "config.json"), "utf8"), @@ -46,14 +59,19 @@ async function requestPackagedFile(root: string, pathname: string): Promise = {}; + const respond = (body: Buffer | null) => (body === null ? null : { body, headers }); // Exercise route matching against the emitted files, not just the JSON shape. for (const route of config.routes) { if ("handle" in route) { const contents = await readStatic(pathname); - if (contents !== null) return contents; + if (contents !== null) return respond(contents); + } else if ("headers" in route) { + if (new RegExp(route.src).test(pathname)) Object.assign(headers, route.headers); } else { const pattern = new RegExp(route.src); - if (pattern.test(pathname)) return readStatic(pathname.replace(pattern, route.dest)); + if (pattern.test(pathname)) + return respond(await readStatic(pathname.replace(pattern, route.dest))); } } return null; @@ -81,6 +99,7 @@ describe("prebuilt docs deployment", () => { "docs/start", "docs/start/installation", "pagefind", + "pagefind/fragment", ].sort(), ); for (const [path, contents] of Object.entries(builtFiles)) { @@ -134,10 +153,35 @@ describe("prebuilt docs deployment", () => { ["/sitemap.xml", builtFiles["sitemap.xml"]], ["/pagefind/pagefind-entry.json", builtFiles["pagefind/pagefind-entry.json"]], ] as const) { - expect(await requestPackagedFile(root, url)).toEqual(Buffer.from(contents)); + expect((await requestPackagedFile(root, url))?.body).toEqual(Buffer.from(contents)); } for (const url of ["/missing", "/docs/missing/", "/assets/missing.js", "/404.html"]) { expect(await requestPackagedFile(root, url)).toBeNull(); } }); + + it("caches only content-hashed files as immutable", async () => { + const root = await fixture(); + await prepareDocsDeployment(root); + const cacheControl = async (url: string) => + (await requestPackagedFile(root, url))?.headers["cache-control"]; + for (const url of [ + "/assets/client.wasm", + "/pagefind/fragment/en_020da39.pf_fragment", + "/pagefind/pagefind.en_6e131784e9.pf_meta", + ]) { + expect(await cacheControl(url)).toBe("public, max-age=31536000, immutable"); + } + // Stable URLs must revalidate, or a deployment would serve stale pages and search entries. + for (const url of [ + "/", + "/docs/", + "/docs/start/installation/", + "/sitemap.xml", + "/pagefind/pagefind-entry.json", + ]) { + expect(await requestPackagedFile(root, url)).not.toBeNull(); + expect(await cacheControl(url)).toBeUndefined(); + } + }); }); diff --git a/scripts/prepare-docs-deployment.ts b/scripts/prepare-docs-deployment.ts index 32d9fd79..95a97b37 100644 --- a/scripts/prepare-docs-deployment.ts +++ b/scripts/prepare-docs-deployment.ts @@ -9,6 +9,21 @@ const requiredFiles = [ "pagefind/pagefind-entry.json", ]; +/** + * Content-hashed outputs never change under one URL, so browsers may keep + * them for a year without revalidating. Everything else (HTML, sitemap, + * Pagefind's stable entry files) keeps Vercel's revalidating default so a new + * deployment is visible immediately. + */ +const IMMUTABLE_CACHE = "public, max-age=31536000, immutable"; +const IMMUTABLE_PATHS = [ + // Vite emits every bundled script, stylesheet, font, and WASM file here with a content hash. + "^/assets/.+$", + // Pagefind names its shards and metadata after their content. + "^/pagefind/(?:fragment|index)/.+$", + "^/pagefind/pagefind\\.[^/]+\\.pf_meta$", +]; + async function requireDirectory(path: string): Promise { if (!(await lstat(path)).isDirectory()) { throw new Error(`Expected a real directory, not a symlink or special file: ${path}`); @@ -65,6 +80,11 @@ export async function prepareDocsDeployment(root = repositoryRoot): Promise ({ + src, + headers: { "cache-control": IMMUTABLE_CACHE }, + continue: true, + })), { src: "^/$", dest: "/index.html" }, { handle: "filesystem" }, { src: "^/(.+?)/?$", dest: "/$1/index.html" }, From 78ec7d6ea456840dd39f241bc6bd139f9bd69c55 Mon Sep 17 00:00:00 2001 From: chh-ay Date: Thu, 8 Oct 2026 15:58:53 +0700 Subject: [PATCH 03/14] docs(formulas): describe every full-engine family and reject supported unsupported-examples The formula contract now fails when an unsupported-category example names a function some build evaluates, and the inventory drops the examples the full engine now supports. --- .../docs/reference/compatibility-limits.md | 2 +- .../docs/reference/compatibility-results.md | 16 ++++----- .../docs/reference/formula-functions.md | 8 ++--- docs/src/generated/compatibility.json | 18 +++++----- packages/formulas/README.md | 22 +++++++++++- scripts/formula-contract.test.ts | 2 +- scripts/formula-contract.ts | 10 +++++- scripts/formula-docs.ts | 6 ++-- test/conformance/corpus.manifest.json | 2 +- .../formula-contract.inventory.json | 36 ------------------- 10 files changed, 56 insertions(+), 66 deletions(-) diff --git a/docs/src/content/docs/reference/compatibility-limits.md b/docs/src/content/docs/reference/compatibility-limits.md index e56d1ee1..c7db9445 100644 --- a/docs/src/content/docs/reference/compatibility-limits.md +++ b/docs/src/content/docs/reference/compatibility-limits.md @@ -6,7 +6,7 @@ description: Explicit compatibility boundaries, resource ceilings, tuning defaul | Area | Supported contract | Boundary | | --- | --- | --- | | Data | Eager columnar data or cancellable datasource pages; dense and allocation-lazy paged storage | Full-sheet queries and exports are incomplete until required pages load | -| Formulas | Persisted formula source, A1/range references, named ranges, the inventory-derived supported function contract, bounded lexical `LET`, and bounded dynamic-array spills | No blanket Excel/Sheets/OpenFormula claim; external/network/database/cube/LAMBDA categories and automatic volatility beyond the clock barrier are unsupported | +| Formulas | Persisted formula source, A1/range references, spill references, named ranges, the inventory-derived supported function contract in two engine builds, bounded lexical `LET` and `LAMBDA`, and bounded dynamic-array spills | No blanket Excel/Sheets/OpenFormula claim; external, network, cube, and live-data categories and automatic volatility beyond the clock barrier are unsupported | | Protection | Serializable client interaction policy with atomic/partial local mutation behavior | Never server authorization; remote operations bypass local UX policy | | Collaboration | Host sequencing, stable mutation IDs, durable optimistic edits, ordered remote operations, presence/comments/revisions, conservative rebase | No bundled server, CRDT, or general OT; ambiguous structural/formula conflicts require host UX | | Worker | OffscreenCanvas rendering with capability/startup fallback | Custom function renderers cannot transfer to the worker | diff --git a/docs/src/content/docs/reference/compatibility-results.md b/docs/src/content/docs/reference/compatibility-results.md index fd290b0b..c0870dd4 100644 --- a/docs/src/content/docs/reference/compatibility-results.md +++ b/docs/src/content/docs/reference/compatibility-results.md @@ -46,7 +46,7 @@ Result labels distinguish **evaluated** formulas or structures, **preserved** so
Result
evaluated
Import
Recognized formulas evaluate; unknown dynamic-array syntax remains preserved source with an explicit formula error.
Export
Anchor formula source is exported; derived spill cells are not serialized as invented formulas.
-
Known boundary
No implicit-intersection operator, spill-reference # syntax, multi-key SORT, higher-order LAMBDA array functions, or general Excel/Sheets dynamic-array family is claimed.
+
Known boundary
No implicit-intersection operator or general Excel/Sheets dynamic-array family is claimed. Multi-key SORTBY, reshaping, and LAMBDA helpers need the full engine; the default engine returns #NAME? for them.
Evidence
2 checked evidence records
Open the checked interactive result @@ -75,20 +75,20 @@ Result labels distinguish **evaluated** formulas or structures, **preserved** so
-LAMBDA and reusable named functions +LAMBDA and its array helpers formula excel -unsupported +partial
-
Result
unsupported
-
Import
Source is preserved; evaluation returns an explicit unsupported-name error.
-
Export
Preserved source may be emitted, without a fabricated cached result.
-
Known boundary
LET is supported separately; no approximation, reusable named-function runtime, or JavaScript execution fallback is provided.
+
Result
evaluated
+
Import
LAMBDA source evaluates when the full engine is selected; with the default engine the source is preserved and returns #NAME?.
+
Export
The exact formula source is emitted without a fabricated cached result.
+
Known boundary
Only in the full engine (@sheetwrite/formulas); the default engine returns #NAME?. Calls nest at most 64 deep, and an uncalled LAMBDA returns #CALC!. No JavaScript execution fallback is provided.
Evidence
1 checked evidence record
Open the checked interactive result @@ -585,7 +585,7 @@ Result labels distinguish **evaluated** formulas or structures, **preserved** so - Portable arithmetic, comparison, concatenation, and percent operators: [spec/source](https://docs.oasis-open.org/office/OpenDocument/v1.3/os/part4-formula/OpenDocument-v1.3-os-part4-formula.html) - Bounded dynamic arrays and spill ranges: [spec/source](https://support.microsoft.com/en-us/office/dynamic-array-formulas-and-spilled-array-behavior-205c6b06-03ba-4151-89a1-87a7eb36e531) - Bounded lexical and lazy LET bindings: [spec/source](https://support.microsoft.com/en-us/office/let-function-34842dd8-b92b-4d3f-b325-b8b8f9908999) -- LAMBDA and reusable named functions: [spec/source](https://support.microsoft.com/en-us/office/lambda-function-bd212d27-1cd1-4321-a34a-ccbf254b8b67) +- LAMBDA and its array helpers: [spec/source](https://support.microsoft.com/en-us/office/lambda-function-bd212d27-1cd1-4321-a34a-ccbf254b8b67) - Quoted cross-sheet references through stable worksheet identity: [spec/source](https://learn.microsoft.com/en-us/openspecs/office_standards/ms-xlsx/) - Create, rename, reorder, hide, unhide, remove, and active fallback: [spec/source](https://learn.microsoft.com/en-us/openspecs/office_standards/ms-xlsx/) - veryHidden worksheet preservation: [spec/source](https://learn.microsoft.com/en-us/office/vba/api/excel.xlsheetvisibility) diff --git a/docs/src/content/docs/reference/formula-functions.md b/docs/src/content/docs/reference/formula-functions.md index 9e4fc429..6c26b020 100644 --- a/docs/src/content/docs/reference/formula-functions.md +++ b/docs/src/content/docs/reference/formula-functions.md @@ -11,10 +11,10 @@ A function's presence means only the signature and semantic profiles linked in i ## Engine builds -- `@sheetwrite/wasm` is the unchanged default engine. All incumbent and required-target functions are available in both builds. -- `@sheetwrite/formulas` is an opt-in, larger analysis engine that also includes the distribution functions marked below. Select it before creating any stores: `import * as formulas from "@sheetwrite/formulas"; await initSheetwrite(undefined, formulas);`. +- `@sheetwrite/wasm` is the default engine. Every function listed for `@sheetwrite/wasm` is in both builds. +- `@sheetwrite/formulas` is the full engine. It adds the analysis families marked below: distributions and statistics, regression, finance, dates, text and regex, database, math and matrices, reshaping, LAMBDA helpers, and grouping. Select it before creating any stores: `import * as formulas from "@sheetwrite/formulas"; await initSheetwrite(undefined, formulas);`. - The active engine's registered names drive formula assist. Optional names are not suggested by the default engine and evaluate to `#NAME?` there; merely importing the optional package does not enable them. -- Distribution evaluation is scalar binary64 with function-specific domains and bounded numerical algorithms. The inventory does not claim array broadcasting, unlimited tail accuracy, or complete Excel/Google Sheets/OpenFormula parity. +- Numeric functions use binary64 arithmetic with function-specific domains and bounded numerical algorithms. The inventory does not claim unlimited tail accuracy or complete Excel/Google Sheets/OpenFormula parity. ## Bounded evaluation contract @@ -2276,9 +2276,7 @@ Unknown functions retain their source and evaluate to `#NAME?`; Sheetwrite does | **Volatile recalculation** (`volatile`) | Automatic volatility semantics beyond the existing clock functions are outside the supported contract. | `INDIRECT`, `OFFSET`, `RAND`, `RANDBETWEEN`, `RANDARRAY` | [Math and trigonometry functions](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb) | [`scripts/formula-contract.test.ts`](https://github.com/chh-ay/sheetwrite/blob/main/scripts/formula-contract.test.ts) | | **Network functions** (`network`) | Formula evaluation never performs network requests. | `ENCODEURL`, `FILTERXML`, `IMAGE`, `WEBSERVICE` | [Web functions](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb) | [`scripts/formula-contract.test.ts`](https://github.com/chh-ay/sheetwrite/blob/main/scripts/formula-contract.test.ts) | | **External data functions** (`external`) | Functions that query external providers or live data connections are not evaluated. | `GOOGLEFINANCE`, `IMPORTDATA`, `IMPORTHTML`, `IMPORTXML`, `RTD` | [Add-in and Automation functions](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb) | [`scripts/formula-contract.test.ts`](https://github.com/chh-ay/sheetwrite/blob/main/scripts/formula-contract.test.ts) | -| **Database functions** (`database`) | D-prefixed database aggregation functions are not evaluated. | `DAVERAGE`, `DCOUNT`, `DGET`, `DSUM`, `DVAR` | [Database functions](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb) | [`scripts/formula-contract.test.ts`](https://github.com/chh-ay/sheetwrite/blob/main/scripts/formula-contract.test.ts) | | **Cube functions** (`cube`) | OLAP cube members, sets, and values are not resolved. | `CUBEMEMBER`, `CUBESET`, `CUBEVALUE` | [Cube functions](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb) | [`scripts/formula-contract.test.ts`](https://github.com/chh-ay/sheetwrite/blob/main/scripts/formula-contract.test.ts) | -| **LAMBDA and higher-order functions** (`lambda`) | User-defined lambdas and higher-order array execution are not evaluated. | `BYCOL`, `BYROW`, `LAMBDA`, `MAKEARRAY`, `MAP`, `REDUCE`, `SCAN` | [Logical functions](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb) | [`scripts/formula-contract.test.ts`](https://github.com/chh-ay/sheetwrite/blob/main/scripts/formula-contract.test.ts) | | **Arbitrary external workbook references** (`external-workbook`) | References to arbitrary workbook files are not loaded or dereferenced. | `'[Book.xlsx]Sheet1'!A1` | [Lookup and reference functions](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb) | [`scripts/formula-contract.test.ts`](https://github.com/chh-ay/sheetwrite/blob/main/scripts/formula-contract.test.ts) | In particular, automatic volatile functions beyond the explicit host-clock barrier, network/external-data functions, arbitrary external workbook links, database functions, cube/OLAP functions, and `LAMBDA`/higher-order execution are unsupported. `TODAY` and `NOW` are the documented clock-function exception; `LET` is supported and is not a `LAMBDA` fallback. diff --git a/docs/src/generated/compatibility.json b/docs/src/generated/compatibility.json index 592fc622..fc5b62f8 100644 --- a/docs/src/generated/compatibility.json +++ b/docs/src/generated/compatibility.json @@ -27,8 +27,8 @@ "dialect": "shared", "status": "partial", "resultMode": "evaluated", - "semantics": "FILTER, SORT, UNIQUE, TRANSPOSE, SEQUENCE, TAKE, DROP, CHOOSECOLS, CHOOSEROWS, direct range arrays, spill ownership, resizing, obstruction, history, and dependency invalidation are evaluated.", - "divergence": "No implicit-intersection operator, spill-reference # syntax, multi-key SORT, higher-order LAMBDA array functions, or general Excel/Sheets dynamic-array family is claimed.", + "semantics": "FILTER, SORT, UNIQUE, TRANSPOSE, SEQUENCE, TAKE, DROP, CHOOSECOLS, CHOOSEROWS, direct range arrays, array constants, spill references (A1#), spill ownership, resizing, obstruction, read order between spills, history, and dependency invalidation are evaluated. The full engine adds SORTBY, reshaping, and LAMBDA array helpers.", + "divergence": "No implicit-intersection operator or general Excel/Sheets dynamic-array family is claimed. Multi-key SORTBY, reshaping, and LAMBDA helpers need the full engine; the default engine returns #NAME? for them.", "fixtureIds": [ "formula-engine-vectors", "formula-document-vectors" @@ -59,18 +59,18 @@ }, { "id": "formula.let-lambda", - "label": "LAMBDA and reusable named functions", + "label": "LAMBDA and its array helpers", "area": "formula", "dialect": "excel", - "status": "unsupported", - "resultMode": "unsupported", - "semantics": "Formula source is retained, but LAMBDA and higher-order execution are not evaluated.", - "divergence": "LET is supported separately; no approximation, reusable named-function runtime, or JavaScript execution fallback is provided.", + "status": "partial", + "resultMode": "evaluated", + "semantics": "The full engine evaluates LAMBDA with lexical parameters, immediate calls, LET-bound functions, ISOMITTED, and the MAP, REDUCE, SCAN, BYROW, BYCOL, and MAKEARRAY helpers, with bounded recursion.", + "divergence": "Only in the full engine (@sheetwrite/formulas); the default engine returns #NAME?. Calls nest at most 64 deep, and an uncalled LAMBDA returns #CALC!. No JavaScript execution fallback is provided.", "fixtureIds": [ "formula-engine-vectors" ], - "importBehavior": "Source is preserved; evaluation returns an explicit unsupported-name error.", - "exportBehavior": "Preserved source may be emitted, without a fabricated cached result.", + "importBehavior": "LAMBDA source evaluates when the full engine is selected; with the default engine the source is preserved and returns #NAME?.", + "exportBehavior": "The exact formula source is emitted without a fabricated cached result.", "warningCode": null, "lastVerifiedProtocolVersion": 3, "source": "https://support.microsoft.com/en-us/office/lambda-function-bd212d27-1cd1-4321-a34a-ccbf254b8b67" diff --git a/packages/formulas/README.md b/packages/formulas/README.md index 20dc4adb..eb8a14f5 100644 --- a/packages/formulas/README.md +++ b/packages/formulas/README.md @@ -2,7 +2,27 @@ The full Sheetwrite Rust/WASM engine. This package builds the same store and loader API as `@sheetwrite/wasm`, with the `analysis` feature enabled. The default engine stays unchanged. Install this package only when your app needs the full formula engine. -The full engine includes `TOCOL`, `TOROW`, `WRAPROWS`, `WRAPCOLS`, `EXPAND`, `HSTACK`, and `VSTACK`. These functions return spill arrays. They support scan order, blank and error filtering, and padding. Missing padding values default to `#N/A`. +The full engine adds these function families: + +| Family | Examples | +| --- | --- | +| Distributions | `NORM.DIST`, `T.INV.2T`, `CHISQ.DIST.RT`, `BINOM.DIST`, `GAMMA.INV` | +| Descriptive statistics | `TRIMMEAN`, `PERCENTILE.EXC`, `RANK.AVG`, `SKEW`, `FORECAST.LINEAR` | +| Regression | `LINEST`, `LOGEST`, `TREND`, `GROWTH`, `FREQUENCY`, `MODE.MULT` | +| Finance | `XIRR`, `XNPV`, `MIRR`, `NPER`, `CUMIPMT` | +| Dates | `NETWORKDAYS.INTL`, `WORKDAY.INTL`, `DATEDIF`, `ISOWEEKNUM` | +| Text and regex | `TEXTSPLIT`, `TEXTBEFORE`, `TEXTAFTER`, `REGEXEXTRACT`, `REGEXREPLACE` | +| Database | `DSUM`, `DAVERAGE`, `DCOUNT`, `DGET` | +| Math and matrices | `AGGREGATE`, `MMULT`, `MINVERSE`, `MDETERM`, `SUMSQ` | +| Reshaping | `SORTBY`, `HSTACK`, `VSTACK`, `TOCOL`, `WRAPROWS`, `EXPAND` | +| LAMBDA | `LAMBDA`, `MAP`, `REDUCE`, `SCAN`, `BYROW`, `MAKEARRAY` | +| Grouping | `GROUPBY`, `PIVOTBY`, `PERCENTOF` | + +The [formulas guide](https://sheetwrite.vercel.app/docs/guides/formulas/#full-formula-engine) describes the limits, and the [formula function contract](https://sheetwrite.vercel.app/docs/reference/formula-functions/) lists every function with its build. The [analysis formulas guide](https://sheetwrite.vercel.app/docs/guides/analysis-formulas/) shows each family with examples, and the [formula analysis showcase](https://sheetwrite.vercel.app/showcases/formulas/) runs thirteen panels of them live. + +## Reshaping functions + +`TOCOL`, `TOROW`, `WRAPROWS`, `WRAPCOLS`, `EXPAND`, `HSTACK`, and `VSTACK` return spill arrays. They support scan order, blank and error filtering, and padding. Missing padding values default to `#N/A`. ## Select the engine diff --git a/scripts/formula-contract.test.ts b/scripts/formula-contract.test.ts index d9afea35..8bae89b2 100644 --- a/scripts/formula-contract.test.ts +++ b/scripts/formula-contract.test.ts @@ -108,7 +108,7 @@ describe("formula capability contract", () => { expect(result.summary).toEqual({ functions: 299, requiredSupported: 100, - unsupportedCategories: 7, + unsupportedCategories: 5, parserSpellings: 156, assistSpellings: 156, formulasSpellings: 302, diff --git a/scripts/formula-contract.ts b/scripts/formula-contract.ts index cf4dab5d..945c6090 100644 --- a/scripts/formula-contract.ts +++ b/scripts/formula-contract.ts @@ -11,7 +11,7 @@ const ASSIST_PATH = "packages/core/src/formula-assist.ts"; const ANALYSIS_PATH = "packages/wasm/src/eval/analysis"; const REQUIRED_TARGET_HASH = "3efef696839478b92b59a004537f9ede7fa803f9749042bb830b664086495e80"; const UNSUPPORTED_CATEGORY_HASH = - "eef794e222795f2122b414d5ee9efd09802cf92ef372ae9fa12aac29fa06eb5b"; + "84d65b931bb56e52b65e22bc98ec22caa51c309e4481e5d87acd0740bc58c29c"; const FORMULA_NAME = /^[A-Z][A-Z0-9.]*$/; const _ID = /^[a-z][a-z0-9-]*$/; @@ -370,6 +370,14 @@ function validateReferencesAndNames(inventory: JsonObject, issues: string[]): vo `contract.unsupportedCategories[${index}].source: unknown source ${String(category.source)}`, ); } + // An unsupported example must not also be a function that some build evaluates. + for (const example of Array.isArray(category.examples) ? category.examples : []) { + if (typeof example === "string" && parserNames.has(example)) { + issues.push( + `contract.unsupportedCategories[${index}].examples: ${example} is a supported function`, + ); + } + } } } diff --git a/scripts/formula-docs.ts b/scripts/formula-docs.ts index 3d9c82af..e760a417 100644 --- a/scripts/formula-docs.ts +++ b/scripts/formula-docs.ts @@ -233,10 +233,10 @@ export function renderFormulaFunctionContract(inventoryValue: unknown): string { "", "## Engine builds", "", - "- `@sheetwrite/wasm` is the unchanged default engine. All incumbent and required-target functions are available in both builds.", - '- `@sheetwrite/formulas` is an opt-in, larger analysis engine that also includes the distribution functions marked below. Select it before creating any stores: `import * as formulas from "@sheetwrite/formulas"; await initSheetwrite(undefined, formulas);`.', + "- `@sheetwrite/wasm` is the default engine. Every function listed for `@sheetwrite/wasm` is in both builds.", + '- `@sheetwrite/formulas` is the full engine. It adds the analysis families marked below: distributions and statistics, regression, finance, dates, text and regex, database, math and matrices, reshaping, LAMBDA helpers, and grouping. Select it before creating any stores: `import * as formulas from "@sheetwrite/formulas"; await initSheetwrite(undefined, formulas);`.', "- The active engine's registered names drive formula assist. Optional names are not suggested by the default engine and evaluate to `#NAME?` there; merely importing the optional package does not enable them.", - "- Distribution evaluation is scalar binary64 with function-specific domains and bounded numerical algorithms. The inventory does not claim array broadcasting, unlimited tail accuracy, or complete Excel/Google Sheets/OpenFormula parity.", + "- Numeric functions use binary64 arithmetic with function-specific domains and bounded numerical algorithms. The inventory does not claim unlimited tail accuracy or complete Excel/Google Sheets/OpenFormula parity.", "", "## Bounded evaluation contract", "", diff --git a/test/conformance/corpus.manifest.json b/test/conformance/corpus.manifest.json index 1a0b2299..cdd2d88c 100644 --- a/test/conformance/corpus.manifest.json +++ b/test/conformance/corpus.manifest.json @@ -2395,7 +2395,7 @@ "generator": "sheetwrite.conformance.original-evidence", "generatorVersion": 1, "inventoryPath": "test/conformance/formula-contract.inventory.json", - "inventorySha256": "ef72f97196b3470d5e96eac55d793a826a445174f8eecdf44bbc5a4479fd2f65", + "inventorySha256": "a0ed3e0f8dfe9987d537d9a745b23a23928882eb84fa7745baf38cbb7159a692", "minimums": { "formula": 2000, "mutation": 250, "workbook": 100 }, "operationShapesSha256": "2b320198d9e2405b31886c79c0deec004efb6aa78a28964b144f3ae965704be1", "protocol": 1, diff --git a/test/conformance/formula-contract.inventory.json b/test/conformance/formula-contract.inventory.json index bc02beaa..92ceefdf 100644 --- a/test/conformance/formula-contract.inventory.json +++ b/test/conformance/formula-contract.inventory.json @@ -5034,24 +5034,6 @@ }, "version": 1 }, - { - "id": "database", - "title": "Database functions", - "status": "unsupported", - "scope": "D-prefixed database aggregation functions are not evaluated.", - "examples": ["DAVERAGE", "DCOUNT", "DGET", "DSUM", "DVAR"], - "source": "microsoft-excel-functions", - "section": "Database functions", - "implementation": { - "status": "missing", - "paths": [] - }, - "evidence": { - "status": "registration-tested", - "paths": ["scripts/formula-contract.test.ts"] - }, - "version": 1 - }, { "id": "cube", "title": "Cube functions", @@ -5070,24 +5052,6 @@ }, "version": 1 }, - { - "id": "lambda", - "title": "LAMBDA and higher-order functions", - "status": "unsupported", - "scope": "User-defined lambdas and higher-order array execution are not evaluated.", - "examples": ["BYCOL", "BYROW", "LAMBDA", "MAKEARRAY", "MAP", "REDUCE", "SCAN"], - "source": "microsoft-excel-functions", - "section": "Logical functions", - "implementation": { - "status": "missing", - "paths": [] - }, - "evidence": { - "status": "registration-tested", - "paths": ["scripts/formula-contract.test.ts"] - }, - "version": 1 - }, { "id": "external-workbook", "title": "Arbitrary external workbook references", From a79e0145cb3da771c96177fd3fb3e3ab73483cfc Mon Sep 17 00:00:00 2001 From: chh-ay Date: Thu, 8 Oct 2026 15:58:53 +0700 Subject: [PATCH 04/14] docs: add the 0.5.0 guides and rework the start and concept pages What's new with an upgrade checklist, analysis formulas, the calculation engine, and updated collaboration, persistence, interaction, styling and configuration guides. --- docs/public/llms-full.txt | 640 +++++++++++++++++- docs/public/llms.txt | 6 + docs/src/components/DocsShell.tsx | 4 - .../docs/concepts/calculation-engine.md | 86 +++ .../docs/concepts/runtime-ownership.mdx | 7 + docs/src/content/docs/frameworks/react.md | 1 + docs/src/content/docs/frameworks/svelte.md | 1 + docs/src/content/docs/frameworks/vue.md | 1 + .../content/docs/guides/analysis-formulas.md | 235 +++++++ docs/src/content/docs/guides/collaboration.md | 58 ++ docs/src/content/docs/guides/configuration.md | 10 + docs/src/content/docs/guides/formulas.md | 44 +- docs/src/content/docs/guides/interaction.md | 28 + docs/src/content/docs/guides/persistence.md | 19 + docs/src/content/docs/guides/styling.md | 14 + docs/src/content/docs/start/first-grid.md | 35 + docs/src/content/docs/start/installation.mdx | 2 +- docs/src/content/docs/start/whats-new.md | 93 +++ docs/src/generated/docs-contract.json | 4 +- docs/src/lib/navigation.ts | 5 + 20 files changed, 1274 insertions(+), 19 deletions(-) create mode 100644 docs/src/content/docs/concepts/calculation-engine.md create mode 100644 docs/src/content/docs/guides/analysis-formulas.md create mode 100644 docs/src/content/docs/start/whats-new.md diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index 2b515cc7..688ed0de 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -8,6 +8,7 @@ Packages: 0.4.0. Canonical HTML: https://sheetwrite.vercel.app/docs/ ## Contents +- [Calculation engine](#calculation-engine) - [Runtime ownership](#runtime-ownership) - [Adapter lifecycle](#adapter-lifecycle) - [React](#react) @@ -15,6 +16,7 @@ Packages: 0.4.0. Canonical HTML: https://sheetwrite.vercel.app/docs/ - [Vanilla JavaScript](#vanilla-javascript) - [Vue](#vue) - [Accessibility](#accessibility) +- [Analysis formulas](#analysis-formulas) - [Collaboration and offline work](#collaboration-and-offline-work) - [Configuration](#configuration) - [Data operations and export](#data-operations-and-export) @@ -31,10 +33,97 @@ Packages: 0.4.0. Canonical HTML: https://sheetwrite.vercel.app/docs/ - [Error handling](#error-handling) - [Your first grid](#your-first-grid) - [Installation](#installation) +- [What's new in 0.5.0](#what-s-new-in-0-5-0) - [Public API declarations](#public-api-declarations) ## Authored guides +### Calculation engine + +Source: https://sheetwrite.vercel.app/docs/concepts/calculation-engine/ + +Sheetwrite calculates formulas in a Rust engine compiled to WebAssembly. The +TypeScript core sends edits to the engine as transactions, and the engine keeps +every formula result current. This page explains the model. The +[formulas guide](https://sheetwrite.vercel.app/docs/guides/formulas/) has the exact syntax and semantics. + +## One engine, two builds + +| Build | Package | Use it when | +| --- | --- | --- | +| Default | `@sheetwrite/wasm` | The app needs the standard functions: math, lookup, text, logic, dates, dynamic arrays, and `LET`. It is the smallest download. | +| Full | `@sheetwrite/formulas` | The app needs analysis functions: `GROUPBY`, `PIVOTBY`, `LAMBDA`, statistics, regression, finance, regex, and more. | + +Both builds compile from the same source and have the same API, storage, and +limits. The full build only registers more functions. An app selects one build +one time, before it creates a Grid or a store, and cannot change it later. See +[Full formula engine](https://sheetwrite.vercel.app/docs/start/installation/#full-formula-engine) and the +[analysis formulas guide](https://sheetwrite.vercel.app/docs/guides/analysis-formulas/). + +## What happens on an edit + +1. The Grid or the host submits a transaction, for example a `set` operation. +2. The engine stores the new values and marks the changed cells dirty. +3. A dependency index records which formulas read which cells and ranges. The +engine uses it to find every formula that the dirty cells affect, also +through other formulas. +4. Only those formulas calculate again. A formula that no change reaches keeps +its result. +5. The transaction returns after the calculation. `store.getCell(address).resolved` +gives the new result at once, and the `change` event and the next paint show it. + +The dependency index changes only when formula reads change. If you rewrite a +formula and its cell and range reads stay the same, for example a new constant, +the index stays valid. + +## Spills + +A formula that returns an array, such as `=SORT(A2:A100)` or `={1,2;3,4}`, +**spills**: the formula cell is the anchor, and the result fills the cells to +its right and below. Spills are atomic. If any destination cell is not empty, +the anchor shows `#SPILL!` and no partial result appears. + +`A1#` refers to the current spill of `A1` and follows it when it grows or +shrinks. When one spill reads another, the engine calculates them in dependency +order, so the reader always sees current values. Spills that read each other +show `#CYCLE!`. See [Dynamic arrays and spills](https://sheetwrite.vercel.app/docs/guides/formulas/#dynamic-arrays-and-spills). + +## Paged data and `#LOADING!` + +With a windowed datasource, only some rows are in memory. A formula that reads +cells that are not loaded yet shows `#LOADING!` and calculates again when they +arrive. For visible formulas, the Grid asks the engine which same-sheet cells +they read and loads those cells with the window, within a cell budget. See +[Cells that visible formulas read](https://sheetwrite.vercel.app/docs/guides/configuration/#cells-that-visible-formulas-read). + +## Errors are values + +A formula error is a result, not an exception: `#DIV/0!`, `#N/A`, `#NAME?`, +`#REF!`, `#VALUE!`, `#NUM!`, `#SPILL!`, `#CALC!`, `#CYCLE!`, and `#LOADING!`. +Errors flow to dependent formulas, and `IFERROR` can replace them. An unknown +function returns `#NAME?`, and its source stays in the document, so a later +engine build can calculate it. See [Scalars, coercion, and errors](https://sheetwrite.vercel.app/docs/guides/formulas/#scalars-coercion-and-errors). + +## Limits + +Every formula runs inside fixed resource limits. When a formula reaches a limit, +it returns an error, usually `#NUM!`, and never a partial result: + +- one range can hold at most 1,000,000 cells; +- one spill can hold at most 1,000,000 cells and 64 MiB; +- one dynamic-array calculation can do at most 2,000,000 cell operations; +- dependencies and nested expressions can be at most 256 levels deep. + +The [compatibility limits](https://sheetwrite.vercel.app/docs/reference/compatibility-limits/#formula-evaluation) +list every limit, its unit, and its behavior. + +## No network, no custom code + +Formula calculation makes no network request and runs no JavaScript from the +document. Functions that need external data, such as `WEBSERVICE` or +`GOOGLEFINANCE`, are not supported. `TODAY` and `NOW` change only when the host +calls `store.recalculateVolatile(date)`. + ### Runtime ownership Source: https://sheetwrite.vercel.app/docs/concepts/runtime-ownership/ @@ -69,10 +158,17 @@ lets the store hand the renderer a transferable, typed-array-backed window. Because the engine is columnar and WASM-resident: - A sheet of 100k+ rows costs no DOM nodes and no per-cell JS objects. +- Bulk loads write packed blocks: one engine call per column, with all text +cells in one UTF-8 buffer instead of one string per cell crossing into WASM. - The render hot path is a single bulk read, not N cell lookups. - The same snapshot can be transferred to a worker for off-thread painting (see [Worker rendering](https://sheetwrite.vercel.app/docs/guides/worker-rendering/)). +The same engine calculates formulas. The default build is `@sheetwrite/wasm`, and +the optional full build `@sheetwrite/formulas` adds the analysis functions. See +[Calculation engine](https://sheetwrite.vercel.app/docs/concepts/calculation-engine/) for recalculation, +spills, and limits. + ## Virtualization & scaled scroll Only the rows intersecting the viewport are ever painted. `computeWindow` turns the @@ -470,6 +566,7 @@ export function Sheet({ columns, rows, save, onReady }: SheetProps) { save(event.transaction)} onReady={onReady} /> @@ -518,6 +615,7 @@ The binding is populated before `onReady` and cleared before reset or unmount. A emit("ready", event); @@ -719,6 +818,241 @@ window. Use the grid's keyboard navigation (arrows, Page Up/Down, Home/End — s [Interaction](https://sheetwrite.vercel.app/docs/guides/interaction/#keyboard-navigation)) to move focus; that scrolls new rows into view and into the mirror, and updates `aria-activedescendant`. +### Analysis formulas + +Source: https://sheetwrite.vercel.app/docs/guides/analysis-formulas/ + +The full formula engine, `@sheetwrite/formulas`, adds analysis functions to the +default engine: grouping and pivot tables, `LAMBDA` helpers, statistics, +regression, distributions, finance, dates, text and regex, reshaping, database +functions, and matrices. This guide shows one task for each family. The +[formula function contract](https://sheetwrite.vercel.app/docs/reference/formula-functions/) lists every +function, its signature, and its build. The +[formulas guide](https://sheetwrite.vercel.app/docs/guides/formulas/) explains syntax, references, errors, +and spills. To see these functions recalculate live over 20,000 orders, open the +[formula analysis showcase](https://sheetwrite.vercel.app/showcases/formulas/). + +## Select the engine + +Select the full engine one time, before you create a Grid or a store. The rest +of the API does not change. + +```ts +import { initSheetwrite } from "@sheetwrite/core"; +import * as formulas from "@sheetwrite/formulas"; + +await initSheetwrite(undefined, formulas); +``` + +With the default engine, these functions return `#NAME?`, and formula assist +does not suggest them. + +## The example data + +All examples use this sheet of 12 orders in `A1:G12`. Column E holds +`Units × Price`, and column F holds order dates. + +| Row | A Region | B Product | C Units | D Price | E Revenue | F Date | G Note | +| --- | --- | --- | ---: | ---: | ---: | --- | --- | +| 1 | North | Desk | 4 | 250 | 1,000 | 2026-01-05 | Ref INV-1042; ship by Friday | +| 2 | South | Chair | 10 | 80 | 800 | 2026-01-09 | Ref INV-1043; gift wrap | +| 3 | North | Chair | 6 | 80 | 480 | 2026-01-14 | Ref INV-1044 | +| 4 | East | Desk | 2 | 250 | 500 | 2026-01-20 | Ref INV-1045; call 555-0142 | +| 5 | South | Lamp | 15 | 30 | 450 | 2026-02-02 | Ref INV-1046 | +| 6 | East | Lamp | 8 | 30 | 240 | 2026-02-06 | Ref INV-1047; ship by Monday | +| 7 | North | Lamp | 12 | 30 | 360 | 2026-02-11 | Ref INV-1048 | +| 8 | South | Desk | 3 | 250 | 750 | 2026-02-17 | Ref INV-1049 | +| 9 | East | Chair | 7 | 80 | 560 | 2026-02-23 | Ref INV-1050; call 555-0187 | +| 10 | North | Desk | 5 | 250 | 1,250 | 2026-03-03 | Ref INV-1051 | +| 11 | South | Chair | 9 | 80 | 720 | 2026-03-10 | Ref INV-1052 | +| 12 | East | Desk | 1 | 250 | 250 | 2026-03-16 | Ref INV-1053; gift wrap | + +Cash flows for the finance examples are in `H1:I4`: −10,000 on 2026-01-01, +2,500 on 2026-04-01, 4,200 on 2026-09-01, and 6,000 on 2026-12-31. + +Every result below comes from the full engine. Results that spill are shown as +the cells they fill. + +## Summarize: GROUPBY, PIVOTBY, PERCENTOF + +`GROUPBY` groups rows by one or more key columns and aggregates the values. It +adds a total row by default. + +```text +=GROUPBY(A1:A12, E1:E12, SUM) +``` + +| Region | Revenue | +| --- | ---: | +| East | 1550 | +| North | 3090 | +| South | 2720 | +| Total | 7360 | + +`PIVOTBY` adds column keys: + +```text +=PIVOTBY(A1:A12, B1:B12, E1:E12, SUM) +``` + +| | Chair | Desk | Lamp | Total | +| --- | ---: | ---: | ---: | ---: | +| East | 560 | 750 | 240 | 1550 | +| North | 480 | 2250 | 360 | 3090 | +| South | 1520 | 750 | 450 | 2720 | +| Total | 2560 | 3750 | 1050 | 7360 | + +The optional arguments control headers, totals, and sort order. `PERCENTOF` as +the function gives each group's share, and a `LAMBDA` can be the function: + +| Formula | Result | +| --- | --- | +| `=GROUPBY(A1:A12, E1:E12, SUM, 0, 0, -2)` | North 3090, South 2720, East 1550 (no total, largest first) | +| `=GROUPBY(A1:A12, E1:E12, PERCENTOF, 0, 0)` | East 0.2106, North 0.4198, South 0.3696 | +| `=GROUPBY(B1:B12, C1:C12, LAMBDA(units, MAX(units)), 0, 0)` | Chair 10, Desk 5, Lamp 15 | + +A vector of functions, such as `HSTACK(SUM, AVERAGE)`, returns `#VALUE!`. Use +one function per `GROUPBY`. + +## Your own functions: LAMBDA, MAP, REDUCE, SCAN, BYROW + +`LAMBDA` makes a function from parameters and a calculation. The helpers call it +for each value, row, or column. + +| Formula | Result | +| --- | --- | +| `=MAP(C1:C3, D1:D3, LAMBDA(units, price, units * price))` | 1000; 800; 480 (spills down) | +| `=REDUCE(0, E1:E12, LAMBDA(total, x, IF(x >= 1000, total + x, total)))` | 2250 | +| `=SCAN(0, E1:E4, LAMBDA(running, x, running + x))` | 1000; 1800; 2280; 2780 (running total) | +| `=BYROW(C1:D3, LAMBDA(row, PRODUCT(row)))` | 1000; 800; 480 | +| `=LET(big, LAMBDA(x, x >= 1000), FILTER(B1:B12, MAP(E1:E12, big)))` | Desk; Desk | + +`LET` can name a `LAMBDA` and call it later. An uncalled `LAMBDA` returns +`#CALC!`, and a call with the wrong number of arguments returns `#VALUE!`. Calls +nest up to 64 deep. + +## Describe: statistics and ranking + +| Formula | Result | Meaning | +| --- | --- | --- | +| `=TRIMMEAN(E1:E12, 0.2)` | 587 | Mean revenue without the top and bottom 10% | +| `=PERCENTILE.EXC(E1:E12, 0.9)` | 1175 | 90th percentile order | +| `=RANK.AVG(E2, E1:E12)` | 3 | The South chair order is the third largest | +| `=SKEW(E1:E12)` | 0.784 | Revenue has a tail of large orders | +| `=FREQUENCY(E1:E12, {500, 1000})` | 6; 5; 1 | Orders up to 500, up to 1,000, and above | +| `=MODE.MULT({4, 10, 6, 4, 15, 10, 8})` | 4; 10 | Every most frequent value | + +## Forecast: regression + +Monthly revenue for six months was 1200, 1350, 1500, 1580, 1700, and 1850. + +| Formula | Result | +| --- | --- | +| `=FORECAST.LINEAR(7, {1200,1350,1500,1580,1700,1850}, {1,2,3,4,5,6})` | 1968 | +| `=TREND({1200;1350;1500;1580;1700;1850}, {1;2;3;4;5;6}, {7;8;9})` | 1968; 2093.14; 2218.29 | +| `=LINEST({1200;1350;1500;1580;1700;1850}, {1;2;3;4;5;6})` | 125.14, 1092 (slope, intercept) | + +`LINEST` and `LOGEST` accept more than one predictor column, the `const` +argument, and `stats = TRUE` for five rows of fit statistics. `TREND` and +`GROWTH` project new values from the same fit. + +## Estimate risk: distributions + +| Formula | Result | Meaning | +| --- | --- | --- | +| `=NORM.INV(0.95, 1000, 120)` | 1197.38 | Demand that covers 95% of days, with mean 1000 and deviation 120 | +| `=BINOM.DIST(3, 10, 0.2, FALSE)` | 0.2013 | Chance of exactly 3 returns in 10 orders at a 20% return rate | +| `=CONFIDENCE.T(0.05, STDEV.S(E1:E12), 12)` | 193.05 | Half-width of the 95% interval for mean revenue | + +The engine has 36 distribution functions: normal, Student t, chi-squared, F, +binomial, Poisson, exponential, gamma, beta, lognormal, and Weibull, with their +inverses. + +## Plan money: finance + +| Formula | Result | Meaning | +| --- | --- | --- | +| `=XIRR(H1:H4, I1:I4)` | 0.3897 | Annual return of irregular cash flows (39%) | +| `=XNPV(0.08, H1:H4, I1:I4)` | 1999.96 | Their value today at 8% a year | +| `=PMT(0.06/12, 36, -25000)` | 760.55 | Monthly payment on a 25,000 loan over 3 years at 6% | +| `=CUMIPMT(0.06/12, 36, 25000, 1, 12, 0)` | −1286.73 | Interest paid in the first year of that loan | + +The full engine also has `MIRR`, `NPER`, `CUMPRINC`, `EFFECT`, `NOMINAL`, and +depreciation functions (`SLN`, `DB`, `DDB`, `SYD`). + +## Schedule: working days and dates + +| Formula | Result | +| --- | --- | +| `=NETWORKDAYS.INTL(DATE(2026,1,1), DATE(2026,1,31), 1)` | 22 working days | +| `=WORKDAY.INTL(DATE(2026,1,30), 5, "0000011")` | 46059, that is 2026-02-06 | +| `=DATEDIF(DATE(2024,3,15), DATE(2026,1,5), "m")` | 21 full months | +| `=ISOWEEKNUM(DATE(2026,1,5))` | 2 | + +The weekend argument is a number or a seven-character mask that starts on +Monday: `"0000011"` means Saturday and Sunday. A holiday range is the optional +last argument. + +## Clean text: split, extract, and regex + +| Formula | Result | +| --- | --- | +| `=TEXTSPLIT("North,South,East", ",")` | North, South, East (spills across) | +| `=TEXTBEFORE(G1, ";")` | Ref INV-1042 | +| `=TEXTAFTER(G1, "; ")` | ship by Friday | +| `=REGEXEXTRACT(G1, "INV-[0-9]+")` | INV-1042 | +| `=REGEXREPLACE(G4, "[0-9]{3}-[0-9]{4}", "***-****")` | Ref INV-1045; call \*\*\*-\*\*\*\* | +| `=REGEXTEST(G6, "ship by")` | TRUE | +| `=ARRAYTOTEXT({"North", "South"})` | North, South | + +Regex uses the regex-lite syntax. Patterns with lookaround, backreferences, or +Unicode classes return `#VALUE!`, never a wrong match. Case-insensitive matching +folds ASCII letters only. + +## Reshape arrays + +| Formula | Result | +| --- | --- | +| `=VSTACK({"Region","Units"}, HSTACK(A1:A3, C1:C3))` | A header row on top of North 4, South 10, North 6 | +| `=TAKE(SORTBY(HSTACK(B1:B12, E1:E12), E1:E12, -1), 3)` | Desk 1250, Desk 1000, Chair 800 | +| `=WRAPROWS(SEQUENCE(6), 3)` | 1, 2, 3 / 4, 5, 6 | +| `=TOCOL({1,2;3,4})` | 1; 2; 3; 4 | +| `=EXPAND({1,2;3,4}, 3, 3, 0)` | The 2 × 2 array padded to 3 × 3 with 0 | + +`SORTBY` keeps equal items in their order. `TOCOL` and `TOROW` can skip blanks +and errors and read by row or by column. + +## Query tables: database functions + +Database functions take a table with a header row, a field, and a criteria table +with the same headers. Criteria columns use AND, and criteria rows use OR. + +| Formula | Result | +| --- | --- | +| `=DSUM({"Region","Units";"North",4;"South",10;"North",6}, "Units", {"Region";"North"})` | 10 | +| `=DAVERAGE({"Region","Units";"North",4;"South",10;"North",6}, "Units", {"Region";"North"})` | 5 | + +In a workbook, the table and the criteria are usually ranges, such as +`=DSUM(A1:E200, "Revenue", H1:H2)`. + +## Matrices + +| Formula | Result | +| --- | --- | +| `=MMULT({1,2;3,4}, {5;6})` | 17; 39 | +| `=MINVERSE({4,7;2,6})` | 0.6, −0.7 / −0.2, 0.4 | +| `=MDETERM({4,7;2,6})` | 10 | +| `=SUMSQ(C1:C3)` | 152 | + +## Limits + +- All these functions use the normal spill, shape, memory, and work limits. A +result that does not fit returns an error, not a partial result. +- `AGGREGATE` options that skip hidden rows have no effect, because formulas do +not see hidden-row state. +- Each family's exact argument rules are in the +[formula function contract](https://sheetwrite.vercel.app/docs/reference/formula-functions/). + ### Collaboration and offline work Source: https://sheetwrite.vercel.app/docs/guides/collaboration/ @@ -778,6 +1112,7 @@ An HTTP adapter can stay transport-neutral at the core boundary: ```ts import type { PersistenceAdapter, + PersistenceBatchCommitRequest, PersistenceCommitRequest, PersistenceCommitResponse, WorkbookSnapshot, @@ -806,6 +1141,20 @@ const persistenceAdapter: PersistenceAdapter = { ); return responseJson(response); }, + // Required for undo of very large edits; see "Large undo and atomic batches". + async commitBatch(request: PersistenceBatchCommitRequest) { + const { signal, ...body } = request; + const response = await fetch( + `/api/documents/${encodeURIComponent(request.documentId)}/operation-batches`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + signal, + }, + ); + return responseJson(response); + }, }; ``` @@ -848,6 +1197,12 @@ Handle `commit()` in one database transaction: 5. Periodically fold the log into `snapshot_json`; never rewrite operation versions or accept client-authored server timestamps. +Handle `commitBatch()` the same way, with one difference: the request has +`versionOperationCounts`, and it fills several consecutive versions. In one +database transaction, check the duplicate and the base version once, validate +every version, then append all of them or none. Store each version with its +`batch: { index, count }` position, and acknowledge with the last version. + Normalized cell tables are an alternative for products that need SQL queries over cell values. They do not replace the protocol: the host must still reconstruct a complete schema-versioned `WorkbookSnapshot`, preserve formula @@ -855,6 +1210,43 @@ source/style/metadata, and serialize structural operations under one document version. Mixing independent cell writes with the operation log breaks atomic row/column/formula semantics. +## Large undo and atomic batches + +Undo of a large clear restores the old cells. When the ordinary `setBlock` +operation is too large for one version, the Grid sends one `restoreBlock` +operation instead: the same cells as compressed JSON (`deflate-json-v1`). If +even that does not fit one version, the Grid splits the restore into an atomic +batch of up to 16 versions and 64 MiB. Undo stays one history step and one +`change` event. + +Before you accept 0.5.0 clients, make sure that your server: + +1. accepts `restoreBlock` wherever it switches over the `DocumentOp` union, and +decodes it as untrusted input. `@sheetwrite/core` does not export a server +decoder, so your decoder must do these checks, in this order, before it +changes the document: +- `encoding` is `"deflate-json-v1"`, and `decodedBytes` is a positive +integer of at most 64 MiB; +- `data` is canonical base64 (it decodes and re-encodes to the same text); +- the raw DEFLATE stream inflates into a buffer of exactly `decodedBytes` +bytes. Never inflate without that output limit: a small stream can expand +to gigabytes. Reject a stream that ends early or has bytes left over; +- the bytes are valid UTF-8 JSON of a `PackedCellBlock` whose row and +column counts match `range`, with at most 4,000,000 cells; +- every value, formula, reference, and style in the block passes the same +validation as a `setBlock` operation; +2. implements `commitBatch` as above, or accepts that `SyncCoordinator` +rejects a local step that needs more than one version; +3. publishes batch members with their `batch` field, so that peers apply the +whole batch at once; and +4. folds `restoreBlock` into snapshots as ordinary cells. Snapshots never +store restore streams. + +The [document operations reference](https://sheetwrite.vercel.app/docs/reference/document-operations/#atomic-multi-version-batches) +has the exact field rules, limits, and error codes. When an undo is above the +transaction limits even after this, the Grid changes nothing and reports it; +see [Interaction](https://sheetwrite.vercel.app/docs/guides/interaction/#undo-and-redo). + ## Durable pending commits `SyncCoordinator` can receive a `PendingCommitStorage` adapter. With one configured, it: @@ -1340,6 +1732,17 @@ required pages are loaded. `Store.queryCapability(sheet)` and `getCellLoadState(addr)` expose that state. Default chunk/cache values and eviction behavior are listed in [Compatibility and limits](https://sheetwrite.vercel.app/docs/reference/compatibility-limits/#rendering-interaction-and-paged-data). +#### Cells that visible formulas read + +A visible formula can read cells outside the visible window, for example a +variance column that reads a plan column far to the right. The Grid asks the +engine which same-sheet cells the visible formulas read, also through other +formulas, and requests those cells with the window. The formula then resolves +instead of staying `#LOADING!`. A cell budget equal to the prefetch byte limit +bounds this work, so a whole-column read does not load the full column in one +window. Reads from other sheets are not loaded this way; return those values +from the datasource or keep that sheet dense. + ### Serializable documents Use `WorkbookSnapshot` plus `validateWorkbookSnapshot()` at persistence @@ -2029,10 +2432,44 @@ grid.store.applyTransaction({ ## Supported formulas -The checked, versioned `sheetwrite.formula-capabilities` contract is the source of truth for function registration. Its generated reference publishes all **100 required-supported target functions plus every incumbent function**, grouped by family, with aliases, exact signature and semantic profiles, implementation/evidence paths, source links, and behavior status. Do not infer support from an Excel, Google Sheets, or OpenFormula function with a similar name. +The checked, versioned `sheetwrite.formula-capabilities` contract is the source of truth for function registration. Its generated reference lists every function in both engines, grouped by family, with aliases, exact signature and semantic profiles, implementation and evidence paths, source links, and behavior status. Do not infer support from an Excel, Google Sheets, or OpenFormula function with a similar name. [Browse the generated formula function contract →](https://sheetwrite.vercel.app/docs/reference/formula-functions/) +## Full formula engine + +Sheetwrite has two engine builds with the same API. `@sheetwrite/wasm` is the default engine: it has the standard function set and the smallest download. `@sheetwrite/formulas` is the full engine: the same engine with the analysis families below. An app selects one engine, one time, before it creates a Grid or a store: + +```ts +import { initSheetwrite } from "@sheetwrite/core"; +import * as formulas from "@sheetwrite/formulas"; + +await initSheetwrite(undefined, formulas); +``` + +| Family | Examples | +| --- | --- | +| Distributions | `NORM.DIST`, `T.INV.2T`, `CHISQ.DIST.RT`, `BINOM.DIST`, `GAMMA.INV`, `CONFIDENCE.T` | +| Descriptive statistics | `TRIMMEAN`, `PERCENTILE.EXC`, `RANK.AVG`, `SKEW`, `KURT`, `FORECAST.LINEAR` | +| Regression | `LINEST`, `LOGEST`, `TREND`, `GROWTH`, `FREQUENCY`, `MODE.MULT` | +| Finance | `XIRR`, `XNPV`, `MIRR`, `NPER`, `CUMIPMT`, `DDB` | +| Dates | `NETWORKDAYS.INTL`, `WORKDAY.INTL`, `DATEDIF`, `ISOWEEKNUM` | +| Text and regex | `TEXTSPLIT`, `TEXTBEFORE`, `TEXTAFTER`, `REGEXEXTRACT`, `REGEXREPLACE`, `ARRAYTOTEXT` | +| Database | `DSUM`, `DAVERAGE`, `DCOUNT`, `DGET`, `DMAX`, `DSTDEV` | +| Math and matrices | `AGGREGATE`, `MMULT`, `MINVERSE`, `MDETERM`, `SUMSQ`, `COMBIN` | +| Reshaping | `SORTBY`, `HSTACK`, `VSTACK`, `TOCOL`, `WRAPROWS`, `EXPAND` | +| LAMBDA | `LAMBDA`, `MAP`, `REDUCE`, `SCAN`, `BYROW`, `BYCOL`, `MAKEARRAY`, `ISOMITTED` | +| Grouping | `GROUPBY`, `PIVOTBY`, `PERCENTOF` | + +The default engine returns `#NAME?` for these functions, and formula assist does not suggest them. The [formula analysis showcase](https://sheetwrite.vercel.app/showcases/formulas/) runs thirteen panels of them live over 20,000 orders, and the [analysis formulas guide](https://sheetwrite.vercel.app/docs/guides/analysis-formulas/) shows each family with examples. + +Limits of the full engine: + +- `LAMBDA` parameters bind lexically, and `LET` can bind a `LAMBDA`. An uncalled `LAMBDA` returns `#CALC!`. A call with the wrong number of arguments returns `#VALUE!`. Calls are limited to a depth of 64 and 2,000,000 work units per outer helper; more returns `#NUM!`. +- `GROUPBY` and `PIVOTBY` accept one built-in function name, such as `SUM`, or a `LAMBDA` with one or two parameters. A vector of functions, such as `HSTACK(SUM,AVERAGE)`, returns `#VALUE!`. +- Regex functions do not support lookaround, backreferences, or Unicode classes. Such patterns return `#VALUE!`, never a wrong match. +- `AGGREGATE` options that skip hidden rows have no effect, because formulas do not see hidden-row state. + Function names are case-insensitive. Commas are the only documented argument separator. Interior omitted optional arguments are preserved (`XLOOKUP(key, keys, results,, 0)`). Locale-specific separators are not accepted. ## Scalars, coercion, and errors @@ -2068,7 +2505,7 @@ Coercion follows these documented rules: Bindings are lazy. Only names reachable from `calculation` are expanded and evaluated, so an unused cell read, error, or `NOW()` does not become a dependency, error, or volatile marker. A used binding preserves ordinary formula dependency, spill, copy/fill, and structural-reference behavior. `LET(x,1/0,7)` therefore returns `7`, while `LET(x,A1,x+1)` tracks `A1`. -One `LET` admits at most 126 bindings. Expansion admits at most 16,384 AST nodes across reachable bindings. Invalid name/arity/binding structure returns `#VALUE!`; expansion beyond the node ceiling returns `#NUM!`. `LET` does not create callable functions: `LAMBDA` and higher-order execution remain unsupported. +One `LET` admits at most 126 bindings. Expansion admits at most 16,384 AST nodes across reachable bindings. Invalid name/arity/binding structure returns `#VALUE!`; expansion beyond the node ceiling returns `#NUM!`. In the full engine, a `LET` value can be a `LAMBDA`; see [Full formula engine](#full-formula-engine). ## References, ranges, and named ranges @@ -2217,7 +2654,7 @@ Lookup errors in the scanned range propagate. Approximate modes validate orderin `FILTER`, `SORT`, `UNIQUE`, `TRANSPOSE`, `SEQUENCE`, `TAKE`, `DROP`, `CHOOSECOLS`, and `CHOOSEROWS` return rectangular values. A direct range formula such as `=A1:B4` also spills: - `FILTER(array, include, [if_empty])` accepts a one-column include range matching the array's rows or a one-row include range matching its columns. Shape mismatches are `#VALUE!`. No selected values returns `if_empty`, or `#CALC!` when omitted. -- `SORT(array, [sort_index], [sort_order], [by_col])` defaults to the first column, ascending. `sort_index` selects the row/column key; `sort_order` is `1` or `-1`; `by_col=TRUE` sorts columns instead of rows. Multi-key sorting is unsupported. +- `SORT(array, [sort_index], [sort_order], [by_col])` defaults to the first column, ascending. `sort_index` selects the row/column key; `sort_order` is `1` or `-1`; `by_col=TRUE` sorts columns instead of rows. For several keys, use `SORTBY` in the full engine. - `UNIQUE(array, [by_col], [exactly_once])` preserves first-seen order. `by_col=TRUE` compares columns; `exactly_once=TRUE` keeps only items occurring once. An empty result is `#CALC!`. - `TRANSPOSE(array)` swaps rows and columns. - `SEQUENCE(rows, [columns], [start], [step])` requires positive integer dimensions and defaults to one column, start `1`, step `1`; values fill row-major. @@ -2256,13 +2693,13 @@ The admission check happens before materialization when the shape is statically ## Unsupported categories -Formula evaluation performs no network request and runs no custom JavaScript. Automatic volatile functions beyond the explicit `TODAY`/`NOW` host-clock barrier, network functions, external/live-data providers, arbitrary external workbook references, database functions, cube/OLAP functions, and `LAMBDA` or higher-order array execution are unsupported. Examples include `RAND`, `RANDBETWEEN`, `INDIRECT`, `OFFSET`, `WEBSERVICE`, `GOOGLEFINANCE`, `IMPORT*`, `RTD`, `DSUM`, `CUBEVALUE`, `LAMBDA`, `MAP`, `REDUCE`, and `SCAN`. +Formula evaluation performs no network request and runs no custom JavaScript. Automatic volatile functions beyond the explicit `TODAY`/`NOW` host-clock barrier, network functions, external/live-data providers, arbitrary external workbook references, and cube/OLAP functions are unsupported. Examples include `RAND`, `RANDBETWEEN`, `INDIRECT`, `OFFSET`, `WEBSERVICE`, `GOOGLEFINANCE`, `IMPORT*`, `RTD`, and `CUBEVALUE`. Database functions such as `DSUM` and `LAMBDA` helpers such as `MAP` are in the full engine only. Unsupported names evaluate to `#NAME?` while preserving source for snapshots and interchange. There is no compatibility shim or side-effecting fallback. The generated [unsupported-category ledger](https://sheetwrite.vercel.app/docs/reference/formula-functions/#unsupported-categories) is derived from the versioned inventory. ## Performance evidence -No formula throughput or latency number is published from this guide because no checked final formula-performance evidence file is present. Follow the [performance evidence guide](https://sheetwrite.vercel.app/docs/guides/performance-resources/) to capture and freshness-check measurements; do not treat resource ceilings as benchmark results. +The [default and full formula engine results](https://sheetwrite.vercel.app/docs/guides/performance-resources/#default-and-full-formula-engines) compare both engines on the formula benchmark and time the analysis families. Follow the [performance evidence guide](https://sheetwrite.vercel.app/docs/guides/performance-resources/) to capture and freshness-check measurements; do not treat resource ceilings as benchmark results. ## Point mode and reference rewriting @@ -2445,6 +2882,34 @@ When the grid is focused and not editing: | Ctrl/Cmd + Shift + Z (or Ctrl/Cmd + Y) | Redo. | | Ctrl/Cmd + F | Open the find bar (when `config.find` is not `false`). | +## Undo and redo + +`grid.undo()` and `grid.redo()` (and Ctrl/Cmd + Z, Ctrl/Cmd + Shift + Z) step +through the last 200 recorded transactions. Remote operations from a +`SyncCoordinator` do not create undo entries. + +Undo of a large clear restores every old cell. When the restore is larger than +one transaction allows, the Grid sends one compressed `restoreBlock` operation. +If that still needs more than one server version, it uses an atomic batch, +which needs `PersistenceAdapter.commitBatch`. See +[Large undo and atomic batches](https://sheetwrite.vercel.app/docs/guides/collaboration/#large-undo-and-atomic-batches). + +If the restore is above the batch ceilings, cannot be split, or needs a +`commitBatch` that the adapter does not have, `grid.undo()` changes nothing. The +Grid emits `mutation-rejected` with a `resource-limit` issue and removes that +entry from the history, so older edits can still be undone. Show the reason to +the user: + +```ts +grid.on("mutation-rejected", ({ issues }) => { + for (const issue of issues) { + if (issue.kind === "resource-limit") { + console.warn(`Not undone: ${issue.resource} ${issue.actual} is above ${issue.max}.`); + } + } +}); +``` + ## Inline editing Editing happens in a single real `