Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
d3793d4
Implement durable peer store and forward
lucas-barake Jul 26, 2026
65efd02
Extend durable peer store and forward
lucas-barake Jul 26, 2026
6aefe95
Refactor durable relay around generic SQL storage
lucas-barake Jul 26, 2026
203881d
Stabilize SQLite relay claim planning
lucas-barake Jul 26, 2026
a842bb6
Address durable relay review findings
lucas-barake Jul 26, 2026
1170faa
Handle Effect errors at call sites
lucas-barake Jul 26, 2026
1be91ee
Move task tracking to LEDGER.md and tighten service rules
lucas-barake Jul 27, 2026
ecf8f27
Add the cluster relay inbox entity and its store contract
lucas-barake Jul 27, 2026
55d9041
Implement the relay inbox store over Effect SQL
lucas-barake Jul 27, 2026
e9faf30
Add the relay front door over the existing wire contract
lucas-barake Jul 27, 2026
6bdab08
Enforce authorization for the life of a relay session
lucas-barake Jul 27, 2026
cdf6e3c
Cover the relay inbox store against real SQLite
lucas-barake Jul 27, 2026
eba9c80
Unbreak the type check on the relay inbox store suite
lucas-barake Jul 27, 2026
6e96ef0
Cover the relay inbox entity and fix what that exposed
lucas-barake Jul 27, 2026
a3a0ec7
Prove the relay wire format over a real socket
lucas-barake Jul 27, 2026
4ca281d
Run relay inbox retention as a cluster singleton
lucas-barake Jul 27, 2026
d8ba935
Span the relay inbox I/O and entity boundaries
lucas-barake Jul 27, 2026
87c1260
Restore the authorization the front door had dropped
lucas-barake Jul 27, 2026
1750464
Cover the remaining front-door contract guarantees
lucas-barake Jul 27, 2026
6cd97d5
Delete the single-process relay and cover the store on every dialect
lucas-barake Jul 27, 2026
1d63e96
Stop a withheld delivery from stalling the rest of the inbox
lucas-barake Jul 27, 2026
215ff57
Pin the front door's identity checks
lucas-barake Jul 27, 2026
13f8841
Pin the front door's authorization at every call site
lucas-barake Jul 27, 2026
b2241c6
Stop a dead dispatcher from silently swallowing an inbox
lucas-barake Jul 27, 2026
43d78c7
Delete the relay configuration that no longer reaches any code
lucas-barake Jul 27, 2026
2c1e80d
Take Duration.Input for configured durations
lucas-barake Jul 27, 2026
d32975e
Describe the relay that exists rather than the one that was deleted
lucas-barake Jul 27, 2026
3208478
Authorize a delivery against the peer that actually sent it
lucas-barake Jul 27, 2026
aa31268
Make closing a replaced session atomic under interruption
lucas-barake Jul 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/initial-release.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,8 @@
---

Initial release of Effect Local and its core, SQL, RPC, browser, and testing packages.

Local first document synchronization built on Automerge, with SQLite backed replicas on Node and in the browser, and
an authenticated store and forward peer relay over Effect RPC. The relay is a cluster of per device entities, so more
than one relay node can serve one database, and durable custody is injectable behind `RelayInboxStore` with a
`SqlClient` implementation for SQLite, PostgreSQL, and MySQL.
2 changes: 0 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
# Agent Instructions

Read `RULES.md` before any work.

Read the untracked task ledger in `AGENTS.local.md`. Only the main task owner edits or replaces it. Review agents report their findings directly and do not modify the ledger.
2 changes: 0 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
# Agent Instructions

Read `RULES.md` before any work.

Read the untracked task ledger in `AGENTS.local.md`. Only the main task owner edits or replaces it. Review agents report their findings directly and do not modify the ledger.
647 changes: 211 additions & 436 deletions README.md

Large diffs are not rendered by default.

17 changes: 11 additions & 6 deletions RULES.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,10 @@ Read this file before any work. Treat these rules as required for every package.

## Coordination

- Read `AGENTS.local.md` before editing.
- Only the main task owner edits or replaces `AGENTS.local.md`, and only when the repository level task changes to unrelated work.
- The main task owner records the task, branch, PR, ownership, checkpoints, commands, results, decisions, and next actions.
- The main task owner checks `git status` and the remote branch before mutation, rebases before every push, and pushes each independently verified fix.
- Check `git status` and the remote branch before mutation, rebase before every push, and push each independently verified fix.
- Never overwrite, revert, or reformat another contributor's work.
- Keep commits narrow.
- Use one production owner for overlapping files. Review agents must not edit production code, tests, shared ledgers, commits, branches, or PR state.
- Use one production owner for overlapping files. Review agents must not edit production code, tests, commits, branches, or PR state.

## Effect Source Of Truth

