@wcstack/state 1.29.0 → 1.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.ja.md CHANGED
@@ -872,6 +872,36 @@ export default {
872
872
 
873
873
  4. **直接インデックスアクセス** — 数値インデックスで特定の要素にアクセスすることもできます:`this["users.0.name"]` はループコンテキストなしで `users[0].name` に解決されます。
874
874
 
875
+ ### getter は state に対して純粋であること
876
+
877
+ getter のキャッシュを無効化するのは**依存グラフだけ**で、依存グラフに載るのは getter が **`this` を通して読んだもの**だけです。それ以外の入力は無効化から見えないため、**最初に計算した値がそのまま残り続けます**:
878
+
879
+ ```javascript
880
+ // ❌ 二度と再計算されない — 依存グラフ上の何も変化しないため
881
+ get stamp() { return `${this.label} @ ${Date.now()}`; } // Date.now() は追跡外
882
+ get theme() { return document.body.dataset.theme; } // DOM は追跡外
883
+ get total() { return this.price * exchangeRate; } // モジュール変数は追跡外
884
+ ```
885
+
886
+ 規則は「**`this` を通してのみ読む。getter から state を書かない・DOM を触らない**」です。追跡外の入力をどうしても使いたい場合は、その入力を state に持たせてパスに代入する(通常の契約に戻す)か、以下の逃げ道を使ってください:
887
+
888
+ | API | 用途 |
889
+ |---|---|
890
+ | `this.$trackDependency(path)` | 依存を明示的に追加し、そのパスの変更でこの getter を dirty にする |
891
+ | `this.$postUpdate(path)` | 追跡外の入力が変わったことを getter の外から通知する |
892
+ | `this.$untrackDependency(fn)` | 依存として登録せずにパスを読む(上の対称) |
893
+
894
+ ```javascript
895
+ // ✅ 時計を state 側で刻み、getter は純粋なまま
896
+ export default {
897
+ now: Date.now(),
898
+ get stamp() { return `${this.label} @ ${this.now}`; },
899
+ $connectedCallback() { setInterval(() => { this.now = Date.now(); }, 1000); },
900
+ };
901
+ ```
902
+
903
+ getter の例外は握り潰されません。評価された場所(バインディングの適用・`$watch` の評価・自分での読み取り)でそのまま表面化します。
904
+
875
905
  ### ループインデックス変数(`$1`, `$2`, ...)
876
906
 
877
907
  getter やイベントハンドラ内で、`this.$1`、`this.$2` などで現在のループイテレーションのインデックスを取得できます(0始まりの値、1始まりの命名):
@@ -1627,6 +1657,48 @@ $streams: {
1627
1657
 
1628
1658
  完全な契約 —— ライフサイクルと所有権・restart セマンティクス・flush 粒度・スコープ外リスト —— は [docs/streams.ja.md](docs/streams.ja.md) を参照してください。
1629
1659
 
1660
+ ## 評価のきっかけ(demand root)
1661
+
1662
+ パス getter は **lazy** です。誰も読まなければ一度も評価されません。したがって「この getter は走るか」は getter 自身を読んでも決まりません —— **需要(demand)がどこから来るか**で決まります。
1663
+
1664
+ 需要の根は **3 つだけ**です:
1665
+
1666
+ | 根 | 場所 | 描画に依存するか |
1667
+ |---|---|---|
1668
+ | **live DOM バインディング** | `data-wcs` / mustache / コメントバインディング | **する**(その要素が消えると需要も消える) |
1669
+ | **`$watch` の宣言** | state 側 | しない(headless) |
1670
+ | **`$streams` の `args`** | state 側 | しない(起動・restart のたびに評価される) |
1671
+
1672
+ **`$updatedCallback` は根ではありません。** それは「バインディングが適用された結果」の報告であり、需要を作りません。
1673
+
1674
+ ### 描画がプログラムの意味論を変えうる
1675
+
1676
+ この 3 つのうち 1 つ目が DOM にあることの帰結として、**表示専用のつもりの要素が購読の実体になり得ます**。実際に踏んだ例が [`examples/state-intersect-scroll`](../../examples/state-intersect-scroll) にあります:
1677
+
1678
+ ```html
1679
+ <!-- 表示のつもりだった要素。これが唯一の需要の根だった -->
1680
+ <b data-wcs="textContent: $streamStatus.pageResult"></b>
1681
+ ```
1682
+
1683
+ ```javascript
1684
+ // $updatedCallback は binding 駆動 —— 上の <b> を消すと paths に現れなくなり、
1685
+ // フィードの commit が黙って止まる
1686
+ $updatedCallback(paths) {
1687
+ if (!paths.includes("$streamStatus.pageResult")) return;
1688
+ this.items = this.items.concat(this.pageResult.items);
1689
+ }
1690
+ ```
1691
+
1692
+ **規則:** 描画に依存させたくないロジックは、`$watch`(または `$streams` の `args`)に根を置いてください。`$updatedCallback` は「描かれたものに追随する」用途に限ります。
1693
+
1694
+ 上の例は `$watch` に置き換え済みで、`<b>` は表示専用に戻っています。この形(`$updatedCallback` が、どのバインディングにも現れないパスを判定に使っている)は **`wcs/updated-callback-unbound`** として静的に検出されます。
1695
+
1696
+ ### 残る制約
1697
+
1698
+ 需要の根が 3 か所に分かれること自体は変わりません。**ある getter が評価されるかを知るには、その 3 か所(ページの全バインディング・全 `$watch`・全 `$streams.args`)を見る必要があり、getter の定義だけを読んでも分かりません。** lint と DevTools の配線カバレッジはこの照合を機械にやらせるためのものです。
1699
+
1700
+ なお `$watch` に宣言したスカラー getter は **eager** になります(接続時に 1 回、以後は依存に触れたバッチごとに評価)。ワイルドカード行の getter は eager 化しません(初回評価がリスト全体を舐めるため)。
1701
+
1630
1702
  ## Watch(`$watch`)
1631
1703
 
1632
1704
  `$updatedCallback` は **binding 駆動** です。その更新で live DOM binding が実際に適用された path だけを報告するため、**描画していない値の変化は見えません**。**`$watch`** はその headless 版で、ページ上でそのパスがバインドされているかどうかに関わらず、state の変化で発火します(**ワイルドカードの行パスだけは例外**で、headless に成立させるには `$listKeys` が要ります。後述)。
@@ -1939,6 +2011,76 @@ export default {
1939
2011
  - `$updatedCallback(paths, indexesListByPath)` は、その drain で live binding が適用された path の一覧を受け取ります。binding のない state 書き込みでは呼ばれず、`paths` にも現れません。ワイルドカードをもつパスが更新された場合は、`indexesListByPath` から対象のインデックス情報も取得可能です。`async` を使用できますが、戻り値は await されません。
1940
2012
  - Web Component を使用している場合は、コンポーネント側に `async $stateReadyCallback(stateProp)` を定義おくことで、`bind-component` でバインドした状態が利用可能になった瞬間にフックとして呼び出されます。
1941
2013
 
2014
+ ## 診断と失敗の扱い
2015
+
2016
+ ### 存在しないパスへの配線は報告されます
2017
+
2018
+ 配線したパスが state 上で解決しないことが**確実**なとき、バインド確立時(`$watch` は宣言時)に 1 回だけ警告します。診断 code はコンソール・`@wcstack/lint`・VS Code 拡張で共通です:
2019
+
2020
+ ```
2021
+ [@wcstack/state] [wcs/binding-path-missing] Bound path "user.nmae" does not resolve on state "default":
2022
+ "nmae" is not declared. Did you mean "name"? Updates to this path will be silently
2023
+ dropped. Validate statically: npx @wcstack/lint <file>.
2024
+ ```
2025
+
2026
+ | 状況 | 挙動 |
2027
+ |---|---|
2028
+ | ネストしたパスの打ち間違い(`user.nmae`) | `console.warn`(`wcs/binding-path-missing`)。更新は届かないままなので、直すのは書き手 |
2029
+ | トップレベルのパスの打ち間違い(`cout`) | 読み取り時に throw。文面は上と同じ語彙(did-you-mean 付き) |
2030
+ | `$watch` のキーの打ち間違い | `console.warn`(`wcs/watch-path-missing`)。単一セグメントでも報告する |
2031
+
2032
+ 判定は**過小近似**です。静的に決められない形では黙ります —— 誤検知でページを騒がせないことを優先しているためで、以下はすべて警告しません:
2033
+
2034
+ - 親が `null` / `undefined`(初期値 `null` に後から代入する形)
2035
+ - 初期値が空配列のリストの行フィールド(行の形が分からない)
2036
+ - 途中の getter の戻り値のサブプロパティ
2037
+ - mapped な `bind-component` の子スコープ(パスの正本は親側)
2038
+ - `$` 始まりの予約名前空間(`$command.*` など)
2039
+
2040
+ 裏を返すと、**警告が出ない = 正しい保証にはなりません**。網羅した検査は `npx @wcstack/lint <file>` 側で行ってください。
2041
+
2042
+ ### 添字の本数・階数・循環も検査されます
2043
+
2044
+ パス文字列から機械的に決まる整合は、実行時にも lint にも同じ診断 code で現れます。
2045
+
2046
+ | 診断 | 何を見るか | 直し方 |
2047
+ |---|---|---|
2048
+ | `wcs/index-arity` | `$resolve(path, indexes)` は `*` の本数と**厳密一致**、`$getAll(path, indexes)` は**上限**(不足は「残りの階層を全展開」という正当な接頭辞) | 本数を合わせる |
2049
+ | `wcs/wildcard-rank` | パスの `*` の本数(と `$N` の N)が、囲む `for` の段数を超えていないか | `for` を足すか、`$resolve(path, indexes)` で行を明示する |
2050
+ | `wcs/getter-cycle` | パス getter どうしが循環参照していないか | 循環を断つ |
2051
+
2052
+ `$resolve` / `$getAll` の**添字の超過は以前は黙って捨てられ**、取り違えたまま「もっともらしい値」が返っていました。現在はどちらもエラーです:
2053
+
2054
+ ```javascript
2055
+ // ❌ "*" は 1 本しか無いのに 2 本渡している → 以前は items[0] の値が返っていた
2056
+ this.$resolve("items.*.price", [row, col]);
2057
+
2058
+ // ✅ 2 次元なら 2 本
2059
+ this.$resolve("matrix.*.*", [row, col]);
2060
+ // ✅ $getAll の不足は「残りを全部」の意味なので正当
2061
+ this.$getAll("matrix.*.*", [row]);
2062
+ ```
2063
+
2064
+ ### バインディング 1 本の失敗は 1 本に閉じ込められます
2065
+
2066
+ バインディングの適用が throw しても、そのバッチの残り・`$updatedCallback`・`$watch`・`$streams` の restart はすべて続行します。失敗は握り潰されず、`console.error` と DevTools(`state:binding-apply-error`)に出ます。
2067
+
2068
+ ```
2069
+ [@wcstack/state] binding "text: items.*.label" failed to apply; the rest of this batch continues.
2070
+ ```
2071
+
2072
+ 隔離しない場合、1 本の throw が「値は新しいのに DOM は途中まで」という半端な状態を作り、しかも `$watch` と stream の restart が丸ごと消えていました(README のこの下にある発火順の契約が黙って破れる)。
2073
+
2074
+ ### 値と DOM は巻き戻しません
2075
+
2076
+ 異常系はすべて「報告して続行」で、適用済みの値を戻すことはありません。これは以下で共通の姿勢です:
2077
+
2078
+ | 機構 | 上限 | 超過時 |
2079
+ |---|---|---|
2080
+ | 因果伝播の hop | 32 | その transaction の未処理レコードのみ quarantine |
2081
+ | `$watch` の書き込み連鎖 | 32 | そのバッチの watch 発火をスキップ |
2082
+ | バインディングの適用失敗 | — | その 1 本のみスキップ |
2083
+
1942
2084
  ## 設定
1943
2085
 
1944
2086
  `bootstrapState()` に部分的な設定オブジェクトを渡します:
package/README.md CHANGED
@@ -872,6 +872,36 @@ Two-way binding works with path setters — editing the input calls the setter,
872
872
 
873
873
  4. **Direct index access** — You can also access specific elements by numeric index: `this["users.0.name"]` resolves as `users[0].name` without needing loop context.
874
874
 
875
+ ### Getters must be pure with respect to state
876
+
877
+ A getter's cache is invalidated **only** through the dependency graph, and the graph only records what the getter read **through `this`**. Anything else a getter reads is invisible to invalidation, so the first value computed is the value you keep:
878
+
879
+ ```javascript
880
+ // ❌ Never recomputes — nothing in the dependency graph ever changes
881
+ get stamp() { return `${this.label} @ ${Date.now()}`; } // Date.now() is untracked
882
+ get theme() { return document.body.dataset.theme; } // the DOM is untracked
883
+ get total() { return this.price * exchangeRate; } // a module variable is untracked
884
+ ```
885
+
886
+ The rule: **read only through `this`, and don't write state or touch the DOM from a getter.** For the cases where an untracked input genuinely has to participate, put the input into state and assign to it (the normal path-assignment contract), or use the escape hatches:
887
+
888
+ | API | Use it for |
889
+ |---|---|
890
+ | `this.$trackDependency(path)` | Register an extra dependency so this getter is dirtied when that path changes |
891
+ | `this.$postUpdate(path)` | Announce that an untracked input changed, from outside the getter |
892
+ | `this.$untrackDependency(fn)` | Read a path *without* registering it as a dependency (the inverse) |
893
+
894
+ ```javascript
895
+ // ✅ The clock ticks in state; the getter stays pure
896
+ export default {
897
+ now: Date.now(),
898
+ get stamp() { return `${this.label} @ ${this.now}`; },
899
+ $connectedCallback() { setInterval(() => { this.now = Date.now(); }, 1000); },
900
+ };
901
+ ```
902
+
903
+ Getters that throw are not swallowed: the exception surfaces where the getter was evaluated (a binding apply, a `$watch` evaluation, or your own read).
904
+
875
905
  ### Loop Index Variables (`$1`, `$2`, ...)
876
906
 
877
907
  Inside getters and event handlers, `this.$1`, `this.$2`, etc. provide the current loop iteration index (0-based value, 1-based naming):
@@ -1632,6 +1662,48 @@ Key rules:
1632
1662
 
1633
1663
  See [docs/streams.md](docs/streams.md) for the full contract — lifecycle and ownership, restart semantics, flush granularity, and the out-of-scope list.
1634
1664
 
1665
+ ## Demand roots — what makes a getter run
1666
+
1667
+ Path getters are **lazy**. One that nobody reads is never evaluated. So "does this getter run?" is not answerable from the getter itself — it depends on **where the demand comes from**.
1668
+
1669
+ There are exactly **three** demand roots:
1670
+
1671
+ | Root | Lives in | Depends on rendering |
1672
+ |---|---|---|
1673
+ | **A live DOM binding** | `data-wcs` / mustache / comment bindings | **Yes** — remove the element and the demand goes with it |
1674
+ | **A `$watch` declaration** | the state | No (headless) |
1675
+ | **A `$streams` `args` function** | the state | No (evaluated on start and on every restart) |
1676
+
1677
+ **`$updatedCallback` is not a root.** It reports what the bindings did; it does not create demand.
1678
+
1679
+ ### Rendering can change program semantics
1680
+
1681
+ Because the first root lives in the DOM, **an element you think of as display-only can be the actual subscription**. This one was hit for real, in [`examples/state-intersect-scroll`](../../examples/state-intersect-scroll):
1682
+
1683
+ ```html
1684
+ <!-- Meant as display. It was the only demand root. -->
1685
+ <b data-wcs="textContent: $streamStatus.pageResult"></b>
1686
+ ```
1687
+
1688
+ ```javascript
1689
+ // $updatedCallback is binding-driven — delete that <b> and the path stops
1690
+ // appearing in `paths`, so the feed silently stops committing.
1691
+ $updatedCallback(paths) {
1692
+ if (!paths.includes("$streamStatus.pageResult")) return;
1693
+ this.items = this.items.concat(this.pageResult.items);
1694
+ }
1695
+ ```
1696
+
1697
+ **The rule:** logic that must not depend on what is rendered belongs on a `$watch` (or a `$streams` `args`). Keep `$updatedCallback` for "follow what was drawn".
1698
+
1699
+ That example now uses `$watch`, and the `<b>` is display-only again. This shape — `$updatedCallback` testing a path that is not bound anywhere — is detected statically as **`wcs/updated-callback-unbound`**.
1700
+
1701
+ ### The limitation that remains
1702
+
1703
+ Demand still comes from three separate places. **To know whether a getter is evaluated you have to inspect all three — every binding on the page, every `$watch`, and every `$streams` `args` — and reading the getter's definition will not tell you.** The linter and the DevTools wiring-coverage view exist to make a machine do that cross-check.
1704
+
1705
+ Note that a scalar getter named in `$watch` becomes **eager** (evaluated once at connect, then at the end of every batch touching its dependencies). Wildcard row getters do not become eager, because the first evaluation would walk the whole list.
1706
+
1635
1707
  ## Watch (`$watch`)
1636
1708
 
1637
1709
  `$updatedCallback` is **binding-driven**: it reports the paths whose live DOM bindings were actually applied in that update, so a value you never render is invisible to it. **`$watch`** is the headless counterpart — it fires on state changes whether or not anything on the page is bound to the path. (One exception, spelled out below: a *wildcard* row path needs `$listKeys` to work headlessly.)
@@ -1944,6 +2016,76 @@ All hooks except `$disconnectedCallback` support `async` — you can use `async/
1944
2016
  - `$updatedCallback(paths, indexesListByPath)` receives the paths whose live bindings were applied in that drain. Unbound state writes do not invoke it or appear in `paths`. For wildcard updates, `indexesListByPath` contains the updated index sets. Can be `async`, but the return value is not awaited
1945
2017
  - In Web Components, define `async $stateReadyCallback(stateProp)` to receive a hook when the bound state becomes available via `bind-component`
1946
2018
 
2019
+ ## Diagnostics and failure handling
2020
+
2021
+ ### Wiring to a path that does not exist is reported
2022
+
2023
+ When a wired path provably does not resolve against the state, you get one warning at binding time (at declaration time for `$watch`). The diagnostic codes are shared by the console, `@wcstack/lint`, and the VS Code extension:
2024
+
2025
+ ```
2026
+ [@wcstack/state] [wcs/binding-path-missing] Bound path "user.nmae" does not resolve on state "default":
2027
+ "nmae" is not declared. Did you mean "name"? Updates to this path will be silently
2028
+ dropped. Validate statically: npx @wcstack/lint <file>.
2029
+ ```
2030
+
2031
+ | Situation | Behavior |
2032
+ |---|---|
2033
+ | Typo in a nested path (`user.nmae`) | `console.warn` (`wcs/binding-path-missing`). Updates still never arrive — you fix it |
2034
+ | Typo in a top-level path (`cout`) | Throws on read, with the same wording and did-you-mean |
2035
+ | Typo in a `$watch` key | `console.warn` (`wcs/watch-path-missing`), reported even for a single segment |
2036
+
2037
+ The check **under-approximates**: it stays silent for anything it cannot decide statically, because a false alarm costs more than a missed one. None of these warn:
2038
+
2039
+ - A `null` / `undefined` parent (the "seed as `null`, assign later" shape)
2040
+ - Row fields of a list that starts empty (the row shape is unknown)
2041
+ - Sub-properties of an intermediate getter's return value
2042
+ - Mapped `bind-component` child scopes (the parent owns the path)
2043
+ - Reserved `$` namespaces (`$command.*` and friends)
2044
+
2045
+ So **no warning is not a proof of correctness.** For exhaustive checking, run `npx @wcstack/lint <file>`.
2046
+
2047
+ ### Index arity, wildcard rank, and getter cycles are checked too
2048
+
2049
+ Anything that follows mechanically from the path string is reported at runtime and by the linter under the same diagnostic code.
2050
+
2051
+ | Diagnostic | What it checks | Fix |
2052
+ |---|---|---|
2053
+ | `wcs/index-arity` | `$resolve(path, indexes)` must match the `*` count **exactly**; `$getAll(path, indexes)` has it as an **upper bound** (fewer is a legitimate prefix meaning "expand the rest") | Match the count |
2054
+ | `wcs/wildcard-rank` | The path's `*` count (and the N in `$N`) must not exceed the enclosing `for` nesting | Add a `for`, or name the row with `$resolve(path, indexes)` |
2055
+ | `wcs/getter-cycle` | Path getters must not form a dependency cycle | Break the cycle |
2056
+
2057
+ Previously **extra indexes were silently discarded** by both APIs, so a mixed-up call returned a plausible-looking wrong value. Both now throw:
2058
+
2059
+ ```javascript
2060
+ // ❌ Only one "*" in the path, two indexes given — used to return items[0]'s value
2061
+ this.$resolve("items.*.price", [row, col]);
2062
+
2063
+ // ✅ Two levels, two indexes
2064
+ this.$resolve("matrix.*.*", [row, col]);
2065
+ // ✅ Fewer is fine for $getAll — it means "expand the remaining levels"
2066
+ this.$getAll("matrix.*.*", [row]);
2067
+ ```
2068
+
2069
+ ### One failing binding is confined to that binding
2070
+
2071
+ If applying a binding throws, the rest of that batch, `$updatedCallback`, `$watch`, and `$streams` restarts all still run. The failure is not swallowed — it goes to `console.error` and to DevTools (`state:binding-apply-error`):
2072
+
2073
+ ```
2074
+ [@wcstack/state] binding "text: items.*.label" failed to apply; the rest of this batch continues.
2075
+ ```
2076
+
2077
+ Without that confinement, a single throw left "new values, half-updated DOM" behind, and silently dropped every `$watch` handler and stream restart for the batch — quietly breaking the firing-order contract documented above.
2078
+
2079
+ ### Values and the DOM are never rolled back
2080
+
2081
+ Every failure mode reports and continues; nothing already applied is reverted:
2082
+
2083
+ | Mechanism | Limit | On exceeding |
2084
+ |---|---|---|
2085
+ | Propagation hops | 32 | Quarantine the transaction's remaining records |
2086
+ | `$watch` write chain | 32 | Skip watch firing for that batch |
2087
+ | Binding apply failure | — | Skip that one binding |
2088
+
1947
2089
  ## Configuration
1948
2090
 
1949
2091
  Pass a partial configuration object to `bootstrapState()`: