Skip to content

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 — the wasm-bindgen boundary (EntityStoreHandle), plus the posthaste-link-near-end engine compiled in for wasm32 (D41: kernel + projector + near-end); values cross as JSON strings; cargo check passes for wasm32 and 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, acceptMutationJson it, persist to the pending set, POST the MutationRequest (the typed MailOperation), record the MutationReceipt (clientMutationIdruntimeMutationId).
  • Down-channel. Subscribe to the runtime frame stream and map frames: ViewSnapshot/ViewReplace/ViewDeltaingestBatchJson of 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 keyed Failed settlement to drive a failure toast — the change silently reverts. Closing it needs a runtime lifecycle decision (double-settlement vs deferred-confirm) plus re-establishing the OperationId↔link mutation_id correlation 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.