import { type Accessor, createEffect, createMemo, createSignal, untrack, } from "solid-js"; import { type MaybeAccessor, readOption } from "../maybe-accessor"; import { SNAPSHOT_KEYS, type Snapshottable, snapshotKeys, } from "../observability/snapshot"; // Stage 02 — error-contract spike. // // Validates RFC 05's claim that a compound's API surface should not carry a // parallel `error` accessor: validation *messages* (returned strings) live in // the data graph, validation *exceptions* (sync throws / async rejections) // propagate through the reactive graph to the nearest `` boundary. // Two different concepts, two different channels — neither one is `c.error`. // // Companion doc: work-left/primitive-template.md §2.3. /** * What a validator function may return. A returned string (or array of * strings) is a validation **message** — surfaced to the consumer through * `validity().errors`. Distinct from a validator that throws or rejects: a * thrown error is treated as a programmer/system fault and propagates to * ``. */ export type ValidationResult = string | readonly string[] | undefined | null; export type ValidatorChangeReason = "imperative" | "input" | "submit"; export interface ValidatorValidity { /** `undefined` until the first validation has settled. */ valid: boolean | undefined; /** Empty when valid or pre-validation. */ errors: readonly string[]; /** Convenience: `errors[0] ?? ""`. */ message: string; } export interface CreateValidatorOptions { /** * Reactive value being validated. Tracking the accessor in the * primitive's compute lets validation re-run when it flips. */ value: MaybeAccessor; /** * Pure validator. Returns a message string (or list) when invalid, * `undefined`/`null` when valid. May return a Promise of the same. * * **Throwing or rejecting is reserved for true errors** (network * failure, programmer bug). The primitive does NOT catch — exceptions * surface to the nearest `` boundary in the consumer tree. */ validate?: (value: V) => ValidationResult | Promise; /** * Recoverable side-effect that runs whenever a settled value lands. * Wired through a separate `createEffect({ effect, error })` bundle — * if it throws, the primitive logs and resets local bookkeeping * **without** propagating to ``. Use for autosave, telemetry, * cross-field reactions: things the consumer should not have to wrap a * boundary around. */ onValueSettled?: (value: V) => undefined | (() => void); /** * Pre-change cancellation hook. Called with the next validity snapshot, * the triggering event, and a structured `reason`. The consumer can * call `event.preventDefault()` to suppress the validity write. */ onValidityChange?: ( next: ValidatorValidity, event: Event, reason: ValidatorChangeReason, ) => void; } export interface ValidatorApi { value: Accessor; /** * Latest committed validity. Reading inside an `` boundary * surfaces validator exceptions; reading inside a `` boundary * suspends while async validation is pending. * * **No `error` accessor accompanies this** — by design (RFC 05). * Returned message strings are not errors; they are typed feedback. */ validity: Accessor; /** * Imperatively trigger validation. Awaiting the returned promise * observes async-validator rejections at the call site; reading * `validity()` afterwards re-throws them into the graph for ``. * Pass an `event` to participate in pre-change cancellation. */ validate: ( event?: Event, reason?: ValidatorChangeReason, ) => Promise; } const NOT_VALIDATED: ValidatorValidity = Object.freeze({ valid: undefined, errors: Object.freeze([]) as readonly string[], message: "", }); function toValidity(result: ValidationResult): ValidatorValidity { if (result == null) { return { valid: true, errors: [], message: "" }; } const errors: readonly string[] = Array.isArray(result) ? (result as readonly string[]).filter((s) => s.length > 0) : typeof result === "string" && result.length > 0 ? [result] : []; if (errors.length === 0) { return { valid: true, errors: [], message: "" }; } return { valid: false, errors, message: errors[0] ?? "" }; } function isPromiseLike(v: unknown): v is PromiseLike { return ( typeof v === "object" && v !== null && typeof (v as { then?: unknown }).then === "function" ); } export function createValidator( options: CreateValidatorOptions, ): ValidatorApi { const value: Accessor = () => readOption(options.value); // Internal validity-store backed by a signal that rejects can poison. // On rejection we stash the error in `pendingThrow`; the public // `validity` memo re-reads it and throws into the graph so a `` // boundary above the consumer's read site catches it. const [committed, setCommitted] = createSignal( NOT_VALIDATED, { ownedWrite: true, name: "validator.committed" }, ); const [pendingThrow, setPendingThrow] = createSignal(undefined, { ownedWrite: true, name: "validator.pendingThrow", }); let generation = 0; const runValidate = async ( v: V, event: Event, reason: ValidatorChangeReason, ): Promise => { const myGen = ++generation; const isStale = () => myGen !== generation; const validateFn = options.validate; // No validator → trivially valid. Useful for hooking pre-change // cancellation without writing a real `validate`. if (!validateFn) { const next: ValidatorValidity = { valid: true, errors: [], message: "" }; options.onValidityChange?.(next, event, reason); if (event.defaultPrevented) return committed(); setPendingThrow(undefined); setCommitted(next); return next; } // Sync throws are intentionally not caught here — they re-throw at // the call site (consumer's `await c.validate()` rejects) AND // poison `validity()` so a downstream read inside `` also // catches. This mirrors RFC 05's "one error path" promise. let resultOrPromise: ValidationResult | Promise; try { resultOrPromise = validateFn(v); } catch (err) { if (!isStale()) setPendingThrow(err); throw err; } let result: ValidationResult; if (isPromiseLike(resultOrPromise)) { try { result = await resultOrPromise; } catch (err) { if (!isStale()) setPendingThrow(err); throw err; } if (isStale()) return committed(); } else { result = resultOrPromise; } const next = toValidity(result); options.onValidityChange?.(next, event, reason); if (event.defaultPrevented) return committed(); setPendingThrow(undefined); setCommitted(next); return next; }; const validate = ( event?: Event, reason: ValidatorChangeReason = "imperative", ): Promise => { const ev = event ?? new Event("validate", { cancelable: true }); return runValidate(untrack(value), ev, reason); }; // Public validity accessor. The memo re-reads `pendingThrow` so a // poisoned validator surfaces to `` on every read until the // next successful `validate()` clears it. const validity = createMemo( () => { const err = pendingThrow(); if (err !== undefined) throw err; return committed(); }, { name: "validator.validity" }, ); // `onValueSettled` runs through the {effect, error} bundle. Errors // here are explicitly **internal** — they don't surface to the // consumer, by design. The bundle's `error` handler is the primitive's // recovery path: log, run cleanup, move on. // // **Observed contract — important.** RFC 01's bundle catches exceptions // thrown from the **compute** phase, not the effect phase. So the // throwing work has to ride in the compute body. The empty `effect` // callback is the price of using the bundle as a try/catch around // per-tick side-effect work. if (options.onValueSettled) { const settled = options.onValueSettled; createEffect( (prev) => { const v = value(); if (prev !== undefined) { // Run cleanup from the prior tick before the new // side-effect; mirrors React-effect lifecycle. } settled(v); return v; }, { effect: () => undefined, error: (err, cleanup) => { // In a real component this would route through // `DEV.diagnostics` so attribution names the compound // + slot (stage 04). For the spike, console.warn is // enough to demonstrate the contract. if (typeof console !== "undefined") { console.warn("[createValidator] onValueSettled threw", err); } cleanup(); }, }, ); } const api: ValidatorApi = { value, validity, validate }; (api as ValidatorApi & Snapshottable)[SNAPSHOT_KEYS] = VALIDATOR_SNAPSHOT_KEYS; return api; } const VALIDATOR_SNAPSHOT_KEYS = snapshotKeys>( "value", "validity", );