从源码投射的 ATOM 指南

自定义公式 atoms

本指南由功能所属的 README 投射而来,使状态边界、职责和测试与实现保持相邻。

打开权威的功能源码

# custom-formulas

Owns the user-defined custom-formula registry (Wave 8). UI core stores
registrations as plain `{ name, source, description?, paramLabels? }`
records keyed by uppercase name; the Solid host diffs the registry atom
and forwards add/remove to the worker, which `new Function('args',
source)`s the body and hands the resulting callable to the WASM
`Workbook` via `registerCustomFormula`.

The source-string boundary deliberately rules out a `(args) => ...` JS
function travelling across `postMessage`: closures cannot be cloned, and
`fn.toString()` + regex parsing for the body silently drops captured
state. Asking the host for a body string is both cheaper and safer —
the only thing the worker can see is the explicit `args` array.

**Authoritative engine contract:** `excel/rust/excel-core/src/CUSTOM_FORMULAS.md`
documents the WASM-side marshaling, error-token round-tripping, and the
exact precedence order the evaluator uses to resolve a name. This file
is the JS-side host API; the Rust doc wins on any disagreement about
the value boundary.

## State Decision Template

- Source atom:
  - Private `customFormulaRegistryStateAtom`: one aggregate per
    `@einfach/core` store owning `{ status, maxEntries, entries }`. The
    default cap is `256`; hosts may configure any safe integer from `0`
    through the hard ceiling `10_000`.
- Derived atoms:
  - `customFormulaRegistryAtom`:
    `ReadonlyMap<name, CustomFormulaRegistration>`. Mutations can only go
    through the command atoms, so callers cannot bypass capacity or
    lifecycle rules through the public atom API.
  - `customFormulaRegistryLifecycleAtom`: exposes
    `{ status: 'active' | 'disposed', maxEntries, size }` for host UI and
    diagnostics.
  - The host defines a derived `customFormulasSupportedAtom` reading
    `backend.registerCustomFormula != null` and uses it to gate optional
    UI (none in MVP — registration is programmatic, not menu-driven).
- Commands:
  - `configureCustomFormulaRegistryAtom(maxEntries)` — changes the cap
    without eviction. Invalid caps and caps below the current size return
    an explicit rejected outcome and leave state untouched.
  - `registerCustomFormulaAtom(registration)` — add or replace by name.
    Invalid names still throw for backwards compatibility. Capacity and
    disposed-state failures return explicit rejected outcomes; replacing
    an existing name remains valid at capacity.
  - `unregisterCustomFormulaAtom(name)` — reports `removed` or `not-found`.
  - `resetCustomFormulaRegistryAtom` — clears entries, preserves the cap,
    and remains active so the store can register/configure again.
  - `disposeCustomFormulaRegistryAtom` — clears entries and terminally
    disposes this store. Later register, unregister, configure, and reset
    commands reject explicitly without mutation.
- Helper:
  - `validateCustomFormulaName(name)` — returns
    `{ ok: true } | { ok: false; reason }` where `reason` is one of
    `'name-empty' | 'name-format' | 'name-shadows-builtin'`. Hosts that
    want a UI affordance can call this directly without round-tripping
    through the register atom.
- Scale bound: one bounded map per store; no per-name families. Capacity
  rejection never evicts an older formula and never publishes a new map.
- Backend reads: optional `registerCustomFormula(name, source)` /
  `unregisterCustomFormula(name)`. Host adapters that omit these
  methods make accepted registry changes core-only (the host effect
  skips the worker call); this is the same degraded-feature shape every
  other Wave 7/8 optional port uses.
- Per-cell atom risk: none.
- Tests: `test/custom-formulas.test.ts` (core),
  `excel/solid-excel/test/vnext-custom-formulas.test.tsx` (host).

### Compatibility boundary

Existing repository consumers that read or subscribe to
`customFormulaRegistryAtom`, and existing calls to the register/unregister
command atoms, keep their call shape. The public registry atom itself is now
read-only: its former direct-setter capability is intentionally removed because
it could bypass capacity and lifecycle invariants. This is a type-level breaking
boundary for external consumers that wrote a replacement map directly; migrate
those writes to the configure/register/unregister/reset/dispose commands.

## Registry lifecycle

```mermaid
stateDiagram-v2
  [*] --> Active
  Active --> Active: configure accepted
  Active --> Active: register / replace / unregister
  Active --> Active: reset / clear entries
  Active --> Active: capacity or config rejected / no mutation
  Active --> Disposed: dispose / clear entries
  Disposed --> Disposed: register / unregister / configure / reset rejected
  Disposed --> Disposed: repeated dispose / already-disposed
```

The registry transition only governs UI-core state. It does **not** prove
that an asynchronous backend registration which acknowledges after reset or
dispose has been removed remotely. Closing that late-ACK race belongs in the
Solid Provider integration, where the in-flight request and backend handle
are owned; it is a separate follow-up rather than an implied core guarantee.

## Name rules

- Regex: `/^[A-Z][A-Z0-9_.]*$/`. Register requires the as-written name to
  satisfy the upper-case format. Unregister normalizes incoming names, so
  `'mytax'` and `'MYTAX'` resolve to the same registry slot.
- Must not shadow a name in `BUILTIN_FORMULA_NAMES`. That set unions
  two sources:
  1. `ENGINE_BUILTIN_FORMULA_NAMES` — the authoritative mirror of the
     Rust evaluator's `is_builtin_function_name` arms, auto-generated
     by `scripts/extract-builtin-names.mjs` from
     `excel/rust/excel-core/src/eval.rs` (includes `LAMBDA`, `LET`,
     `IFERROR`, `XLOOKUP`, `MAP`, `REDUCE`, the `IM*` complex family,
     the finance batch, …).
  2. `FORMULA_FUNCTION_SPECS` — the IntelliSense seed registry under
     `formula-functions/registry.ts`.
- Re-registering an existing custom name silently replaces the previous
  source / metadata (Excel semantics).
- **No holes.** Every name the engine dispatches is reserved, so no
  registration can be accepted and then silently shadowed at eval time.
  `RESERVED_NAME_WHITELIST` in
  `excel/rust/excel-core/tests/reserved_name_parity.rs` is the escape
  hatch for a deliberate exception, and it is currently **empty**.
  Notably `REGEXTEST` / `REGEXEXTRACT` / `REGEXREPLACE` are reserved
  even though they are `regex-formulas`-gated and do not exist in a
  lite build: reserving them costs lite hosts the ability to polyfill
  the trio here, and buys the guarantee that one workbook never
  computes different values under lite vs full.

If the Rust engine adds a new built-in arm, re-run
`node excel/spreadsheet-ui-core/scripts/extract-builtin-names.mjs`