Skip to content

feat(state): declare accumulation over time with $scan (#272) - #275

Merged
mogera551 merged 9 commits into
mainfrom
feat/state-scan
Sep 14, 2026
Merged

mogera551 merged 9 commits into
mainfrom
feat/state-scan

Conversation

@mogera551

Copy link
Copy Markdown
Contributor

Closes #272.

What this adds

$scan declares a value that accumulates over time — the one kind of accumulation that previously fell back to hand-written $watch handlers.

export default {
  page: 1,
  $streams: {
    pageResult: { args: (s) => s.page, source: loadPage },
  },
  $scan: {
    feed: {
      from: "pageResult",
      initial: { items: [], pages: [] },
      fold: (feed, chunk) => {
        if (chunk?.kind !== "success" || feed.pages.includes(chunk.page)) return feed;
        return { items: feed.items.concat(chunk.items), pages: [...feed.pages, chunk.page] };
      },
    },
  },
};
  • from (a state path) folds once per landing, inside the drain and before $watch. Wildcard paths fold per row.
  • on (an event token) folds synchronously once per event, before $on for the same token.
  • resetOn returns the output to initial when a listed path is written.
  • fold gets no this. Returning the same reference writes nothing. A throw or a thenable is isolated and reported with a phase (evaluate / fold / write).
  • Mistakes are rejected when the state is declared: a getter as a source, feeding an entry its own output, **, a clash with a method, and a stream whose args reach the scan's output (wcs/scan-feedback-loop).

vscode-wcs, and through it wcs-lint, checks the declarations statically, and devtools shows scan failures by phase. examples/state-intersect-scroll now builds its feed with $scan.

  • Design and decisions: docs/state-scan-design.md (D1–D18, §5).
  • Guide: packages/state/docs/scan.md (and scan.ja.md).

The quality pass (last three commits)

After the implementation and a first review, five more review cycles ran. Each cycle started a fresh reviewer, and the same reviewer re-verified every fix. Together they processed 74 findings. The notable fixes:

  • Rows. A row whose read fails no longer drops the whole batch. A list replaced in the same job folds each position once.
  • Pending resets. on scans keep their pending resets consistent with from scans across a failed write, the chain limit, a disconnect, and a re-set while the write is still queued.
  • Gates. The drain and enqueue gates follow the active set, so a disconnected element closes them.
  • initial. Plain data in initial is copied when it is placed on the output and on reset, so writing a child path never changes the declaration.
  • Static checks now match the runtime: cycles between outputs, **, empty names, arrays, method clashes, setter-only sources, $recursion expansions, and $scan inside volumes.

Decisions recorded in §5-6 to §5-9:

  • The drain order stays $scan write → $watch. Writing after $watch was tried and reverted. It lost handler writes to child paths and needed a hook in the write path. D12 now documents what the kept order means for $watch.<output> when the source is written again before the output lands: prev can carry a value, and the handler can fire again with the same value.
  • A reset carries over a re-set only while its write is still queued (D6). Other re-sets are listed there, together with the workaround.
  • Values in initial that are not plain data stay shared by reference (D7).
  • Full-list replacement is left unoptimized. Its planning cost measured within noise and below $watch.

Known follow-ups

Verification

Gate Result
state npm run test:coverage 295 files / 3543 tests, exit 0 — 99.6 / 98.74 / 100 / 99.77, src/scan 100%
state npm run lint, npx tsc --noEmit clean
vscode-wcs npx vitest run, npx tsc --noEmit 37 files / 897 tests, clean
devtools npx vitest run, npm run lint, npx tsc --noEmit 7 files / 135 tests, clean
lint smoke, after rebuilding dist/cli.cjs 20 / 20

e2e runs in CI.

🤖 Generated with Claude Code

mogera551 and others added 9 commits September 13, 2026 16:02
$streams folds within one run and $watch owns no value, so an
accumulation that outlives a run had nowhere to live. $scan declares
it: an output the runtime owns, fed by a state path (from) or an event
token (on), with resetOn and one fold per landing.

- from folds once per batch hit, before $watch, sharing the watch
  runtime's active set, chain depth, prev ledger and error isolation
- on folds once per event, synchronously, before the token's $on
- getter sources, self or cyclic references and malformed shapes raise
  at declaration, before the generation advances
- a stream whose args derive from its own scan output raises
  (wcs/scan-feedback-loop); a chunk sharing a batch with its stream's
  restart is not folded (restart wins)

Design record: docs/state-scan-design.md (Issue #272).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
vscode-wcs and @wcstack/lint now check $scan with the runtime's code
vocabulary, and devtools accepts the new protocol values.

- wcs/scan-declaration-invalid (error): shapes the runtime rejects
- wcs/scan-source-computed (error): from / resetOn on a getter
- wcs/scan-path-missing (warning): from / resetOn paths that do not exist
- scan outputs materialize as path candidates, like $streams values
- the defineState preamble types $scan; devtools accepts phase "fold"
  and path-unresolved source "scan"

Refs #272

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
examples/state-intersect-scroll now accumulates its feed with $scan
instead of concatenating inside a $watch handler. The $watch keeps the
error-time scrollY record and re-arms the sentinel after a full page;
the fold keeps a page key, because the runtime folds once per landing,
not once per page. The six e2e specs pass unchanged against the local
dist, and wcs-validate reports the same diagnostics before and after.

- packages/state/docs/scan(.ja).md: reference for from / on, resetOn,
  firing order, the fold contract and the lifecycle
- README (en/ja): Scan section, mechanism order with $scan, and the
  rendering-semantics note that pointed at the old $watch commit
- timing contract, streams / watch / event-lane design notes: cross
  references; the design record gains the measured results
- scan.streamCommit.test.ts pins the example's new feed boundary
- CHANGELOG: $scan, the lint codes and the devtools protocol values

Refs #272

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review of the $scan implementation found two ways it broke the
once-per-landing contract.

- A scan whose from is another scan's output folded the value that
  scan had just written in the same drain, then folded it again in the
  next batch, skipping the value that had landed ([3, 6, 6] instead of
  [1, 3, 6] when the upstream scan is declared first). The drain now
  plans every scan's next value by reading only, then writes them in
  declaration order.
- resetOn on an on-scan took effect only at the end of the drain, so an
  event that came after the write was wiped, including one an element
  dispatched synchronously while the write was applied to its binding.
  The updater's enqueue now marks a pending reset (scan/eventReset.ts);
  the next event folds into initial, and the drain resets only if the
  mark is still there.

Also:
- a resetOn path under the entry's own from raises (the reset would win
  every time); an ancestor of from stays allowed
- the chain-depth-limit message says $scan folds were skipped too
- failures are reported as threw / returned-promise / write
- recordWatchPrevValue is renamed recordDeclaredPrevValue
- the scan tests share writeState / readState / flushTimes

Refs #272

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- wcs/scan-declaration-invalid also covers an output name or a from /
  resetOn path inherited from Object.prototype, and a resetOn path
  under the entry's own from
- the output-conflict message names setters, which were already caught
- the preamble types fold with this: void, so a method-form fold that
  reads this is a type error (the runtime calls it without this)
- findNonObjectDeclaration's local is renamed to declarationProp

Refs #272

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- design record: D6 revised (pending reset for on-scans), D18 (plan,
  then write), the resetOn-under-from raise, and a review table in
  section 5-4 with the measurements taken before the fix
- scan(.ja).md and README (en/ja): resetOn semantics per source, the
  ancestor pattern, and chained scans folding once per landing
- CHANGELOG: the $scan entry reflects both

Refs #272

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Isolate a row whose read fails instead of dropping the whole fold, and
  fold each list position once when a replacement lands in the same job
- Report read, fold and write failures as separate devtools phases
- Keep an on-scan's pending reset until its write succeeds, carry it
  across a re-set while the write is still queued, and discard it only
  for a batch that will not fire
- Count the drain and enqueue gates by entry into the active set, so a
  disconnected element closes them
- Follow chained scans in the feedback-loop fence, and raise for a
  recursive getter or a setter without a getter used as a source
- Raise when an output name clashes with a method; accept a function
  output on re-set only when the runtime placed it there
- Place a copy of a plain initial on materialize and reset, so a child
  write never changes the declared value; compare by value so a no-op
  reset writes nothing

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
vscode-wcs:
- Report cycles between scan outputs, "**" as wcs/recursion-unsupported,
  non-string sources, an empty output name, an array declaration or
  entry, a method clash, and a setter without a getter as a source
- Match $recursion expansions and recursive getters the way $watch does
- Error for $scan in a mount= volume and warn in a bind-component
- Decide bind-component from parsed attribute names instead of a regex
  over the tag (this also fixes the $recursion check)
- Fold same-key declarations in evaluation order, last one wins
- Type $scan and $streams outputs on `this` without widening properties
  that are already declared
- Stop treating a same-named event token as an existing path

lint: regenerate dist/cli.cjs.
devtools: add the "write" phase and widen the watch-error title.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Design doc: revise the decisions the fixes changed and add sections
  5-5 to 5-9 (findings, measurements, the reverted reordering, and the
  row-landing defect handed off to #274)
- scan.md and README (en, ja): the prev recording rule, resetOn object
  paths, re-set limits, initial copying, the lifecycle table and the
  declaration-time error codes
- Align the mechanism order in the watch and view-transition design docs
  and the prev wording in the watch example
- Note that the intersect-scroll example needs the next release
- CHANGELOG entries

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mogera551
mogera551 merged commit e190e36 into main Sep 14, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

state: 時間軸累積(scan)の穴と $scan の条件付き推奨(決定ゲート G0〜G11)

1 participant