Integration (L3)
1. The verified crates¶
The portable, side-effect-free core is implemented, tested natively, and verified to compile to wasm32-unknown-unknown:
posthaste-replica-core— the shared predictor + convergence engine.posthaste-replica-projector— the working-set view layer (mechanism+projection:ReplicaMechanism,ViewProjection).posthaste-client-node-wasm— thewasm-bindgenboundary (EntityStoreHandle), plus theposthaste-link-near-endengine compiled in forwasm32(D41: kernel + projector + near-end); values cross as JSON strings;cargo checkpasses forwasm32and native.
2. Packaging and the wasm-bindgen boundary¶
just build-client-node-wasm runs the pipeline: build the cdylib for wasm32-unknown-unknown (rust-lld), wasm-bindgen emits the JS loader + .d.ts, wasm-opt -Oz shrinks it (238K→192K). The client-node-wasm CI job rebuilds, checks the committed JS bindings are fresh, and runs a bun smoke test that instantiates the module and folds an optimistic mutation. The generated artifacts are committed (like schema.gen.ts) so web builds need no Rust toolchain. The smoke test caught a real contract bug: acceptMutationJson was camelCase while row ingest was snake_case — the ingest JSON is now camelCase throughout.
3. IndexedDB persistence¶
The replica's durable state is the pending set; the base/rows cache is rebuildable.
- pending set — keyed by
mutationId; value{ messageId, assertion, clientMutationId, runtimeMutationId?, acceptedAt }. Idempotent on key, versioned/migrated, never silently dropped (it is the only home of unconfirmed intent). D54 renamed the in-code vocabulary from "outbox" to "PendingSet" (pendingSetStore.ts), but the persisted IndexedDB object-store name stays the literal string'outbox'— it is on-disk data for every existing install, and renaming it would need a client-side migration that is out of scope. base— per open view, the last served rows so a reload re-renders instantly before the down-channel resumes. Rebuildable: on version skew or corruption, drop and re-acquire by snapshot.
On load: rehydrate the pending set into the handle (replay acceptMutationJson), then ingestBatchJson the cached base before the live stream catches up. Host glue lives under apps/web/src/runtime/replica/ (mapping.ts, pendingSetStore.ts, handle.ts, entityStoreAdapter.ts).
4. Transport injection and the contract↔replica mapping¶
The JS host owns transport (reusing what httpAdapter does against the runtime API):
- Up-channel. A renderer mutation → mint a
ClientMutationId,acceptMutationJsonit, persist to the pending set,POSTtheMutationRequest(the typedMailOperation), record theMutationReceipt(clientMutationId↔runtimeMutationId). - Down-channel. Subscribe to the runtime frame stream and map frames:
ViewSnapshot/ViewReplace/ViewDelta→ingestBatchJsonof the changed rows;MutationSettlement { mutationId, state }→settle(clientMutationId, …)via the receipt map (Confirmed→ confirmed;Failed→ failed, surface). The settlement vocabulary is exactly these two terminal states — a mutation settles confirmed or failed; there is no resting conflict or non-terminal settlement to map. - Membership for
projectViewJson. Pass the view's concrete mailbox when the query is a single-mailbox filter so archived-out rows drop instantly; for smart/compound queries pass none and let the next served base correct (full local query evaluation is the deferred coverage/atoms layer).
5. The failure path and remaining gaps¶
A message mutation settles Confirmed synchronously on local enqueue, so the adapter retires the pending op on that frame. When the provider later rejects, the authority server's failure base-correction appends a message.updated event that recomputes served views into a corrected replace; the adapter re-ingests the reverted base and, with no pending op left, shows authoritative state. So a rejected mutation reverts and does not stick — the replica is correctness-safe to dogfood behind the flag for keyword/mailbox/destroy assertions.
[::state partial plan=eph/PLAN-L2-client-link-unification#3-the-load-bearing-decision]
Two gaps remain before the replica is the default client path:
- Surfaced-failure (UX/observability, not a data gate). Because the mutation already settled
Confirmed, the user briefly saw success and there is no keyedFailedsettlement to drive a failure toast — the change silently reverts. Closing it needs a runtime lifecycle decision (double-settlement vs deferred-confirm) plus re-establishing theOperationId↔linkmutation_idcorrelation severed at the accept/flush boundary. - Incremental recompute (runtime-side perf). The runtime still re-queries the whole page per event and diffs to produce the
ViewDelta; applying the changed message's assertion to a held base instead is not yet built.
Also forward: snapshot recovery on a down-channel gap, the never-confirms backstop, entity convergence for drafts/sends by id, and multi-window (tab-local replicas to start; a SharedWorker-backed single replica for cross-tab coherence if needed).
6. Assertions¶
| ID | Sev. | Assertion |
|---|---|---|
| pending-set-indexeddb-durable | MUST | The pending set persists in IndexedDB keyed by mutation id, survives reload, and is migrated (never silently discarded); the base cache is rebuildable by snapshot. |
| settlement-via-receipt | MUST | The host translates MutationSettlement to settle through the ClientMutationId/RuntimeMutationId pairing recorded from the MutationReceipt. |
| replica-transport-injected | MUST | The WASM node performs no networking itself; the JS host injects the request and stream transports that map to the runtime API. |
| replica-adapter-flagged | SHOULD | entityStoreAdapter is selected behind VITE_RUNTIME_REPLICA, default off, until the surfaced-failure gap closes and dogfood promotes it; the renderer is unchanged when selected. |