Expand All @@ -25,6 +22,8 @@ Read this file before any work. Treat these rules as required for every package.
- Define every library owned typed error with `Schema.TaggedErrorClass<Self>(identifier)("Tag", fields)`.
- Give every typed failure a stable `_tag`. Do not add untagged or `Data.TaggedError` library error types.
- Discriminate tagged errors with `_tag`. Do not use `instanceof` for error discrimination.
- Do not define shared error transformers such as `toSomeError`, `mapErrors`, or helpers that accept `unknown` and return another error. Handle each inferred failure directly at the call site with explicit `_tag` based `Effect.catchTag`, `catchTags`, `catchReason`, or `catchReasons` branches. Use `catchCause` at the call site only when the full Cause must be preserved. Accept small duplication so error channels stay narrow and every handled failure remains explicit.
- Do not use generic `E` intersections, structural `_tag` probes, `typeof error === "object"`, or property existence checks to claim that an Effect failure is a specific tagged error. Let the Effect error channel prove the tag and use the matching typed catch combinator. If the combinator does not typecheck, the call site has not proved that it can fail with that error.
- When a consumer error can share an infrastructure `_tag`, use the infrastructure package's precise guard instead of `catchTag`.
- Use `Effect.catchTag` or `catchTags` for `_tag` failures. Use `catchReason` or `catchReasons` for nested tagged reasons. Use `catchIf`, `catchFilter`, or a specialized combinator for other selected typed failures.
- Use `Effect.catch` only when one handler intentionally covers the complete typed failure channel. It does not catch defects or interruption.
Expand All @@ -39,7 +38,10 @@ Read this file before any work. Treat these rules as required for every package.
- For a service that requires cleanup, acquire it with `Effect.acquireRelease` or another scoped `Effect`, and provide it with `Layer.effect`.
- Keep Layer construction configurable. Do not hide lifecycle, persistence, concurrency, or security choices behind defaults.
- Do not capture consumer specific runtime values in module global state. Pass them through service implementations, Layer constructors, or operation arguments.
- A Layer must capture every service used later by its methods or expose that service as a Layer requirement.
- Resolve dependencies once while the Layer is being built and close over them. A service method must not leave a service in its own requirement channel.
- A service's methods should be `Effect<A, E, never>`. Leak a requirement only when the dependency is genuinely per call, such as a request scoped context or an ambient `Scope` the caller owns, and say why at the definition.
- A leaked requirement is a real cost: it spreads to every transitive caller, it lets a method be invoked under a context the Layer never validated, and it hides which services an implementation actually needs behind the call sites instead of stating it once at construction.
- A Layer must capture every service used later by its methods.
- Export a constructor or use `Layer.fresh` when a context sensitive Layer must build independently more than once under one memo map.

## Composition And Effects
Expand Down Expand Up @@ -69,6 +71,9 @@ Read this file before any work. Treat these rules as required for every package.

- Optimize public APIs for low setup friction and explicit consumer control.
- For pipeable public combinators, use `Function.dual` when data first and data last forms materially improve use.
- Express a configurable duration as `Duration.Input`, never as a bare number named with a unit suffix such as `Millis`. `Duration.Input` lets a consumer write `"30 seconds"`, `Duration.minutes(5)`, or a raw number, and it states the unit in the type instead of in the identifier.
- Convert a configured duration once, with `Duration.toMillis`, at the point the Layer or service is constructed, and close over the result. Do not convert per call and do not thread `Duration.Input` into arithmetic.
- A duration that crosses a wire protocol or is persisted stays a plain number with its unit in the name. `Duration` has no stable serialized encoding, so a protocol or column must name its unit.
- Use `camelCase` for values and functions. Use `PascalCase` for types, services, schemas, and error classes.
- Add an `Error` suffix when it improves clarity. Preserve established class names and serialized `_tag` values for public or protocol errors.
- Name Layer values and constructors so their implementation, configuration, or lifecycle is clear.
Expand Down
46 changes: 46 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,56 @@ for reconstructing the session in place.
4. Workflow journals are durable orchestration records.
5. Atom values are reactive caches.
6. Presence and tab sessions are ephemeral.
7. Sender relay outbox rows are temporary durable transfer state in frontend SQLite.
8. Relay custody rows are temporary durable delivery state in the configured backend SQL database.

Cluster and Workflow do not replace Automerge. They serialize local effects, resolve retry ambiguity, and resume
operations after process loss. Automerge remains responsible for causal history, conflicts, and convergence.

## Relay topology

Effect RPC uses one durable protocol. Version `1` uses `PeerRpc.Rpcs` over a standard Effect RPC socket with
`RpcSerialization`, `RelayServer.layerHandlers` for the front door, and `RelayServer.layer` to compose the front door
with the entity behaviour and its retention singleton.

