# 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`从源码投射的 ATOM 指南
自定义公式 atoms
本指南由功能所属的 README 投射而来,使状态边界、职责和测试与实现保持相邻。