← 1.0.0 scope SAPE-26 Done

Apply a change: carry a log entry from a wire subscription into a database that did not write it

Status
Done
Version
1.0.0
Component
server
Priority
High — the primitive SAPE-27 is built on
Blocked by
Blocks
SAPE-27 — unblocked

Outcome: the wiring was the small half, and it found two data bugs

Client.Subscribe and NextChange exist; the loop was measured against a real sapedbd — seed before the daemon starts, subscribe, then Establish, Declare, insert, update and delete while watching, each Change applied into a blank store, and the two compared collection by collection, spec by spec, document by document field by field in both directions, and across every version of every operation.

The last out-of-scope line below says this ticket "does not rewrite Apply; it is already correct and already tested." That was wrong, and expensively so. Two real defects were in store.Apply the whole time, and TestAReplicaFedTheLogEndsUpTheSame — the green test quoted above as proof — stayed green with both of them present, because both are invisible to a document comparison:

  1. The collection id was not consumed. Apply installed a spec carrying its number without advancing the id generator. A database that followed and was then promoted would hand out id 1 again for its next own collection, and write documents into the middle of another collection's keyspace. That is corruption, not inconvenience.
  2. The write id did not travel with its entry. record() writes both the entry and its done marker; Apply wrote only the entry. A retry after a failover, aimed at a database that had followed, would write a second time — the exact case write ids exist to prevent.

Both surfaced only because somebody kept going past a green test. It is the strongest argument this project has produced for its own rule that one passing case states very little.

A measurement trap was walked into and out of on the way, and is recorded because the escape is the useful part: the first version of the "trimmed log" case used Subscribe(0), which is refused whether or not any trimming happened — so it measured nothing. It was replaced by probing Following.Oldest to prove the trim had already occurred, and only then subscribing at the entry a positive control had just reached successfully.

What it turned up

Description

Replication and follower mode were brought into 1.0.0 by direct product decision — see SAPE-27's "Why now" for the honest reason, which is not this project's usual data-loss/public-promise/install-and-run triage. This ticket is the first half: consume, over the wire, a change that a different process wrote to a different database, and end up with the same document, declaration, or deletion locally.

Corrected 2026-09-20 — the engine half of this already exists and is already tested

The brief for this ticket described Change as "only ever generated, never applied." That is not what measuring the code shows, and it is worth saying plainly because it changes the size and shape of the work: Store.Apply already exists, is exported, and is exercised by tests that do almost exactly what this ticket's acceptance criteria ask for. (Read the section above before trusting the rest of this one: "already tested" turned out to mean "tested in the ways that could not see either bug in it.")

What is actually missing is narrower than "build Apply": Store.Apply has zero callers outside internal/store's own tests. And the wire already carries exactly the right payload to feed it: internal/server/subscribe.go's drain sends each store.Change as json.Marshal(change) on an Event frame (frame type 9). Nothing decodes an Event frame's payload back into a store.Change on the receiving end — internal/wire/wire.go and the public sapedb.go package have no Subscribe method at all, client-side. The two halves — a tested Apply and a tested Event stream carrying exactly its input type — have never been connected across a process boundary.

What this ticket actually is

Make Apply reachable from outside internal/store, and prove it end to end across the wire rather than in one process copying Go values directly — decode a store.Change from the JSON bytes an Event frame carries, the way a real follower will have to, and apply it into a separate database that never saw the primary's memory.

Acceptance criteria

  1. A database is built and written to with a mix of inserts, updates, deletes, a collection declaration, and an operation declaration. Its log is read out — over a real socket, via the Subscribe/Event frames that already exist — decoded from wire JSON into store.Change values on the receiving side, and applied in order into a second, blank database in a separate process. Met.
  2. The two databases are compared document by document and declaration by declaration, not by count — the failure this has to catch is a document that survives with a field missing, and a count agrees with itself while that happens. Met, in both directions per field.
  3. Applying the same change twice, decoded from the wire a second time, changes nothing further. Met.
  4. An entry received out of order is refused with a named error, not silently dropped or silently accepted as a gap. Met.
  5. Whatever new code makes Apply reachable lives in a place that does not require internal/store to grow a wire dependency. Met — the decoding lives on the client side; the store package still knows nothing about frames.

Out of scope