```mermaid
flowchart LR
Sender["Sender SQL replica"] --> Outbox["Stable sender relay outbox"]
Outbox --> FrontDoor["RelayServer front door (any node)"]
FrontDoor --> Entity["RelayInbox entity (single owner per device)"]
Entity --> Custody["Injected backend SQL custody"]
Entity --> RecipientSession["Recipient delivery session"]
RecipientSession --> Receipt["Recipient SQL sync and receipt"]
Receipt --> Ack["Relay acknowledgement"]
Ack --> Entity
```

Endpoint ownership, routing, and cross node wakeups come from cluster sharding rather than from process local state,
so several relay nodes may serve one database as active active. The entity that owns a device is the sole writer for
that device's inbox, which is what makes custody need no distributed protocol of its own. The supplied `SqlClient` may
target SQLite, PostgreSQL, or MySQL. The relay requires a `Sharding` and never builds one: single process in memory,
one runner over SQL, and many sharded runners are all the application's choice.

Relay ordering is FIFO within one exact directed peer channel, where a channel includes the sender's connection
epoch. Distinct channels progress concurrently, so one stalled sender cannot block another. Delivery is stop and wait
within a channel: nothing is offered behind an unsettled message. See
[Store and forward](store-and-forward.md) for delivery, capacity, security, and lifecycle details.

Relay infrastructure treats the Automerge message as opaque. It validates the envelope Schema, endpoint and document
routing, hashes, outer digest, and writer provenance shape. Relay enabled `PeerSync` performs the one semantic
Automerge decode.

That boundary is explicit because Automerge `3.3.2` has no allocation bounded decode API. Ordinary authentication and
document authorization do not imply resource trust. `PeerRelayAuthorization` requires a second exact
`UnsafeUnboundedAutomerge3DecodeGrant` for relay admission and delivery. Its principal, remote, direction, documents,
finite lease, and revocation Effect are all scoped and validated. Default deny is
`denyUnsafeUnboundedAutomerge3Decode`.

Fresh ordinary and unsafe grants feed a local operation gate. Revocation that reaches the gate first prevents SQL
mutation or payload emission. An operation admitted first may finish and returns its real result. Revocation drains
that bounded in flight work and blocks later work. This is a local ordering boundary, not an atomic transaction
between SQLite and an external policy authority.

## Domain API

Applications define schema coded `Document`, `Mutation`, `Projection`, and `Query` values, then collect them in one
Expand Down
38 changes: 38 additions & 0 deletions docs/durability.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,3 +134,41 @@ Projection failure rolls back the replacement.

Browser persistence does not make backup optional. OPFS may be evicted while its origin storage bucket remains best
effort. A complete product must expose export, restore, duplicate, and deletion flows to the person who owns the data.

## Store and forward custody

Store and forward adds three different durable boundaries.

1. Sender admission commits a stable relay message identity and complete outer envelope to the local relay outbox
before a network attempt.
2. `Push` succeeds only after the backend relay store commits the envelope and immutable quota reservation.
3. `AcknowledgeRelay` is attempted only after the recipient commits the Automerge application result and sender
scoped replay receipt through the production sync path.

Relay admission and delivery require the ordinary authorization lease and a separate exact unsafe Automerge resource
trust lease. After fresh grants, a bounded server operation and policy expiry or revocation contend on a local gate.
Revocation that wins before admission prevents the SQL mutation or payload emission. An operation that wins is in
flight, may finish its durable commit or delivery, and returns its real result. Revocation drains that operation and
prevents later work. It does not retroactively cancel the winner. SQLite and the external policy authority do not
share an atomic transaction. The extra grant exists because Automerge `3.3.2` does not expose allocation bounded
semantic decode. Authentication and document access alone do not satisfy it.

These boundaries provide at least once transfer. They do not provide exactly once delivery. A response can be lost
after any commit. A delivery attempt can be abandoned while its message is still `Pending`. The durable identities
and per attempt claim tokens make retries and stale acknowledgements safe, while retained recipient receipts make
duplicate application idempotent during the negotiated window.

Relay FIFO ordering is scoped to one exact directed peer channel, which includes the sender's connection epoch. One
in flight head blocks later rows in that channel. Unrelated channels do not share that ordering fence.

Recipient delivery attempts are durable and bounded by `RelayInbox.Options.maxDeliveries`. An attempt is charged only
once the message has reached the transport, so a delivery abandoned by a disconnect costs nothing. Reaching the cap
changes the message to `DeadLettered` so it stops blocking its channel. Restart cannot reset this poison bound.

Undelivered messages expire at `RelayInbox.Options.messageTtl`. A terminal row keeps its stored envelope until
retention collects the whole row past its deduplication horizon, which is what lets a sender that still holds custody
revive a dead lettered or expired identity. Sender outbox rows expire at their configured retry horizon. Recipient receipts expire according to `PeerRelayReceiptLimits`.

Relay custody rows and client relay outbox or receipt rows are not canonical document history and are not included in
canonical backup archives. Restore fences the previous incarnation, removes stale relay client state, and reconciles
usage before installing the new incarnation. See [Store and forward](store-and-forward.md) for the complete contract.
Loading
Loading