diff --git a/.agents/skills/adr-keeper/SKILL.md b/.agents/skills/adr-keeper/SKILL.md index d4455b4..5d5b439 100644 --- a/.agents/skills/adr-keeper/SKILL.md +++ b/.agents/skills/adr-keeper/SKILL.md @@ -31,6 +31,8 @@ accordingly. **Status is explicit.** Each ADR must have a clear status: Proposed, Accepted, Rejected, Superseded. **Concise over comprehensive.** Focus on the decision, context, and consequences. Avoid unnecessary detail. + +**No changelog language, no ticket numbers.** An ADR records why a decision was made, not when it shipped or which task tracked it. Never write release/version numbers (e.g. "ships in 1.5.2"), ticket/task IDs (e.g. "CE-033"), or shipped-vs-pending status framing tied to a release inside an ADR's Context/Decision/Alternatives/Consequences — that content is transitory and belongs in `CHANGELOG.md` or `TODO.md`, which track it properly. An ADR's own `Status` field (Proposed/Accepted/Rejected/Superseded) is the only place decision lifecycle belongs, and it describes the *decision's* status, not a release's. If a decision text leans on "not yet shipped" or a ticket ID to make sense, that is a sign the reasoning itself isn't decoupled from the release calendar yet — rewrite it so the decision stands on its own. The only permissible exception is when an ADR governs the decision for a breaking change. diff --git a/.agents/skills/adr-keeper/references/adr-index.md b/.agents/skills/adr-keeper/references/adr-index.md index 5e15deb..adbbb32 100644 --- a/.agents/skills/adr-keeper/references/adr-index.md +++ b/.agents/skills/adr-keeper/references/adr-index.md @@ -1,6 +1,6 @@ # ADR Index -**Last updated:** 2026-08-16 +**Last updated:** 2026-08-19 **Total ADRs:** 18 | # | ADR | Status | Related Requirements | diff --git a/.agents/skills/architect/workflows/architecture.md b/.agents/skills/architect/workflows/architecture.md index 882954d..2129c5f 100644 --- a/.agents/skills/architect/workflows/architecture.md +++ b/.agents/skills/architect/workflows/architecture.md @@ -21,14 +21,11 @@ Propose a solution. For each major decision: state the choice, name alternatives Show the design to the user and wait for confirmation before writing documents. -## Step 5: Update ARCHITECTURE.md +## Step 5: Update ARCHITECTURE.md and record the decision as an ADR -Update relevant sections to reflect the agreed design. Add a row to the Key Decisions table for every significant decision: +ARCHITECTURE.md is a high-level overview only — it does not hold decision records (see its introductory note: "For detailed architectural decisions, see the ADR directory"). Update relevant narrative sections to reflect the agreed design, keeping it accurate and concise — it is the developer's primary reference. (Tech-writer will update it again post-implementation for code-level accuracy.) -| Decision | Choice | Alternatives Considered | Rationale | -|----------|--------|------------------------|-----------| - -Keep it accurate and concise — it is the developer's primary reference. (Tech-writer will update it again post-implementation for code-level accuracy.) +For every significant decision, use the `adr-keeper` skill to create a new ADR (choice, alternatives considered, rationale, status). If the decision is a backport, extension, or narrower application of an existing ADR's vocabulary rather than a fresh choice, create a new ADR that references the original as related — do not edit an Accepted ADR in place (ADRs are immutable once accepted; see `adr-keeper`'s own rules) and do not duplicate its content back into ARCHITECTURE.md. ## Step 6: Write tasks to TODO.md @@ -43,7 +40,7 @@ Create `TODO.md` at the project root if it doesn't exist. - Design traces back to REQUIREMENTS.md — no features invented -- Key Decisions table updated in ARCHITECTURE.md for every significant choice +- An ADR created (via `adr-keeper`) for every significant choice; ARCHITECTURE.md updated only for high-level narrative, not decision records - TODO.md has ordered tasks with IDs and sufficient context - No task requires the developer to guess intent or make architectural decisions - Open questions resolved or escalated to the user diff --git a/.agents/skills/cause-effect-dev/SKILL.md b/.agents/skills/cause-effect-dev/SKILL.md index 87a9e54..8654e91 100644 --- a/.agents/skills/cause-effect-dev/SKILL.md +++ b/.agents/skills/cause-effect-dev/SKILL.md @@ -1,9 +1,7 @@ --- name: cause-effect-dev description: > - Expert developer for the @zeix/cause-effect reactive signals library. Use when - implementing features, fixing bugs, writing tests, or answering questions about - the library's internals, public API, or design decisions. + Expert developer for the @zeix/cause-effect reactive signals library. Use when implementing features, fixing bugs, writing tests, or answering questions about the library's internals, public API, or design decisions. user_invocable: false --- @@ -22,6 +20,9 @@ For consumer projects that use `@zeix/cause-effect` as a dependency, use the `ca **Run `bun test`** after every change. +**No ticket numbers in source or tests.** Never write an issue, PR, or ticket number +(`#123`, `CE-456`) into a source comment or a test description. The branch or ticket closes and the reference goes stale — a future reader can't resolve it. Describe the behavior, constraint, or bug being guarded against directly instead; put ticket references in the commit message, where they belong. + **Composite derivation over effect-driven writes.** `adr/0018-shape-indexed-signal-types.md` documents the anti-pattern the library actively closes: an effect that reads a `Task`/`Memo` and writes the result into a `Store`/`List` with `.set()`. When implementing or reviewing a feature that fills one signal from another, prefer extending `deriveStore`/`deriveList`'s async and per-item derivation paths over adding a write path — a discouraged pattern surviving only because no derivation covers the case is itself a signal the derivation surface has a gap. @@ -83,4 +84,4 @@ All in `workflows/`: | write-tests.md | Write or update tests for a signal type or behavior | | answer-question.md | Answer questions about the API, internals, or design | | update-perf-baseline.md | Re-point the performance regression test at a new release | - \ No newline at end of file + diff --git a/.agents/skills/changelog-keeper/SKILL.md b/.agents/skills/changelog-keeper/SKILL.md index 9f284db..e11ebfa 100644 --- a/.agents/skills/changelog-keeper/SKILL.md +++ b/.agents/skills/changelog-keeper/SKILL.md @@ -1,8 +1,7 @@ --- name: changelog-keeper description: > - Maintain CHANGELOG.md for the @zeix/cause-effect library. Use after meaningful code - changes, when asked to add release notes, or to prepare a release. + Maintain CHANGELOG.md for the @zeix/cause-effect library. Use after meaningful code changes, when asked to add release notes, or to prepare a release. user_invocable: true --- @@ -67,4 +66,5 @@ When asked to release a version: - **Include migration notes** under Changed or Removed when behavior breaks compatibility. State clearly what consumers must change and why. - Use backticks for all public API names, internal types, flags, and file names. - \ No newline at end of file +- **No ticket numbers.** Never reference an issue, PR, or ticket number (`#123`, `CE-456`). It becomes a dead link once the branch or ticket closes. Describe the change itself — that is what a consumer needs, and it does not go stale. + diff --git a/.agents/skills/tech-writer/references/tone-guide.md b/.agents/skills/tech-writer/references/tone-guide.md index 71df2e3..4d72256 100644 --- a/.agents/skills/tech-writer/references/tone-guide.md +++ b/.agents/skills/tech-writer/references/tone-guide.md @@ -1,7 +1,5 @@ -Writing tone, register, and conciseness rules for each document maintained by the tech-writer -skill. Violating the tone is as wrong as a factual error — each document has a distinct -primary reader and serves a distinct purpose. +Writing tone, register, and conciseness rules for each document maintained by the tech-writer skill. Violating the tone is as wrong as a factual error — each document has a distinct primary reader and serves a distinct purpose. @@ -15,10 +13,9 @@ These rules apply to every document without exception: - **No changelog language in documentation.** Documents state current truth. Never write "previously", "as of version X", "we changed", or "now supports". Those belong in CHANGELOG.md. -- **No meta-commentary.** Do not write "This section explains…" or "See below for…". - Say the thing directly. -- **Backtick all code.** Every API name, flag, file name, type name, option key, and - shell command is wrapped in backticks, even mid-sentence. +- **No meta-commentary.** Do not write "This section explains…" or "See below for…". Say the thing directly. +- **Backtick all code.** Every API name, flag, file name, type name, option key, and shell command is wrapped in backticks, even mid-sentence. +- **No ticket numbers.** Never reference an issue, PR, or ticket number (`#123`, `CE-456`) in an authoritative document or JSDoc. The branch or ticket closes; the reference goes stale and means nothing to a future reader. State the current behavior or constraint on its own terms — if history matters, it belongs in a commit message or `CHANGELOG.md` entry, not here. @@ -225,6 +222,5 @@ acceptable if they read naturally as a tooltip. - `@param type` annotations — TypeScript already shows the type - JSDoc that restates the TypeScript signature in prose - Generic descriptions that would apply to any function ("Creates and returns a…") -- Multi-paragraph descriptions — if it needs that much explanation, the API design may - need review - \ No newline at end of file +- Multi-paragraph descriptions — if it needs that much explanation, the API design may need review + diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 26f2d24..409ba64 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -200,20 +200,6 @@ For a derived external-push Store (`deriveStore(seed, { watched })`), per-proper Return types remain honest: `byKey(k): S | undefined` etc. on List/Collection (a runtime string may not be a present key). `Store.byKey` is non-nullable because Store keys are statically known from `T`. -## Key Decisions - -| Decision | Choice | Alternatives Considered | Rationale | -|----------|--------|------------------------|-----------| -| Sync callback returning a `Promise` in Memo/Slot | Throw `PromiseValueError` in `recomputeMemo()` (`graph.ts`) the first time a non-`async` callback's return value is thenable | (a) Auto-detect ahead of time in `isAsyncFunction` and reclassify as Task; (b) leave as silent, undocumented misclassification (status quo) | Reclassifying requires invoking the callback before the Memo/Task routing decision is made, which breaks Memo's lazy-evaluation contract and is unreliable for branchy functions. Morphing a live `MemoNode` into a `TaskNode` after callers already hold a `Memo` would silently change `.get()` semantics (synchronous return vs. throws-until-resolved) with no compile-time signal — unsound. A single check at the existing `recomputeMemo()` choke point catches Memo, Slot, and `createComputed`/`createSignal` misuse uniformly, costs one `typeof` check per recompute, and only fires on code that was already broken (non-breaking, "Fixed" changelog category). | -| Effects writing signals they depend on | Bounded convergence: `flush()` drains in passes over queue snapshots (Svelte-style), so self-writing effects re-run until settled and always observe final values; a graph that doesn't settle within 1000 passes throws `EffectConvergenceError`. `propagate()` preserves `FLAG_RUNNING` on effects and `flush()` skips mid-run effects, preventing re-entrant `runEffect` during creation-time writes | (a) Status quo (rejected: `runEffect` clobbered the effect's own dirty re-mark with `CLEAN`, so even converging clamp effects ended one run stale — rendering the pre-clamp value — and mutual effect writes looped until heap exhaustion with no guard); (b) per-effect run cap (rejected: cannot detect ping-pong between effects — each effect individually settles every pass); (c) raw queue-length cap (rejected: false-positives on wide graphs with many distinct dirty effects) | A pass cap is the only metric that is both sound and complete: a settled graph does exactly one pass regardless of effect count, while any non-converging cycle — self-loop, ping-pong, or longer — forces unbounded passes. Converging self-writes (clamping, write-once init) remain supported; non-settling graphs fail loudly at the triggering `set()` instead of silently diverging or hanging. | -| Signal type taxonomy (v2.0, proposed) | Index types by shape (single / keyed sequence / keyed record) × mutability = 6 types, plus `Effect` and `Slot`. Origin moves to the construction verb: `create*` → mutable, `derive*` → readonly. Every shape becomes derivable from every origin. See [ADR-0018](adr/0018-shape-indexed-signal-types.md) | (a) Fill the empty matrix cells but keep 9 type names (rejected: leaves `.set()` reachable on derived values, so the discouraged pattern stays available and idiomatic-looking); (b) document the prohibition harder (rejected: a training-set prior is not answerable by prose — if `.set()` exists it will be called); (c) three factories total with full runtime dispatch (rejected: cannot distinguish sync derivation from external push, and pulls async + watched machinery into every bundle, breaking the ≤4 kB core budget); (d) twelve `create{Origin}{Shape}` factories (rejected: 12 construction names for 6 types is a worse ratio than the status quo) | Users write to state from effects because the derivation matrix has holes — `Task` → keyed sequence and any source → `Store` have no derivation path at all, so the effect is the only door. Closing the holes and removing the setter from derived types are individually insufficient and jointly sufficient: the first makes the correct path exist, the second makes the incorrect one a compile error. Collapsing the taxonomy falls out for free, because `Sensor` is already a `State` without a setter and `Collection` is already a `List` without mutators — both are mutability distinctions wearing the costume of type distinctions | -| `isPending` / `abort` placement (v2.0, proposed) | Free functions in the graph utilities, alongside `batch`, `untrack`, and `match` | (a) Methods on `Task`, as in v1.x (rejected: after the collapse this forces `AsyncSignal`/`AsyncList`/`AsyncStore` subtypes, restoring a 9-name taxonomy); (b) methods on the base `Signal` (rejected: a closure per node on the synchronous hot path, paid by every `State`, for a capability most signals do not have) | Asynchrony is an origin, not a shape. Any of the three shapes can be derived asynchronously, so the accessor must be shape-agnostic. The `pendingNode: StateNode` mechanism of ADR-0001 is unchanged — only the accessor moves and its domain widens. Consumers gain the ability to ask a derived `List` or `Store` whether it is still loading, which no v1.x API expresses | -| Unset state of async composites (v2.0, proposed) | `options.initial` is required for `deriveList`/`deriveStore` with an async input. `deriveCell` keeps `UnsetSignalValueError` and gains `initial` as optional | (a) Throw like `Task` (rejected: `length` and `Symbol.iterator` throwing is a sharp edge, and every consumer would need `match()` merely to read a derived collection); (b) default silently to `[]` / `{}` (rejected: loading-empty becomes indistinguishable from resolved-empty at the point of use) | Requiring `initial` makes the composite never unset, so the whole lookup surface is total. `isPending()` carries the loading distinction instead of the value doing it. `deriveCell` keeps its current behaviour because `match()`'s `nil` branch depends on it | -| Bundle-size limits during refactoring | Split the figure by role: the tree-shaken core budget is a hard promise; the full-library figures are a working diagnostic with deliberate slack, re-baselined from measurement at each release and explicitly not defended during a refactor | (a) Status quo — both absolute and hard (rejected: gzip and minified move in opposite directions under deduplication, so a hard gzip limit selects against consolidating duplicated code, which is the change that most improves the codebase); (b) ratchet against the last published release with a tolerance, as `regression-performance.test.ts` does (rejected for now: it makes every refactor's budget depend on release cadence, and the failure it would catch — accidental blowup — is already caught by a generous absolute ceiling); (c) raise the limits without changing their status (rejected: leaves the same trap one branch later) | The number was doing two incompatible jobs. As a *promise* it must be hard and must reflect what a consumer actually ships — which, given tree-shaking, is the core figure, not the full-library one. As a *regression detector* it must tolerate the byte-level noise that correct refactoring produces. Separating them lets the promise stay strict while the diagnostic stops distorting design. Re-baselining is a release gate, not a routine edit: lowering the ceiling toward measured usage at release keeps it meaningful, raising it mid-branch to unblock a commit does not | -| Other 8 documented "non-obvious behaviors" (conditional reads delaying `watched`, `equals` suppressing subtrees, lazy `watched`/`unwatched` lifecycle stability, Task abort-on-change, Sensor/Task unset state, synchronous scope cleanup, `untrack` vs `watched` independence, `byKey().set()` vs `list.replace()`) | No code changes. Reframe as direct, predictable consequences of the dependency-tracking model in developer-facing docs rather than standalone gotchas | Changing `byKey().set()` to always propagate structurally (rejected: would force every item signal to carry a permanent edge to its list's structural node regardless of whether anything observes structurally, costing a `propagate()` traversal on every item write — conflicts with the "minimal work" performance constraint, and `list.replace()` already exists as the correct API for this case, pinned by `test/list.test.ts:261`) | Each behavior is either inherent to any correct fine-grained reactive graph (read-based edge creation, two-level dirty/check flagging, lazy lifecycle keyed on sink count) or an already-decided trade-off with a working escape hatch. The fix is conceptual, not code: a reader who understands the model shouldn't find these surprising. Serves REQUIREMENTS.md goal #2 (predictable mental model) without touching synchronous-path performance (goal #4). | -| 1.x v2-bridge names for the single-value shape (backported from ADR-0018 Revision 2026-08-17) | `createCell` (an alias of `createState` — the single-value mutable signal, value taken verbatim, no shape conversion), `deriveCell`, and `DeriveCellOptions` are the bridge names as of 1.5.1 (`src/signal.ts`); the 1.5.0 `deriveSignal`/`DeriveSignalOptions` remain as deprecated aliases, removed in 2.0. `createSignal` keeps its shape dispatch unchanged — 2.0 removes the dispatch with no single replacement, and the codemod flags its call sites. `Signal`, `isSignal`, and `isMutableSignal` are untouched — 1.x already carries the correct umbrella meaning | (a) Keep the 1.5.0 names until 2.0 (rejected: they encode the superseded narrow-`Signal` meaning, so everyone who adopts them now — consumers and LLMs alike — faces a second rename at 2.0 and trains on the wrong vocabulary meanwhile); (b) Alias the wide `createSignal` to `createCell` (rejected: v2's `createCell` is the narrow single-value factory, so the alias would work in 1.x and silently flip meaning at the 2.0 boundary under a terminal bridge name); (c) Remove the 1.5.0 names outright in 1.5.1 (rejected: REQUIREMENTS.md mandates backward compatibility from 1.0 onward; removal is reserved for a major) | The Revision settled that `Signal` is the structural umbrella and the single-value shape is `Cell`. 1.5.0 shipped its single-value derive bridge under the superseded meaning one release before the Revision; correcting the bridge now means the 1.x→2.0 migration path teaches exactly one vocabulary, and the one-release-old names carry a deprecation pointing at the final names. | -| `watched` activation for external-push Stores (backported from v2 CE-015) | A lifecycle anchor node on the `deriveStore(seed, { watched })` facade: a source node that carries watcher edges but never holds or propagates a value; every observation form — structural (`get()`/`keys()`) or per-property (`byKey()`/proxy) — links it (`src/nodes/store.ts`) | (a) Link a structural edge on property reads (rejected: defeats per-property granularity — ADR-0015 — by making every `store.prop` read subscribe to "any key changed"); (b) Activate eagerly at construction (rejected: breaks the lazy lifecycle contract and starts external resources nobody watches) | An external-push Store has no value source other than `watched`'s `emit`, so property-only consumption must activate the lifecycle or the store stays frozen at its seed forever. The anchor reuses `unlink()`'s existing sink-count lifecycle (ADR-0011) without creating any graph edge, so activation and tracking remain separate concerns. | - ## Testing Strategy All tests live in `test/`. The `test` script runs the full suite. There is no formal separation of unit and integration tests. diff --git a/CHANGELOG.md b/CHANGELOG.md index a2366a0..c0c3d1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [Unreleased] + +### Added + +- **`Cell` / `MutableCell` types, `isCell(x)` / `isMutableCell(x)` guards**: The 1.x bridge for [ADR-0018](adr/0018-shape-indexed-signal-types.md) §8's shape-indexed `Cell` type. `Cell = State | Memo | Task | Sensor` — a genuine structural narrowing of `Signal`, not just a rename: each origin already carries a distinct `Symbol.toStringTag` literal (`'State' | 'Memo' | 'Task' | 'Sensor'`), so the union excludes `List` / `Store` / `Collection` at the type level with no runtime tag change. `MutableCell = State`, an alias matching `createCell`'s existing return value. `isCell`/`isMutableCell` check `Symbol.toStringTag` membership; `isSignal`/`isMutableSignal` keep their unchanged umbrella meaning. `deriveCell`'s overloads now return `Cell` instead of the wider `Signal`, and `createCell` now returns `MutableCell` instead of `State` — both widening-safe, since every `Cell`/`MutableCell` value already satisfies `Signal`/`MutableSignal` structurally, so no existing caller's code breaks. + +### Fixed + +- **`deriveCell(input, options?)` mis-inferred a zero/single-arg async callback's return type as `Promise`** (`src/nodes/cell.ts`, formerly `src/signal.ts`): The overloads declared the sync `MemoCallback` form before the async `TaskCallback` form. A zero/single-arg `async () => T` callback is structurally assignable to `MemoCallback` too (fewer parameters is always fine), and TypeScript's overload resolution picks the first structural match — so `T` unified to `Promise<...>` instead of the resolved value type. For example, `deriveCell(async () => new Map())` inferred `Signal>>` instead of `Signal>`. The deprecated `createComputed` already ordered `TaskCallback` before `MemoCallback` to avoid exactly this; `deriveCell`'s overloads are now reordered to match. Type-inference-only fix — no runtime behavior change. +- **Four per-item derivation overload pairs had the same sync-before-async ordering bug** (`src/nodes/list.ts`, `src/nodes/collection.ts`): `MutableList.deriveCollection()`, `DerivedList.deriveCollection()`, the deprecated free function `deriveCollection()`, and `deriveList()`'s per-item overloads (the current v2.0-facing API) all declared a single-arg sync callback `(sourceValue: T) => R` before the two-arg async callback `(sourceValue: T, abort: AbortSignal) => Promise`. Since `R` is unconstrained, a single-arg async callback that ignores `abort` — a common shape — structurally matched the sync overload first, unifying `R` to `Promise` instead of `X`. For example, `deriveList(source, async (item) => ({ value: item.id }))` inferred `DerivedList>` instead of `DerivedList<{ value: string }>`. Each pair is now reordered so the async overload comes first, matching the `deriveCell` fix above. `deriveList`'s whole-array overloads and `deriveStore` were not affected — their sync-form return type is a concrete `T[]`/`UnknownRecord` shape, which already blocks the bad unification. Type-inference-only fix — no runtime behavior change. + ## 1.5.1 ### Fixed diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index c69663e..1e9ef0c 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -148,8 +148,6 @@ Two figures, doing two different jobs. **The full-library figure is a diagnostic.** It exists to catch an accidental blowup — a dependency pulled in whole, a factory that defeats tree-shaking — not to be optimised against byte by byte. It is a working ceiling with deliberate slack, and it is re-baselined from measurement at each release rather than treated as a constant. -Refactoring may move it in either direction, and a refactor must not be redesigned to defend it. Deduplicating code is the clear case: gzip compresses a second near-identical copy almost for free, so extracting a shared helper reliably reduces minified size while *increasing* gzipped size. The two limits therefore move in opposite directions under exactly the changes that improve the code. Treating the gzipped number as a hard constant would select against consolidation — a premature-optimisation trap, and one this project walked into during CE-013. - Re-baselining is a release gate, not a routine edit. Lowering the ceiling toward measured usage at release time is what keeps the diagnostic meaningful; raising it mid-branch to unblock a commit is what makes it meaningless. The library must remain tree-shakable. An import of one construction path must not pull in the others. This constraint is why the narrow single-value factories are retained alongside `createSignal` and `deriveCell`, and it is what makes the core figure the one that matters. diff --git a/adr/0018-shape-indexed-signal-types.md b/adr/0018-shape-indexed-signal-types.md index e86836a..2aae165 100644 --- a/adr/0018-shape-indexed-signal-types.md +++ b/adr/0018-shape-indexed-signal-types.md @@ -2,18 +2,17 @@ ## Status -✅ Accepted — 2026-08-17 (implementation on branch `v2/shape-exploration`; revision of the same date renamed the narrow shape to `Cell` and restored the umbrella `Signal` — see `MIGRATION-2.0.md` "Second flip"). No release has shipped under this or any prior wording — branch `v2/shape-exploration` is `2.0.0-next.1`, unreleased. - -The only *released* narrow-`Signal` vocabulary is the 1.5.0 bridge factory `deriveSignal` and -its options type, deprecated in 1.5.1 in favor of `deriveCell`/`DeriveCellOptions`. -`createSignal`'s shape dispatch is unrelated to this flip and keeps its 1.x behavior until -2.0 removes it. +✅ Accepted — 2026-08-19 (for v2.0) Amends [ADR-0001](0001-reactive-task-stale-detection.md) (scope of `isPending`). +This ADR is binding for v2.0. It also regulates the 1.x bridge — vocabulary backported ahead of +2.0 to teach it only once — but does not itself govern day-to-day 1.x maintenance beyond that +bridge. + ## Context -The library ships nine signal types, indexed by two axes at once — **shape** (single value, keyed sequence, keyed record) and **origin** (mutable source, sync derivation, async derivation, external push). Nine names cover that matrix only partially, and one cell is restricted: +The library ships nine signal types in v1.x, indexed by two axes at once — **shape** (single value, keyed sequence, keyed record) and **origin** (mutable source, sync derivation, async derivation, external push). Nine names cover that matrix only partially, and one cell is restricted: `deriveCollection` accepts only a `CollectionSource`, so a `Task` or `Memo` cannot become a keyed sequence. The most common async pipeline — fetch an array, key it, render per item — has no derivation path. The observed consequence: authors reach for the discouraged pattern of writing an async result into a mutable `Store` by hand in an effect. Documentation cannot fix this; it is the only door. @@ -126,6 +125,15 @@ Each key of a derived composite gets its own `Cell` that reads the source and se The mechanisms in ADR-0010 (`FLAG_RELINK`), ADR-0014 (two-path access), ADR-0015 (structural lookup edges), and ADR-0017 (proxy write rejection) apply unchanged. +### 8. 1.x bridge: `Cell`/`MutableCell` types and guards + +The 1.x bridge backports this ADR's vocabulary ahead of 2.0 so it is taught only once. The factory half of the bridge — `createCell`, `deriveCell`, `DeriveCellOptions` — leaves a gap: `deriveCell` declares its return type as bare `Signal`, wider than necessary, since `deriveCell` can only ever produce a `State`, `Memo`, `Task`, or `Sensor` — never a `List`/`Store`/`Collection` — but nothing in 1.x expresses that narrower guarantee at the type level. The bridge extends to the type-level half to close it: + +- `type Cell = State | Memo | Task | Sensor`, exported alongside the other Cell-specific 1.x code. A genuine structural narrowing, not just a rename: each 1.x origin already carries a distinct `Symbol.toStringTag` literal (`'State'|'Memo'|'Task'|'Sensor'`), so the union excludes `List`/`Store`/`Collection` at the type level with no runtime tag change — 1.x does not yet have the single collapsed `'Cell'` tag decision 1 defines for 2.0; this backport achieves the same *exclusion* property through the union instead. A fresh structural interface `{ get(): T }` was considered and rejected for this role: it is identical to `Signal`'s existing definition, so it would not actually exclude `List`/`Store` at the type level. +- `type MutableCell = State` — an alias, matching decision 5's `createCell(value, options?) → MutableCell` (aliasing `createState`). +- `deriveCell`'s overloads narrow their return type from `Signal` to `Cell`; `createCell` narrows from `State` to `MutableCell`. Both are widening-safe: every `Cell`/`MutableCell` value already satisfies `Signal`/`MutableSignal` structurally, so no existing caller's code breaks. +- `isCell(value): value is Cell` and `isMutableCell(value): value is MutableCell` — the single-value-shape guards, checking `Symbol.toStringTag` membership in `{'State','Memo','Task','Sensor'}`. `isSignal`/`isMutableSignal` stay unchanged as the umbrella guards. + ## Alternatives Considered - **Fill the matrix, keep nine type names.** Closes the derivation gap but leaves `.set()` reachable on things that shouldn't have it. Rejected — the gap and the mutability leak reinforce each other. diff --git a/index.js b/index.js index 936acd7..1912589 100644 --- a/index.js +++ b/index.js @@ -583,6 +583,78 @@ function isPending(signal) { function abort(signal) { getAsyncSource(signal)?.abort(); } +// src/nodes/memo.ts +function createMemo(fn, options) { + validateCallback(TYPE_MEMO, fn, isSyncFunction); + if (options?.value !== undefined) + validateSignalValue(TYPE_MEMO, options.value, options?.guard); + const node = { + fn, + value: options?.value, + flags: FLAG_DIRTY, + sources: null, + sourcesTail: null, + sinks: null, + sinksTail: null, + equals: options?.equals ?? DEFAULT_EQUALITY, + error: undefined, + stop: undefined + }; + const watched = options?.watched; + const subscribe = makeSubscribe(node, watched ? () => watched(() => { + propagate(node); + if (batchDepth === 0) + flush(); + }) : undefined); + return { + [Symbol.toStringTag]: TYPE_MEMO, + get() { + subscribe(); + refresh(node); + if (node.error) + throw node.error; + validateReadValue(TYPE_MEMO, node.value); + return node.value; + } + }; +} +function isMemo(value) { + return isSignalOfType(value, TYPE_MEMO); +} + +// src/nodes/sensor.ts +function createSensor(watched, options) { + validateCallback(TYPE_SENSOR, watched, isSyncFunction); + if (options?.value !== undefined) + validateSignalValue(TYPE_SENSOR, options.value, options?.guard); + const node = { + value: options?.value, + sinks: null, + sinksTail: null, + equals: options?.equals ?? DEFAULT_EQUALITY, + guard: options?.guard, + stop: undefined + }; + return { + [Symbol.toStringTag]: TYPE_SENSOR, + get() { + if (activeSink) { + if (!node.sinks) + node.stop = watched((next) => { + validateSignalValue(TYPE_SENSOR, next, node.guard); + setState(node, next); + }); + link(node, activeSink); + } + validateReadValue(TYPE_SENSOR, node.value); + return node.value; + } + }; +} +function isSensor(value) { + return isSignalOfType(value, TYPE_SENSOR); +} + // src/nodes/state.ts function createState(value, options) { validateSignalValue(TYPE_STATE, value, options?.guard); @@ -616,6 +688,95 @@ function isState(value) { return isSignalOfType(value, TYPE_STATE); } +// src/nodes/task.ts +function createTask(fn, options) { + validateCallback(TYPE_TASK, fn, isAsyncFunction); + if (options?.value !== undefined) + validateSignalValue(TYPE_TASK, options.value, options?.guard); + const pendingNode = { + value: false, + sinks: null, + sinksTail: null, + equals: DEFAULT_EQUALITY + }; + const node = { + fn, + value: options?.value, + sources: null, + sourcesTail: null, + sinks: null, + sinksTail: null, + flags: FLAG_DIRTY, + equals: options?.equals ?? DEFAULT_EQUALITY, + controller: undefined, + error: undefined, + stop: undefined, + pendingNode + }; + const watched = options?.watched; + const subscribe = makeSubscribe(node, watched ? () => watched(() => { + propagate(node); + if (batchDepth === 0) + flush(); + }) : undefined); + const pendingSubscribe = makeSubscribe(pendingNode); + return { + [Symbol.toStringTag]: TYPE_TASK, + get() { + subscribe(); + refresh(node); + if (node.error) + throw node.error; + validateReadValue(TYPE_TASK, node.value); + return node.value; + }, + isPending() { + pendingSubscribe(); + return node.pendingNode.value; + }, + abort() { + node.controller?.abort(); + node.controller = undefined; + setState(node.pendingNode, false); + } + }; +} +function isTask(value) { + return isSignalOfType(value, TYPE_TASK); +} + +// src/nodes/cell.ts +function createComputed(callback, options) { + return isAsyncFunction(callback) ? createTask(callback, options) : createMemo(callback, options); +} +function deriveCell(input, options) { + if (isFunction(input)) { + const { initial, watched: watched2, ...rest2 } = options ?? {}; + const computedOptions = { + ...rest2, + value: initial, + watched: watched2 + }; + return isAsyncFunction(input) ? createTask(input, computedOptions) : createMemo(input, computedOptions); + } + const { watched, ...rest } = options; + validateCallback("deriveCell", watched, isSyncFunction); + return createSensor(watched, { ...rest, value: input }); +} +var deriveSignal = deriveCell; +function createCell(value, options) { + return createState(value, options); +} +function isComputed(value) { + return isMemo(value) || isTask(value); +} +var CELL_TYPES = new Set([TYPE_STATE, TYPE_MEMO, TYPE_TASK, TYPE_SENSOR]); +function isCell(value) { + return value != null && CELL_TYPES.has(value[Symbol.toStringTag]); +} +function isMutableCell(value) { + return isState(value); +} // src/nodes/list.ts function keysEqual(a, b) { if (a.length !== b.length) @@ -976,102 +1137,6 @@ function isList(value) { return isMutableList(value); } -// src/nodes/memo.ts -function createMemo(fn, options) { - validateCallback(TYPE_MEMO, fn, isSyncFunction); - if (options?.value !== undefined) - validateSignalValue(TYPE_MEMO, options.value, options?.guard); - const node = { - fn, - value: options?.value, - flags: FLAG_DIRTY, - sources: null, - sourcesTail: null, - sinks: null, - sinksTail: null, - equals: options?.equals ?? DEFAULT_EQUALITY, - error: undefined, - stop: undefined - }; - const watched = options?.watched; - const subscribe = makeSubscribe(node, watched ? () => watched(() => { - propagate(node); - if (batchDepth === 0) - flush(); - }) : undefined); - return { - [Symbol.toStringTag]: TYPE_MEMO, - get() { - subscribe(); - refresh(node); - if (node.error) - throw node.error; - validateReadValue(TYPE_MEMO, node.value); - return node.value; - } - }; -} -function isMemo(value) { - return isSignalOfType(value, TYPE_MEMO); -} - -// src/nodes/task.ts -function createTask(fn, options) { - validateCallback(TYPE_TASK, fn, isAsyncFunction); - if (options?.value !== undefined) - validateSignalValue(TYPE_TASK, options.value, options?.guard); - const pendingNode = { - value: false, - sinks: null, - sinksTail: null, - equals: DEFAULT_EQUALITY - }; - const node = { - fn, - value: options?.value, - sources: null, - sourcesTail: null, - sinks: null, - sinksTail: null, - flags: FLAG_DIRTY, - equals: options?.equals ?? DEFAULT_EQUALITY, - controller: undefined, - error: undefined, - stop: undefined, - pendingNode - }; - const watched = options?.watched; - const subscribe = makeSubscribe(node, watched ? () => watched(() => { - propagate(node); - if (batchDepth === 0) - flush(); - }) : undefined); - const pendingSubscribe = makeSubscribe(pendingNode); - return { - [Symbol.toStringTag]: TYPE_TASK, - get() { - subscribe(); - refresh(node); - if (node.error) - throw node.error; - validateReadValue(TYPE_TASK, node.value); - return node.value; - }, - isPending() { - pendingSubscribe(); - return node.pendingNode.value; - }, - abort() { - node.controller?.abort(); - node.controller = undefined; - setState(node.pendingNode, false); - } - }; -} -function isTask(value) { - return isSignalOfType(value, TYPE_TASK); -} - // src/nodes/collection.ts function keyedAdapter(source, options) { const [generateKey, contentBased] = getKeyGenerator(options?.keyConfig); @@ -1470,38 +1535,6 @@ function match(signalOrSignals, handlers) { }); } } -// src/nodes/sensor.ts -function createSensor(watched, options) { - validateCallback(TYPE_SENSOR, watched, isSyncFunction); - if (options?.value !== undefined) - validateSignalValue(TYPE_SENSOR, options.value, options?.guard); - const node = { - value: options?.value, - sinks: null, - sinksTail: null, - equals: options?.equals ?? DEFAULT_EQUALITY, - guard: options?.guard, - stop: undefined - }; - return { - [Symbol.toStringTag]: TYPE_SENSOR, - get() { - if (activeSink) { - if (!node.sinks) - node.stop = watched((next) => { - validateSignalValue(TYPE_SENSOR, next, node.guard); - setState(node, next); - }); - link(node, activeSink); - } - validateReadValue(TYPE_SENSOR, node.value); - return node.value; - } - }; -} -function isSensor(value) { - return isSignalOfType(value, TYPE_SENSOR); -} // src/nodes/store.ts var storeProxyHandler = { get(target, prop) { @@ -1864,27 +1897,6 @@ var SIGNAL_TYPES = new Set([ TYPE_COLLECTION, TYPE_STORE ]); -function createComputed(callback, options) { - return isAsyncFunction(callback) ? createTask(callback, options) : createMemo(callback, options); -} -function deriveCell(input, options) { - if (isFunction(input)) { - const { initial, watched: watched2, ...rest2 } = options ?? {}; - const computedOptions = { - ...rest2, - value: initial, - watched: watched2 - }; - return isAsyncFunction(input) ? createTask(input, computedOptions) : createMemo(input, computedOptions); - } - const { watched, ...rest } = options; - validateCallback("deriveCell", watched, isSyncFunction); - return createSensor(watched, { ...rest, value: input }); -} -var deriveSignal = deriveCell; -function createCell(value, options) { - return createState(value, options); -} function createSignal(value) { if (isSignal(value)) return value; @@ -1911,9 +1923,6 @@ function createMutableSignal(value) { return createStore(value); return createState(value); } -function isComputed(value) { - return isMemo(value) || isTask(value); -} function isSignal(value) { return value != null && SIGNAL_TYPES.has(value[Symbol.toStringTag]); } @@ -2008,6 +2017,7 @@ export { isMutableStore, isMutableSignal, isMutableList, + isMutableCell, isMemo, isList, isFunction, @@ -2015,6 +2025,7 @@ export { isDerivedList, isComputed, isCollection, + isCell, isAsyncFunction, deriveStore, deriveSignal, diff --git a/index.ts b/index.ts index d631aef..1aa8fad 100644 --- a/index.ts +++ b/index.ts @@ -39,6 +39,19 @@ export { unown, untrack, } from './src/graph' +export { + type Cell, + createCell, + createComputed, + type DeriveCellOptions, + type DeriveSignalOptions, + deriveCell, + deriveSignal, + isCell, + isComputed, + isMutableCell, + type MutableCell, +} from './src/nodes/cell' export { type Collection, type CollectionCallback, @@ -108,15 +121,8 @@ export { } from './src/nodes/store' export { createTask, isTask, type Task } from './src/nodes/task' export { - createCell, - createComputed, createMutableSignal, createSignal, - type DeriveCellOptions, - type DeriveSignalOptions, - deriveCell, - deriveSignal, - isComputed, isMutableSignal, isSignal, type MutableSignal, diff --git a/src/nodes/cell.ts b/src/nodes/cell.ts new file mode 100644 index 0000000..cf005d5 --- /dev/null +++ b/src/nodes/cell.ts @@ -0,0 +1,266 @@ +import { validateCallback } from '../errors' +import { + type Cleanup, + type ComputedOptions, + type MemoCallback, + type SignalOptions, + type TaskCallback, + TYPE_MEMO, + TYPE_SENSOR, + TYPE_STATE, + TYPE_TASK, +} from '../graph' +import { isAsyncFunction, isFunction, isSyncFunction } from '../util' +import { createMemo, isMemo, type Memo } from './memo' +import { createSensor, type Sensor } from './sensor' +import { createState, isState, type State } from './state' +import { createTask, isTask, type Task } from './task' + +/* === Types === */ + +/** + * The single-value signal shape — the type union of `State`, `Memo`, `Task`, and `Sensor`. + * A 1.x bridge for [ADR-0018](../../adr/0018-shape-indexed-signal-types.md)'s `Cell`: + * a genuine structural narrowing of `Signal`, since each origin already carries a distinct + * `Symbol.toStringTag` literal, so this union excludes `List`/`Store`/`Collection` at the type + * level with no runtime tag change. + * + * @since 1.5.2 + * @template T - The type of value held by the cell + */ +type Cell = State | Memo | Task | Sensor + +/** + * The mutable single-value signal shape — an alias of `State`, matching `createCell`'s + * `MutableCell` return type ([ADR-0018](../../adr/0018-shape-indexed-signal-types.md) + * decision 5: `createCell` aliases `createState`). + * + * @since 1.5.2 + * @template T - The type of value held by the cell + */ +type MutableCell = State + +/** + * Configuration options for `deriveCell`'s function-input forms (sync and async). + * Mirrors `ComputedOptions`, renamed to the `initial` vocabulary `deriveList`/`deriveStore` + * already use, so the whole `derive*` family reads consistently. + * + * Unlike `deriveList`/`deriveStore`, `initial` stays optional here: those two default to an + * empty array/record so a collection is never unset, but `Signal` has no such universal + * empty value for an unconstrained `T`. An early read before the first resolution throws + * `UnsetSignalValueError`, exactly as `createTask` already behaves — see + * [ADR-0018](../../adr/0018-shape-indexed-signal-types.md) §3, which scopes the + * required-`initial` rule to `List`/`Store` only. + * + * @since 1.5.1 + * @template T - The type of value the signal holds + */ +type DeriveCellOptions = SignalOptions & { + /** Initial value. Seeds a reducer pattern, or the value read before an async computation first resolves. */ + initial?: T + /** + * Optional callback invoked when the signal is first watched by an effect. + * Receives an `invalidate` function that marks the signal dirty and triggers re-evaluation. + * Must return a cleanup function that is called when the signal is no longer watched. + */ + watched?: (invalidate: () => void) => Cleanup +} + +/** + * The 1.5.0 name of `DeriveCellOptions`. + * + * @deprecated Use `DeriveCellOptions` instead — the ADR-0018 Revision (2026-08-17) names the + * single-value shape `Cell` and keeps `Signal` as the umbrella, so the `derive*` options type + * carries the `Cell` name. Same members, renamed. Removed in v2.0. See + * [MIGRATION-2.0.md](../../MIGRATION-2.0.md). + * + * @since 1.5.0 + * @template T - The type of value the signal holds + */ +type DeriveSignalOptions = DeriveCellOptions + +/* === Factory Functions === */ + +/** + * Create a derived signal from existing signals + * + * @deprecated Use `deriveCell(callback, options?)` instead — same dispatch (sync function → + * `Memo`, async function → `Task`), returned as `Cell` rather than the deprecated + * `Memo`/`Task` union. `createComputed` is removed in v2.0. See + * [MIGRATION-2.0.md](../../MIGRATION-2.0.md). + * + * @since 0.9.0 + * @param callback - Computation callback function + * @param options - Optional configuration + */ +function createComputed( + callback: TaskCallback, + options?: ComputedOptions, +): Task +function createComputed( + callback: MemoCallback, + options?: ComputedOptions, +): Memo +function createComputed( + callback: TaskCallback | MemoCallback, + options?: ComputedOptions, +): Memo | Task { + return isAsyncFunction(callback) + ? createTask(callback as TaskCallback, options) + : createMemo(callback as MemoCallback, options) +} + +/** + * Create a read-only signal from any origin — the bridge replacement for `createComputed`, + * under its terminal v2.0 name ([ADR-0018](../../adr/0018-shape-indexed-signal-types.md) + * Revision 2026-08-17: the single-value shape is `Cell`; `Signal` stays the umbrella). + * Dispatches on `input`: a sync function derives a `Memo`, an async function derives a + * `Task`, and a seed value with `options.watched` derives a `Sensor`. All three are + * returned as `Cell` — origin is not part of the return type. + * + * @since 1.5.1 + * @template T - The type of value the signal holds + * @param input - A computation function or a seed value + * @param options - Optional configuration; `watched` is required when `input` is a seed value + * @returns A read-only Cell + * + * @example + * ```ts + * const userId = createCell(1) + * const user = deriveCell(async (_prev, abort) => { + * const res = await fetch(`/api/users/${userId.get()}`, { signal: abort }) + * return res.json() + * }, { initial: fallbackUser }) + * ``` + */ +function deriveCell( + input: TaskCallback, + options?: DeriveCellOptions, +): Cell +function deriveCell( + input: MemoCallback, + options?: DeriveCellOptions, +): Cell +function deriveCell( + input: T, + options: SignalOptions & { watched: (set: (next: T) => void) => Cleanup }, +): Cell +function deriveCell( + input: MemoCallback | TaskCallback | T, + options?: + | DeriveCellOptions + | (SignalOptions & { watched: (set: (next: T) => void) => Cleanup }), +): Cell { + if (isFunction(input)) { + const { initial, watched, ...rest } = (options ?? + {}) as DeriveCellOptions + const computedOptions = { + ...rest, + value: initial, + watched, + } as ComputedOptions + return isAsyncFunction(input) + ? createTask(input as TaskCallback, computedOptions) + : createMemo(input as MemoCallback, computedOptions) + } + + const { watched, ...rest } = options as SignalOptions & { + watched: (set: (next: T) => void) => Cleanup + } + validateCallback('deriveCell', watched, isSyncFunction) + return createSensor(watched, { ...rest, value: input as T }) +} + +/** + * The 1.5.0 name of `deriveCell`. + * + * @deprecated Use `deriveCell(input, options?)` instead — `deriveSignal` shipped in 1.5.0 + * under the pre-Revision ADR-0018 vocabulary that named the single-value shape `Signal`. + * Same dispatch and behavior, renamed. `deriveSignal` is removed in v2.0. See + * [MIGRATION-2.0.md](../../MIGRATION-2.0.md). + * + * @since 1.5.0 + */ +const deriveSignal: typeof deriveCell = deriveCell + +/** + * Create a mutable single-value signal — the terminal v2.0 name for single-value mutable + * construction ([ADR-0018](../../adr/0018-shape-indexed-signal-types.md) Revision 2026-08-17: + * the single-value shape is `Cell`; `Signal` stays the umbrella). An alias of `createState`, + * so `guard` and `equals` apply exactly as there. The value is taken verbatim — no shape + * conversion: an array is held as an array value, not a `List`; a record as a record value, + * not a `Store`. Use `createList`/`createStore` for those shapes. + * + * @since 1.5.1 + * @template T - The type of value the signal holds + * @param value - The initial value + * @param options - Optional configuration for the signal + * @returns A MutableCell signal with get(), set(), and update() methods + */ +function createCell( + value: T, + options?: SignalOptions, +): MutableCell { + return createState(value, options) +} + +/* === Guards === */ + +/** + * Check if a value is a computed signal + * + * @deprecated Removed in v2.0 with no mechanical replacement — origin is no longer part of the + * consumption contract. Use `isSignal`/`isMutableSignal` or a plain property check instead. See + * [MIGRATION-2.0.md](../../MIGRATION-2.0.md) § Origin guards. + * + * @since 0.9.0 + * @param value - Value to check + * @returns True if value is a computed signal, false otherwise + */ +function isComputed(value: unknown): value is Memo | Task { + return isMemo(value) || isTask(value) +} + +const CELL_TYPES = new Set([TYPE_STATE, TYPE_MEMO, TYPE_TASK, TYPE_SENSOR]) + +/** + * Check whether a value is a Cell — a State, Memo, Task, or Sensor. + * + * @since 1.5.2 + * @param value - Value to check + * @returns True if value is a Cell, false otherwise + */ +function isCell(value: unknown): value is Cell { + return ( + value != null && + CELL_TYPES.has( + (value as Record)[Symbol.toStringTag] as string, + ) + ) +} + +/** + * Check whether a value is a MutableCell — equivalent to `isState`, exported under the + * forward-compatible name. + * + * @since 1.5.2 + * @param value - Value to check + * @returns True if value is a MutableCell, false otherwise + */ +function isMutableCell(value: unknown): value is MutableCell { + return isState(value) +} + +export { + type Cell, + createCell, + createComputed, + type DeriveCellOptions, + type DeriveSignalOptions, + deriveCell, + deriveSignal, + isCell, + isComputed, + isMutableCell, + type MutableCell, +} diff --git a/src/nodes/collection.ts b/src/nodes/collection.ts index 1e6e7e9..31a8b45 100644 --- a/src/nodes/collection.ts +++ b/src/nodes/collection.ts @@ -174,10 +174,10 @@ type DerivedList = Signal> = { * forms are removed in v2.0. See `MIGRATION-2.0.md`. */ deriveCollection( - callback: (sourceValue: T) => R, + callback: (sourceValue: T, abort: AbortSignal) => Promise, ): DerivedList deriveCollection( - callback: (sourceValue: T, abort: AbortSignal) => Promise, + callback: (sourceValue: T) => R, ): DerivedList readonly length: number } @@ -474,12 +474,12 @@ function collectionFacade>( */ function deriveCollection( source: ListSource, - callback: (sourceValue: U) => T, + callback: (sourceValue: U, abort: AbortSignal) => Promise, options?: DeriveListOptions, ): DerivedList function deriveCollection( source: ListSource, - callback: (sourceValue: U, abort: AbortSignal) => Promise, + callback: (sourceValue: U) => T, options?: DeriveListOptions, ): DerivedList function deriveCollection( @@ -837,12 +837,12 @@ function deriveList( ): DerivedList function deriveList( input: ListSource, - itemCallback: (sourceValue: U) => T, + itemCallback: (sourceValue: U, abort: AbortSignal) => Promise, options?: DeriveListOptions, ): DerivedList function deriveList( input: ListSource, - itemCallback: (sourceValue: U, abort: AbortSignal) => Promise, + itemCallback: (sourceValue: U) => T, options?: DeriveListOptions, ): DerivedList function deriveList( diff --git a/src/nodes/list.ts b/src/nodes/list.ts index fab9307..81b8b72 100644 --- a/src/nodes/list.ts +++ b/src/nodes/list.ts @@ -108,10 +108,10 @@ type MutableList< * forms are removed in v2.0. See `MIGRATION-2.0.md`. */ deriveCollection( - callback: (sourceValue: T) => R, + callback: (sourceValue: T, abort: AbortSignal) => Promise, ): DerivedList deriveCollection( - callback: (sourceValue: T, abort: AbortSignal) => Promise, + callback: (sourceValue: T) => R, ): DerivedList } diff --git a/src/signal.ts b/src/signal.ts index ab97094..f5a35a6 100644 --- a/src/signal.ts +++ b/src/signal.ts @@ -1,10 +1,7 @@ -import { InvalidSignalValueError, validateCallback } from './errors' +import { InvalidSignalValueError } from './errors' import { - type Cleanup, - type ComputedOptions, type MemoCallback, type Signal, - type SignalOptions, type TaskCallback, TYPE_COLLECTION, TYPE_LIST, @@ -21,18 +18,11 @@ import { type MutableList, type UnknownRecord, } from './nodes/list' -import { createMemo, isMemo, type Memo } from './nodes/memo' -import { createSensor } from './nodes/sensor' +import { createMemo, type Memo } from './nodes/memo' import { createState, isState, type State } from './nodes/state' import { createStore, type MutableStore } from './nodes/store' -import { createTask, isTask, type Task } from './nodes/task' -import { - isAsyncFunction, - isFunction, - isRecord, - isSignalOfType, - isSyncFunction, -} from './util' +import { createTask, type Task } from './nodes/task' +import { isAsyncFunction, isFunction, isRecord, isSignalOfType } from './util' /* === Types === */ @@ -48,45 +38,6 @@ type MutableSignal = { update(callback: (value: T) => T): void } -/** - * Configuration options for `deriveCell`'s function-input forms (sync and async). - * Mirrors `ComputedOptions`, renamed to the `initial` vocabulary `deriveList`/`deriveStore` - * already use, so the whole `derive*` family reads consistently. - * - * Unlike `deriveList`/`deriveStore`, `initial` stays optional here: those two default to an - * empty array/record so a collection is never unset, but `Signal` has no such universal - * empty value for an unconstrained `T`. An early read before the first resolution throws - * `UnsetSignalValueError`, exactly as `createTask` already behaves — see - * [ADR-0018](../adr/0018-shape-indexed-signal-types.md) §3, which scopes the - * required-`initial` rule to `List`/`Store` only. - * - * @since 1.5.1 - * @template T - The type of value the signal holds - */ -type DeriveCellOptions = SignalOptions & { - /** Initial value. Seeds a reducer pattern, or the value read before an async computation first resolves. */ - initial?: T - /** - * Optional callback invoked when the signal is first watched by an effect. - * Receives an `invalidate` function that marks the signal dirty and triggers re-evaluation. - * Must return a cleanup function that is called when the signal is no longer watched. - */ - watched?: (invalidate: () => void) => Cleanup -} - -/** - * The 1.5.0 name of `DeriveCellOptions`. - * - * @deprecated Use `DeriveCellOptions` instead — the ADR-0018 Revision (2026-08-17) names the - * single-value shape `Cell` and keeps `Signal` as the umbrella, so the `derive*` options type - * carries the `Cell` name. Same members, renamed. Removed in v2.0. See - * [MIGRATION-2.0.md](../MIGRATION-2.0.md). - * - * @since 1.5.0 - * @template T - The type of value the signal holds - */ -type DeriveSignalOptions = DeriveCellOptions - /* === Constants === */ const SIGNAL_TYPES = new Set([ @@ -102,129 +53,6 @@ const SIGNAL_TYPES = new Set([ /* === Factory Functions === */ -/** - * Create a derived signal from existing signals - * - * @deprecated Use `deriveCell(callback, options?)` instead — same dispatch (sync function → - * `Memo`, async function → `Task`), returned as `Signal` rather than the deprecated - * `Memo`/`Task` union. `createComputed` is removed in v2.0. See - * [MIGRATION-2.0.md](../MIGRATION-2.0.md). - * - * @since 0.9.0 - * @param callback - Computation callback function - * @param options - Optional configuration - */ -function createComputed( - callback: TaskCallback, - options?: ComputedOptions, -): Task -function createComputed( - callback: MemoCallback, - options?: ComputedOptions, -): Memo -function createComputed( - callback: TaskCallback | MemoCallback, - options?: ComputedOptions, -): Memo | Task { - return isAsyncFunction(callback) - ? createTask(callback as TaskCallback, options) - : createMemo(callback as MemoCallback, options) -} - -/** - * Create a read-only signal from any origin — the bridge replacement for `createComputed`, - * under its terminal v2.0 name ([ADR-0018](../adr/0018-shape-indexed-signal-types.md) - * Revision 2026-08-17: the single-value shape is `Cell`; `Signal` stays the umbrella). - * Dispatches on `input`: a sync function derives a `Memo`, an async function derives a - * `Task`, and a seed value with `options.watched` derives a `Sensor`. All three are - * returned as `Signal` — origin is not part of the return type. - * - * @since 1.5.1 - * @template T - The type of value the signal holds - * @param input - A computation function or a seed value - * @param options - Optional configuration; `watched` is required when `input` is a seed value - * @returns A read-only Signal - * - * @example - * ```ts - * const userId = createCell(1) - * const user = deriveCell(async (_prev, abort) => { - * const res = await fetch(`/api/users/${userId.get()}`, { signal: abort }) - * return res.json() - * }, { initial: fallbackUser }) - * ``` - */ -function deriveCell( - input: MemoCallback, - options?: DeriveCellOptions, -): Signal -function deriveCell( - input: TaskCallback, - options?: DeriveCellOptions, -): Signal -function deriveCell( - input: T, - options: SignalOptions & { watched: (set: (next: T) => void) => Cleanup }, -): Signal -function deriveCell( - input: MemoCallback | TaskCallback | T, - options?: - | DeriveCellOptions - | (SignalOptions & { watched: (set: (next: T) => void) => Cleanup }), -): Signal { - if (isFunction(input)) { - const { initial, watched, ...rest } = (options ?? - {}) as DeriveCellOptions - const computedOptions = { - ...rest, - value: initial, - watched, - } as ComputedOptions - return isAsyncFunction(input) - ? createTask(input as TaskCallback, computedOptions) - : createMemo(input as MemoCallback, computedOptions) - } - - const { watched, ...rest } = options as SignalOptions & { - watched: (set: (next: T) => void) => Cleanup - } - validateCallback('deriveCell', watched, isSyncFunction) - return createSensor(watched, { ...rest, value: input as T }) -} - -/** - * The 1.5.0 name of `deriveCell`. - * - * @deprecated Use `deriveCell(input, options?)` instead — `deriveSignal` shipped in 1.5.0 - * under the pre-Revision ADR-0018 vocabulary that named the single-value shape `Signal`. - * Same dispatch and behavior, renamed. `deriveSignal` is removed in v2.0. See - * [MIGRATION-2.0.md](../MIGRATION-2.0.md). - * - * @since 1.5.0 - */ -const deriveSignal: typeof deriveCell = deriveCell - -/** - * Create a mutable single-value signal — the terminal v2.0 name for single-value mutable - * construction ([ADR-0018](../adr/0018-shape-indexed-signal-types.md) Revision 2026-08-17: - * the single-value shape is `Cell`; `Signal` stays the umbrella). An alias of `createState`, - * so `guard` and `equals` apply exactly as there. The value is taken verbatim — no shape - * conversion: an array is held as an array value, not a `List`; a record as a record value, - * not a `Store`. Use `createList`/`createStore` for those shapes. - * - * @since 1.5.1 - * @template T - The type of value the signal holds - * @param value - The initial value - * @param options - Optional configuration for the signal - * @returns A State signal with get(), set(), and update() methods - */ -function createCell( - value: T, - options?: SignalOptions, -): State { - return createState(value, options) -} - /** * Convert a value to a Signal. * @@ -281,21 +109,6 @@ function createMutableSignal(value: unknown): unknown { /* === Guards === */ -/** - * Check if a value is a computed signal - * - * @deprecated Removed in v2.0 with no mechanical replacement — origin is no longer part of the - * consumption contract. Use `isSignal`/`isMutableSignal` or a plain property check instead. See - * [MIGRATION-2.0.md](../MIGRATION-2.0.md) § Origin guards. - * - * @since 0.9.0 - * @param value - Value to check - * @returns True if value is a computed signal, false otherwise - */ -function isComputed(value: unknown): value is Memo | Task { - return isMemo(value) || isTask(value) -} - /** * Check whether a value is a Signal * @@ -329,15 +142,8 @@ function isMutableSignal(value: unknown): value is MutableSignal { } export { - createCell, - createComputed, createMutableSignal, createSignal, - type DeriveCellOptions, - type DeriveSignalOptions, - deriveCell, - deriveSignal, - isComputed, isMutableSignal, isSignal, type MutableSignal, diff --git a/test/collection.test.ts b/test/collection.test.ts index 16138a2..42ad729 100644 --- a/test/collection.test.ts +++ b/test/collection.test.ts @@ -9,6 +9,7 @@ import { createScope, createState, createStore, + type DerivedList, isCollection, isList, } from '../index.ts' @@ -1061,3 +1062,13 @@ test('Type Inference for custom createItem', () => { Equal> | undefined> > }) + +test('DerivedList.deriveCollection() single-arg async callback infers the resolved item type, not Promise', () => { + const list = createList([1, 2, 3]) + const col = list.deriveCollection((v: number) => v) + const doubled = col.deriveCollection(async (v: number) => v * 2) + // If the overload order regresses, `doubled` unifies to `DerivedList>` + // and this assignment fails to compile. + const typedDoubled: DerivedList = doubled + expect(typedDoubled).toBeDefined() +}) diff --git a/test/derive-list.test.ts b/test/derive-list.test.ts index c9df1ec..00abd2e 100644 --- a/test/derive-list.test.ts +++ b/test/derive-list.test.ts @@ -7,6 +7,7 @@ import { createScope, createState, createTask, + type DerivedList, DuplicateKeyError, deriveList, isCollection, @@ -130,7 +131,7 @@ describe('per-item derivation from an unkeyed source', () => { dispose() }) - describe('cached item signals (CE-012 regression)', () => { + describe('cached item signals', () => { // The trigger in every case below is caching the item signal OUTSIDE the // effect, so the effect's only edge is item-Memo -> source and the // collection's own rebuild never runs in the propagation pass. @@ -437,5 +438,14 @@ describe('deriveList', () => { expect(doubled.get()).toEqual([2, 4]) dispose() }) + + test('single-arg async item callback infers the resolved item type, not Promise', () => { + const list = createList([1, 2, 3]) + const doubled = deriveList(list, async (v: number) => v * 2) + // If the overload order regresses, `doubled` unifies to `DerivedList>` + // and this assignment fails to compile. + const typedDoubled: DerivedList = doubled + expect(typedDoubled).toBeDefined() + }) }) }) diff --git a/test/list.test.ts b/test/list.test.ts index 1c4bb19..a5874ef 100644 --- a/test/list.test.ts +++ b/test/list.test.ts @@ -8,6 +8,7 @@ import { createState, createStore, createTask, + type DerivedList, DuplicateKeyError, isList, isMemo, @@ -1202,3 +1203,12 @@ test('Type Inference for custom createItem', () => { Equal> | undefined> > }) + +test('MutableList.deriveCollection() single-arg async callback infers the resolved item type, not Promise', () => { + const list = createList([1, 2, 3]) + const doubled = list.deriveCollection(async (v: number) => v * 2) + // If the overload order regresses, `doubled` unifies to `DerivedList>` + // and this assignment fails to compile. + const typedDoubled: DerivedList = doubled + expect(typedDoubled).toBeDefined() +}) diff --git a/test/signal.test.ts b/test/signal.test.ts index 27f32ee..41ed9c5 100644 --- a/test/signal.test.ts +++ b/test/signal.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'bun:test' import { abort, + type Cell, createCell, createComputed, createEffect, @@ -17,9 +18,11 @@ import { deriveSignal, InvalidCallbackError, InvalidSignalValueError, + isCell, isComputed, isList, isMemo, + isMutableCell, isMutableSignal, isSignal, isState, @@ -27,6 +30,7 @@ import { isTask, type List, type Memo, + type MutableCell, PromiseValueError, type Signal, type State, @@ -120,6 +124,17 @@ describe('deriveCell', () => { cleanup() }) + test('zero-arg async callback infers the resolved value type, not Promise', () => { + const cleanup = createScope(() => { + const result = deriveCell(async () => new Map()) + // If the overload order regresses, `result` unifies to + // `Signal>>` and this assignment fails to compile. + const typedResult: Signal> = result + expect(typedResult).toBeDefined() + }) + cleanup() + }) + test('throws InvalidCallbackError when the seed form omits watched', () => { expect(() => // biome-ignore lint/suspicious/noExplicitAny: testing invalid input @@ -340,6 +355,52 @@ describe('createCell', () => { expect(isStore(record)).toBe(false) expect(record.get()).toEqual({ a: 1 }) }) + + test('return type narrows to MutableCell, not the wider Signal', () => { + const result = createCell(42) + const typedResult: MutableCell = result + expect(typedResult).toBeDefined() + }) +}) + +describe('isCell', () => { + test('returns true for State, Memo, Task, and Sensor', () => { + const cleanup = createScope(() => { + expect(isCell(createState(1))).toBe(true) + expect(isCell(createMemo(() => 1))).toBe(true) + expect(isCell(createTask(async () => 1))).toBe(true) + expect(isCell(deriveCell(1, { watched: () => () => {} }))).toBe(true) + }) + cleanup() + }) + + test('returns false for List, Store, and non-signals', () => { + expect(isCell(createList([1, 2]))).toBe(false) + expect(isCell(createStore({ a: 1 }))).toBe(false) + expect(isCell(42)).toBe(false) + expect(isCell(null)).toBe(false) + }) + + test('narrows to Cell', () => { + const value: unknown = createState(1) + if (isCell(value)) { + const typed: Cell = value + expect(typed.get()).toBe(1) + } + }) +}) + +describe('isMutableCell', () => { + test('returns true for State, false for Memo/Task/List/Store', () => { + const cleanup = createScope(() => { + expect(isMutableCell(createState(1))).toBe(true) + expect(isMutableCell(createMemo(() => 1))).toBe(false) + expect(isMutableCell(createTask(async () => 1))).toBe(false) + expect(isMutableCell(createList([1, 2]))).toBe(false) + expect(isMutableCell(createStore({ a: 1 }))).toBe(false) + }) + cleanup() + }) }) describe('deprecated single-value names', () => { diff --git a/types/index.d.ts b/types/index.d.ts index 5385639..d050efd 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -1,10 +1,11 @@ /** * @name Cause & Effect - * @version 1.5.0 + * @version 1.5.1 * @author Esther Brunner */ export { CircularDependencyError, DuplicateKeyError, EffectConvergenceError, type Guard, InvalidCallbackError, InvalidSignalValueError, InvalidStoreMutationError, NullishSignalValueError, PromiseValueError, ReadonlySignalError, RequiredOwnerError, UnsetSignalValueError, } from './src/errors'; export { abort, batch, type Cleanup, type ComputedOptions, createScope, DEEP_EQUALITY, DEFAULT_EQUALITY, type EffectCallback, isEqual, isPending, type MaybeCleanup, type MemoCallback, type ScopeOptions, type Signal, type SignalOptions, SKIP_EQUALITY, type TaskCallback, unown, untrack, } from './src/graph'; +export { type Cell, createCell, createComputed, type DeriveCellOptions, type DeriveSignalOptions, deriveCell, deriveSignal, isCell, isComputed, isMutableCell, type MutableCell, } from './src/nodes/cell'; export { type Collection, type CollectionCallback, type CollectionChanges, type CollectionOptions, type CollectionSource, createCollection, type DeriveCollectionCallback, type DeriveCollectionOptions, type DerivedList, type DeriveListOptions, deriveList, isCollection, isDerivedList, type ListCallback, type ListChanges, type ListSource, type PerItemCallback, } from './src/nodes/collection'; export { createEffect, type MatchHandlers, type MaybePromise, match, type SingleMatchHandlers, } from './src/nodes/effect'; export { createList, isList, isMutableList, type KeyConfig, type List, type ListOptions, type MutableList, } from './src/nodes/list'; @@ -14,5 +15,5 @@ export { createSlot, isSlot, type Slot, type SlotDescriptor, } from './src/nodes export { createState, isState, type State, type UpdateCallback, } from './src/nodes/state'; export { createStore, type DerivedStore, type DeriveStoreOptions, deriveStore, isMutableStore, isStore, type MutableStore, type Store, type StoreCallback, type StoreOptions, } from './src/nodes/store'; export { createTask, isTask, type Task } from './src/nodes/task'; -export { createCell, createComputed, createMutableSignal, createSignal, type DeriveCellOptions, type DeriveSignalOptions, deriveCell, deriveSignal, isComputed, isMutableSignal, isSignal, type MutableSignal, } from './src/signal'; +export { createMutableSignal, createSignal, isMutableSignal, isSignal, type MutableSignal, } from './src/signal'; export { isAsyncFunction, isFunction, isObjectOfType, isRecord, isSignalOfType, valueString, } from './src/util'; diff --git a/types/src/nodes/cell.d.ts b/types/src/nodes/cell.d.ts new file mode 100644 index 0000000..fee3ce4 --- /dev/null +++ b/types/src/nodes/cell.d.ts @@ -0,0 +1,160 @@ +import { type Cleanup, type ComputedOptions, type MemoCallback, type SignalOptions, type TaskCallback } from '../graph'; +import { type Memo } from './memo'; +import { type Sensor } from './sensor'; +import { type State } from './state'; +import { type Task } from './task'; +/** + * The single-value signal shape — the type union of `State`, `Memo`, `Task`, and `Sensor`. + * A 1.x bridge for [ADR-0018](../../adr/0018-shape-indexed-signal-types.md)'s `Cell`: + * a genuine structural narrowing of `Signal`, since each origin already carries a distinct + * `Symbol.toStringTag` literal, so this union excludes `List`/`Store`/`Collection` at the type + * level with no runtime tag change. + * + * @since 1.5.2 + * @template T - The type of value held by the cell + */ +type Cell = State | Memo | Task | Sensor; +/** + * The mutable single-value signal shape — an alias of `State`, matching `createCell`'s + * `MutableCell` return type ([ADR-0018](../../adr/0018-shape-indexed-signal-types.md) + * decision 5: `createCell` aliases `createState`). + * + * @since 1.5.2 + * @template T - The type of value held by the cell + */ +type MutableCell = State; +/** + * Configuration options for `deriveCell`'s function-input forms (sync and async). + * Mirrors `ComputedOptions`, renamed to the `initial` vocabulary `deriveList`/`deriveStore` + * already use, so the whole `derive*` family reads consistently. + * + * Unlike `deriveList`/`deriveStore`, `initial` stays optional here: those two default to an + * empty array/record so a collection is never unset, but `Signal` has no such universal + * empty value for an unconstrained `T`. An early read before the first resolution throws + * `UnsetSignalValueError`, exactly as `createTask` already behaves — see + * [ADR-0018](../../adr/0018-shape-indexed-signal-types.md) §3, which scopes the + * required-`initial` rule to `List`/`Store` only. + * + * @since 1.5.1 + * @template T - The type of value the signal holds + */ +type DeriveCellOptions = SignalOptions & { + /** Initial value. Seeds a reducer pattern, or the value read before an async computation first resolves. */ + initial?: T; + /** + * Optional callback invoked when the signal is first watched by an effect. + * Receives an `invalidate` function that marks the signal dirty and triggers re-evaluation. + * Must return a cleanup function that is called when the signal is no longer watched. + */ + watched?: (invalidate: () => void) => Cleanup; +}; +/** + * The 1.5.0 name of `DeriveCellOptions`. + * + * @deprecated Use `DeriveCellOptions` instead — the ADR-0018 Revision (2026-08-17) names the + * single-value shape `Cell` and keeps `Signal` as the umbrella, so the `derive*` options type + * carries the `Cell` name. Same members, renamed. Removed in v2.0. See + * [MIGRATION-2.0.md](../../MIGRATION-2.0.md). + * + * @since 1.5.0 + * @template T - The type of value the signal holds + */ +type DeriveSignalOptions = DeriveCellOptions; +/** + * Create a derived signal from existing signals + * + * @deprecated Use `deriveCell(callback, options?)` instead — same dispatch (sync function → + * `Memo`, async function → `Task`), returned as `Cell` rather than the deprecated + * `Memo`/`Task` union. `createComputed` is removed in v2.0. See + * [MIGRATION-2.0.md](../../MIGRATION-2.0.md). + * + * @since 0.9.0 + * @param callback - Computation callback function + * @param options - Optional configuration + */ +declare function createComputed(callback: TaskCallback, options?: ComputedOptions): Task; +declare function createComputed(callback: MemoCallback, options?: ComputedOptions): Memo; +/** + * Create a read-only signal from any origin — the bridge replacement for `createComputed`, + * under its terminal v2.0 name ([ADR-0018](../../adr/0018-shape-indexed-signal-types.md) + * Revision 2026-08-17: the single-value shape is `Cell`; `Signal` stays the umbrella). + * Dispatches on `input`: a sync function derives a `Memo`, an async function derives a + * `Task`, and a seed value with `options.watched` derives a `Sensor`. All three are + * returned as `Cell` — origin is not part of the return type. + * + * @since 1.5.1 + * @template T - The type of value the signal holds + * @param input - A computation function or a seed value + * @param options - Optional configuration; `watched` is required when `input` is a seed value + * @returns A read-only Cell + * + * @example + * ```ts + * const userId = createCell(1) + * const user = deriveCell(async (_prev, abort) => { + * const res = await fetch(`/api/users/${userId.get()}`, { signal: abort }) + * return res.json() + * }, { initial: fallbackUser }) + * ``` + */ +declare function deriveCell(input: TaskCallback, options?: DeriveCellOptions): Cell; +declare function deriveCell(input: MemoCallback, options?: DeriveCellOptions): Cell; +declare function deriveCell(input: T, options: SignalOptions & { + watched: (set: (next: T) => void) => Cleanup; +}): Cell; +/** + * The 1.5.0 name of `deriveCell`. + * + * @deprecated Use `deriveCell(input, options?)` instead — `deriveSignal` shipped in 1.5.0 + * under the pre-Revision ADR-0018 vocabulary that named the single-value shape `Signal`. + * Same dispatch and behavior, renamed. `deriveSignal` is removed in v2.0. See + * [MIGRATION-2.0.md](../../MIGRATION-2.0.md). + * + * @since 1.5.0 + */ +declare const deriveSignal: typeof deriveCell; +/** + * Create a mutable single-value signal — the terminal v2.0 name for single-value mutable + * construction ([ADR-0018](../../adr/0018-shape-indexed-signal-types.md) Revision 2026-08-17: + * the single-value shape is `Cell`; `Signal` stays the umbrella). An alias of `createState`, + * so `guard` and `equals` apply exactly as there. The value is taken verbatim — no shape + * conversion: an array is held as an array value, not a `List`; a record as a record value, + * not a `Store`. Use `createList`/`createStore` for those shapes. + * + * @since 1.5.1 + * @template T - The type of value the signal holds + * @param value - The initial value + * @param options - Optional configuration for the signal + * @returns A MutableCell signal with get(), set(), and update() methods + */ +declare function createCell(value: T, options?: SignalOptions): MutableCell; +/** + * Check if a value is a computed signal + * + * @deprecated Removed in v2.0 with no mechanical replacement — origin is no longer part of the + * consumption contract. Use `isSignal`/`isMutableSignal` or a plain property check instead. See + * [MIGRATION-2.0.md](../../MIGRATION-2.0.md) § Origin guards. + * + * @since 0.9.0 + * @param value - Value to check + * @returns True if value is a computed signal, false otherwise + */ +declare function isComputed(value: unknown): value is Memo | Task; +/** + * Check whether a value is a Cell — a State, Memo, Task, or Sensor. + * + * @since 1.5.2 + * @param value - Value to check + * @returns True if value is a Cell, false otherwise + */ +declare function isCell(value: unknown): value is Cell; +/** + * Check whether a value is a MutableCell — equivalent to `isState`, exported under the + * forward-compatible name. + * + * @since 1.5.2 + * @param value - Value to check + * @returns True if value is a MutableCell, false otherwise + */ +declare function isMutableCell(value: unknown): value is MutableCell; +export { type Cell, createCell, createComputed, type DeriveCellOptions, type DeriveSignalOptions, deriveCell, deriveSignal, isCell, isComputed, isMutableCell, type MutableCell, }; diff --git a/types/src/nodes/collection.d.ts b/types/src/nodes/collection.d.ts index 75e6716..a68ec52 100644 --- a/types/src/nodes/collection.d.ts +++ b/types/src/nodes/collection.d.ts @@ -109,8 +109,8 @@ type DerivedList = Signal> = { * `users.deriveCollection(f)` becomes `deriveList(users, f)`. Both `.deriveCollection()` * forms are removed in v2.0. See `MIGRATION-2.0.md`. */ - deriveCollection(callback: (sourceValue: T) => R): DerivedList; deriveCollection(callback: (sourceValue: T, abort: AbortSignal) => Promise): DerivedList; + deriveCollection(callback: (sourceValue: T) => R): DerivedList; readonly length: number; }; /** @@ -205,8 +205,8 @@ type CollectionCallback = ListCallback; * @param options - Key generation and item equality. Applies only to an unkeyed source. * @returns A Collection signal */ -declare function deriveCollection(source: ListSource, callback: (sourceValue: U) => T, options?: DeriveListOptions): DerivedList; declare function deriveCollection(source: ListSource, callback: (sourceValue: U, abort: AbortSignal) => Promise, options?: DeriveListOptions): DerivedList; +declare function deriveCollection(source: ListSource, callback: (sourceValue: U) => T, options?: DeriveListOptions): DerivedList; /** * Creates an externally-driven Collection with a watched lifecycle. * @@ -266,8 +266,8 @@ declare function deriveList(input: (prev: T[], abort: AbortSignal) declare function deriveList(input: T[], options: DeriveListOptions & { watched: ListCallback; }): DerivedList; -declare function deriveList(input: ListSource, itemCallback: (sourceValue: U) => T, options?: DeriveListOptions): DerivedList; declare function deriveList(input: ListSource, itemCallback: (sourceValue: U, abort: AbortSignal) => Promise, options?: DeriveListOptions): DerivedList; +declare function deriveList(input: ListSource, itemCallback: (sourceValue: U) => T, options?: DeriveListOptions): DerivedList; /** * Checks if a value is a read-only derived List signal. * diff --git a/types/src/nodes/list.d.ts b/types/src/nodes/list.d.ts index ddbb11d..d21fbdb 100644 --- a/types/src/nodes/list.d.ts +++ b/types/src/nodes/list.d.ts @@ -68,8 +68,8 @@ type MutableList = MutableSignal> = * `users.deriveCollection(f)` becomes `deriveList(users, f)`. Both `.deriveCollection()` * forms are removed in v2.0. See `MIGRATION-2.0.md`. */ - deriveCollection(callback: (sourceValue: T) => R): DerivedList; deriveCollection(callback: (sourceValue: T, abort: AbortSignal) => Promise): DerivedList; + deriveCollection(callback: (sourceValue: T) => R): DerivedList; }; /** * The mutable keyed-sequence type, under its v1 name. diff --git a/types/src/signal.d.ts b/types/src/signal.d.ts index 5aaf6cd..39608fc 100644 --- a/types/src/signal.d.ts +++ b/types/src/signal.d.ts @@ -1,4 +1,4 @@ -import { type Cleanup, type ComputedOptions, type MemoCallback, type Signal, type SignalOptions, type TaskCallback } from './graph'; +import { type MemoCallback, type Signal, type TaskCallback } from './graph'; import { type MutableList, type UnknownRecord } from './nodes/list'; import { type Memo } from './nodes/memo'; import { type State } from './nodes/state'; @@ -15,111 +15,6 @@ type MutableSignal = { set(value: T): void; update(callback: (value: T) => T): void; }; -/** - * Configuration options for `deriveCell`'s function-input forms (sync and async). - * Mirrors `ComputedOptions`, renamed to the `initial` vocabulary `deriveList`/`deriveStore` - * already use, so the whole `derive*` family reads consistently. - * - * Unlike `deriveList`/`deriveStore`, `initial` stays optional here: those two default to an - * empty array/record so a collection is never unset, but `Signal` has no such universal - * empty value for an unconstrained `T`. An early read before the first resolution throws - * `UnsetSignalValueError`, exactly as `createTask` already behaves — see - * [ADR-0018](../adr/0018-shape-indexed-signal-types.md) §3, which scopes the - * required-`initial` rule to `List`/`Store` only. - * - * @since 1.5.1 - * @template T - The type of value the signal holds - */ -type DeriveCellOptions = SignalOptions & { - /** Initial value. Seeds a reducer pattern, or the value read before an async computation first resolves. */ - initial?: T; - /** - * Optional callback invoked when the signal is first watched by an effect. - * Receives an `invalidate` function that marks the signal dirty and triggers re-evaluation. - * Must return a cleanup function that is called when the signal is no longer watched. - */ - watched?: (invalidate: () => void) => Cleanup; -}; -/** - * The 1.5.0 name of `DeriveCellOptions`. - * - * @deprecated Use `DeriveCellOptions` instead — the ADR-0018 Revision (2026-08-17) names the - * single-value shape `Cell` and keeps `Signal` as the umbrella, so the `derive*` options type - * carries the `Cell` name. Same members, renamed. Removed in v2.0. See - * [MIGRATION-2.0.md](../MIGRATION-2.0.md). - * - * @since 1.5.0 - * @template T - The type of value the signal holds - */ -type DeriveSignalOptions = DeriveCellOptions; -/** - * Create a derived signal from existing signals - * - * @deprecated Use `deriveCell(callback, options?)` instead — same dispatch (sync function → - * `Memo`, async function → `Task`), returned as `Signal` rather than the deprecated - * `Memo`/`Task` union. `createComputed` is removed in v2.0. See - * [MIGRATION-2.0.md](../MIGRATION-2.0.md). - * - * @since 0.9.0 - * @param callback - Computation callback function - * @param options - Optional configuration - */ -declare function createComputed(callback: TaskCallback, options?: ComputedOptions): Task; -declare function createComputed(callback: MemoCallback, options?: ComputedOptions): Memo; -/** - * Create a read-only signal from any origin — the bridge replacement for `createComputed`, - * under its terminal v2.0 name ([ADR-0018](../adr/0018-shape-indexed-signal-types.md) - * Revision 2026-08-17: the single-value shape is `Cell`; `Signal` stays the umbrella). - * Dispatches on `input`: a sync function derives a `Memo`, an async function derives a - * `Task`, and a seed value with `options.watched` derives a `Sensor`. All three are - * returned as `Signal` — origin is not part of the return type. - * - * @since 1.5.1 - * @template T - The type of value the signal holds - * @param input - A computation function or a seed value - * @param options - Optional configuration; `watched` is required when `input` is a seed value - * @returns A read-only Signal - * - * @example - * ```ts - * const userId = createCell(1) - * const user = deriveCell(async (_prev, abort) => { - * const res = await fetch(`/api/users/${userId.get()}`, { signal: abort }) - * return res.json() - * }, { initial: fallbackUser }) - * ``` - */ -declare function deriveCell(input: MemoCallback, options?: DeriveCellOptions): Signal; -declare function deriveCell(input: TaskCallback, options?: DeriveCellOptions): Signal; -declare function deriveCell(input: T, options: SignalOptions & { - watched: (set: (next: T) => void) => Cleanup; -}): Signal; -/** - * The 1.5.0 name of `deriveCell`. - * - * @deprecated Use `deriveCell(input, options?)` instead — `deriveSignal` shipped in 1.5.0 - * under the pre-Revision ADR-0018 vocabulary that named the single-value shape `Signal`. - * Same dispatch and behavior, renamed. `deriveSignal` is removed in v2.0. See - * [MIGRATION-2.0.md](../MIGRATION-2.0.md). - * - * @since 1.5.0 - */ -declare const deriveSignal: typeof deriveCell; -/** - * Create a mutable single-value signal — the terminal v2.0 name for single-value mutable - * construction ([ADR-0018](../adr/0018-shape-indexed-signal-types.md) Revision 2026-08-17: - * the single-value shape is `Cell`; `Signal` stays the umbrella). An alias of `createState`, - * so `guard` and `equals` apply exactly as there. The value is taken verbatim — no shape - * conversion: an array is held as an array value, not a `List`; a record as a record value, - * not a `Store`. Use `createList`/`createStore` for those shapes. - * - * @since 1.5.1 - * @template T - The type of value the signal holds - * @param value - The initial value - * @param options - Optional configuration for the signal - * @returns A State signal with get(), set(), and update() methods - */ -declare function createCell(value: T, options?: SignalOptions): State; /** * Convert a value to a Signal. * @@ -150,18 +45,6 @@ declare function createMutableSignal(value: MutableSignal): Mut declare function createMutableSignal(value: readonly T[]): MutableList; declare function createMutableSignal(value: T): MutableStore; declare function createMutableSignal(value: T): State; -/** - * Check if a value is a computed signal - * - * @deprecated Removed in v2.0 with no mechanical replacement — origin is no longer part of the - * consumption contract. Use `isSignal`/`isMutableSignal` or a plain property check instead. See - * [MIGRATION-2.0.md](../MIGRATION-2.0.md) § Origin guards. - * - * @since 0.9.0 - * @param value - Value to check - * @returns True if value is a computed signal, false otherwise - */ -declare function isComputed(value: unknown): value is Memo | Task; /** * Check whether a value is a Signal * @@ -178,4 +61,4 @@ declare function isSignal(value: unknown): value is Signal; * @returns True if value is a State, Store, or List, false otherwise */ declare function isMutableSignal(value: unknown): value is MutableSignal; -export { createCell, createComputed, createMutableSignal, createSignal, type DeriveCellOptions, type DeriveSignalOptions, deriveCell, deriveSignal, isComputed, isMutableSignal, isSignal, type MutableSignal, }; +export { createMutableSignal, createSignal, isMutableSignal, isSignal, type MutableSignal, };