Publication

  1. The contract
  2. What an op means to a reader
  3. The paths in
  4. Batching and retention
  5. Tests

A state transition reaches readers in two steps. Discovery works out what changed: reconcile(prev, next) in reconcile.ts, which is pure and schedules nothing. Publication installs the new snapshot and invalidates readers: commit(base, next, patch) on a graph source, in graph.ts. publish(next) is discovery followed by publication, and it is what every yield uses.

The split exists so that a producer that already knows its changes can skip the diff. A persistent collection or a tracked editing session would be such a producer, but neither exists yet: application code still writes yield state, and every change still enters through a diff. The boundary is internal. source and commit are not exported from @nonchalant/core.

Diagram
flowchart LR
    Y["yield next"] --> D{"readers or<br/>a sink?"}
    D -->|no| I["install only"]
    D -->|yes| R["reconcile(prev, next)"]
    R --> C["commit(prev, next, patch)"]
    X["a producer that knows its ops"] -.-> C
    C --> S["snapshot = next"]
    S --> V["invalidate: affects() per gate"]
    V --> F["flush queue"]

The contract

commit(base, next, patch) requires two things:

No revision number is involved, and the wire protocol is unchanged.

Invalidation is the loop that used to sit inside publish, moved without changes. The snapshot is assigned first, so a reader that wakes sees the new state. An empty patch wakes no one. A gate whose reader is in the middle of a run parks the ops, and finalizeGates judges them once that run's paths are sealed (graph.md).

A source with no gates installs next and never computes the diff, because the only thing a patch is used for there is matching it against gates. A reader that arrives later records its reads against whatever snapshot is current at that point.

What an op means to a reader

paths.ts is the one place that decides which recorded reads an op affects. A producer must never implement its own matching. The rules are listed in tracking.md. Two of them matter most for producers other than the diff:

Ops are judged one at a time, in the order they apply, and each op is evaluated against the state it applies to. That is enough for a whole patch or a series of patches: an index can move only at or after a splice point (or an array del), and those same ops already wake every reader of an index there. Unshifted positions keep their coordinates. Parked ops from several publishes are judged by the same rule.

A replay op and an invalidation are the same Op value. The only form kept for invalidation is the parsed segment list (segsList), built once per publication and not stored. No separate invalidation type exists, because nothing would get simpler or faster with one.

The paths in

path discovery publication notes
local yield (process.ts) reconcile(prev, next), only when the source has gates or an instrument sink is installed src.commit(prev, next, ops) with a sink, the diff runs once and the sink's yield event carries the same ops readers are invalidated with (before this change, it ran twice)
incoming wire patch (wire/pump.ts) applyPatch rebuilds the next state, then the pump yields it, so a local diff runs again as a local yield the second diff is kept on purpose; see below
reconnect snapshot the host sends [['set', '', state]] measured from nothing, the pump rebuilds from null, and the local diff compares against the value it kept as a local yield the diff is what lets readers of unchanged paths sleep through a reconnect, so it must stay
inspector replay (inspect/recording.ts) ops recorded from the sink none: applyPatch into its own recording, which is plain data time travel folds patches into a base and does not touch the graph
host watch (wire/watch.ts) reconcile between consecutive observed snapshots encoded onto the transport this is where publications merge together (see batching)

Why the pump still diffs

An incoming patch does describe the transition it installs, and it could feed commit safely when the pump's running snapshot is the source's current one. It still goes through the diff for three reasons:

On one machine, the second diff costs about as much as reconcile on the same shape: tens of µs for one changed row in 10,000. The first thing to try, if profiling shows remote sparse edits are bound by this diff, is to feed the patch in when the base matches and fall back to the diff after a restart.

Batching and retention

Effect batching and dropping intermediate publications are different mechanisms:

Retention:

Tests

packages/core/test/commit.test.ts covers the boundary with a private producer that edits a state op by op (set, del, splice, array del included) and passes next plus the ops to commit:

paths.test.ts checks the array-del rule against hand-built path trees. In instrument.test.ts, "diffs a yield only when something consumes the patch" pins the number of diffs per yield: 0 with no reader and no sink, 1 with either. process.leaks.test.ts pins that ops parked for a mid-run reader, and the values they carried, are released once judged.

Next: graph.md for how gates are woken, tracking.md for what a read records.

This page's source on GitHub