@thomasfosterau/legation 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Thomas Foster
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,351 @@
1
- # Temporary Holding Version
1
+ # @thomasfosterau/legation
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ An offline-first sync engine over a typed resource graph: Node (with its Edges), Collection, declarative Query reads, named Mutation writes, one Client per thread and one Server per backend.
4
+
5
+ Writes are named, schema-validated intents. They run optimistically on the client, and the server applies them authoritatively. Conflicts resolve by replaying pending intents on top of server state (rebase), not by merge strategies. Reads are declarative queries that compile to sync windows. The server tracks each client's view records (CVRs) and sends minimal row patches. The model is in [CONTEXT.md](../../CONTEXT.md) and [ADR-0022](../../docs/adr/0022-resource-graph-public-api.md).
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ npm install @thomasfosterau/legation effect
11
+ ```
12
+
13
+ ## Effect version
14
+
15
+ Peers `effect ^4.0.0`. Tested against `4.1.2` (the version CI pins) and `4.0.0` (the floor; CI runs the tests against it on every PR). Install one copy of `effect` in your app: two copies break `Context` identity.
16
+
17
+ ## Usage
18
+
19
+ This is the whole pipeline in one process: a `Server` over the reference in-memory backend, and a `Client` connected to it by a loopback transport.
20
+
21
+ ```ts
22
+ import { Effect, Schema, Stream } from "effect";
23
+ import {
24
+ Authorization,
25
+ Client,
26
+ Edge,
27
+ MemoryBackend,
28
+ Mutation,
29
+ Node,
30
+ Server,
31
+ Transport,
32
+ type Query,
33
+ } from "@thomasfosterau/legation";
34
+
35
+ // 1. The domain, shared by client and server: a node graph, mutations, reads.
36
+ const projects = Node.make("projects", {
37
+ attributes: { name: Schema.String },
38
+ edges: { todos: Edge.many("todos", { inverse: "project" }) },
39
+ });
40
+ const todos = Node.make("todos", {
41
+ attributes: { title: Schema.String, done: Schema.Boolean },
42
+ // The foreign key is declared once, on the child; `projects.todos` is its inverse.
43
+ edges: { project: Edge.one("projects", { via: "projectId" }) },
44
+ filterable: { done: ["eq"] },
45
+ });
46
+ const nodes = Node.registry([projects, todos]);
47
+
48
+ const Project = Schema.Struct({ id: Schema.String, name: Schema.String });
49
+ const Todo = Schema.Struct({
50
+ id: Schema.String,
51
+ projectId: Schema.String,
52
+ title: Schema.String,
53
+ done: Schema.Boolean,
54
+ });
55
+ const setProject = Mutation.make("project/set", Project);
56
+ const setTodo = Mutation.make("todo/set", Todo);
57
+
58
+ // The optimistic (client-side) handlers: they run once when the mutation is
59
+ // issued and again on every rebase, so they must be re-runnable.
60
+ const mutations = Mutation.registry([
61
+ { mutator: setProject, local: (p, writer) => writer.put("projects", p.id, p) },
62
+ { mutator: setTodo, local: (t, writer) => writer.put("todos", t.id, t) },
63
+ ]);
64
+
65
+ const projectsWithTodos: Query.Read = { collection: "projects", include: { todos: true } };
66
+ const openTodos: Query.Read = { collection: "todos", where: { done: false } };
67
+
68
+ const program = Effect.gen(function* () {
69
+ // 2. Server: one per backend. MemoryBackend is the reference backend; its
70
+ // handlers are the authoritative mutations. Read auth is deny-by-default.
71
+ const backend = yield* MemoryBackend.make(
72
+ MemoryBackend.validatedHandlers([
73
+ MemoryBackend.handler(setProject, (p, writer) => writer.put("projects", p.id, p)),
74
+ MemoryBackend.handler(setTodo, (t, writer) => writer.put("todos", t.id, t)),
75
+ ])
76
+ );
77
+ const server = yield* Server.make({
78
+ backend,
79
+ auth: {
80
+ policy: Authorization.policy(
81
+ { projects: Authorization.allowAll, todos: Authorization.allowAll },
82
+ { collections: nodes.collections }
83
+ ),
84
+ },
85
+ });
86
+
87
+ // 3. Client: one per thread. Here it reaches the server over an in-process
88
+ // loopback transport; `syncLoop.pushDebounce` pushes writes on its own.
89
+ const { client } = yield* Client.make({
90
+ nodes,
91
+ mutations,
92
+ clientGroupId: "group-1",
93
+ transport: yield* Transport.makeInMemory(server.handler, "group-1"),
94
+ syncLoop: { pushDebounce: "50 millis" },
95
+ });
96
+
97
+ // 4. Reads: `subscribe` pins a read's windows (edges included) so the
98
+ // replica syncs them; `observe` subscribes for the stream's lifetime.
99
+ // A read is answered from the replica only once the windows its rows
100
+ // link to have been pulled: a pull that started after they were
101
+ // subscribed has applied.
102
+ yield* client.subscribe(projectsWithTodos);
103
+ yield* Effect.forkScoped(
104
+ client.observe(openTodos).pipe(Stream.runForEach((rows) => Effect.log(`${rows.length} open`)))
105
+ );
106
+
107
+ // 5. Writes: applied optimistically now, confirmed by the server later.
108
+ yield* client.mutate("project/set", { id: "p1", name: "Launch" });
109
+ yield* client.mutate("todo/set", { id: "t1", projectId: "p1", title: "Ship it", done: false });
110
+
111
+ return yield* client.query(projectsWithTodos);
112
+ });
113
+
114
+ Effect.runPromise(Effect.scoped(program)).then((rows) => console.log(JSON.stringify(rows)));
115
+ ```
116
+
117
+ In a real deployment the server runs on its own (a Worker, or a process of its own) and the client connects with `transport: { url }`. See [`@thomasfosterau/legation-alchemy-cloudflare`](../alchemy-cloudflare) for the Cloudflare Workers topology, [SQL backend](#sql-backend-thomasfosteraulegationsql) below for a database-backed `Backend`, and [`@thomasfosterau/legation-browser`](../browser) for the browser hosts. The fuller examples are [`examples/todo`](../../examples/todo), one node with optimistic writes and offline use, and [`examples/commerce`](../../examples/commerce), the full deployment.
118
+
119
+ ## API
120
+
121
+ Everything below is on the root entry, `@thomasfosterau/legation`.
122
+
123
+ | Export | What it is |
124
+ | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
125
+ | `Node` | A resource type. `Node.make(type, { attributes, edges, filterable, sortable })` defines one; `Node.registry([...])` closes and validates a graph. |
126
+ | `Edge` | A relationship between nodes: `Edge.one` (a foreign key on this node), `Edge.many` (the dependent collection, named by its `inverse`) and `Edge.paginated` (a cursor-windowed collection with a fixed `order`). |
127
+ | `Collection` | The named set of one node type that a client materializes. `Node.registry` derives it from the graph. |
128
+ | `Query` | The read. `Query.make({ collection, where, include, order, first, after })` declares a read; `Query.named(name, args)` declares a server-resolved named query. |
129
+ | `Aggregate` | Read projections over a query: `Aggregate.count`, `Aggregate.sum(field)`, `min`, `max` and `avg`. |
130
+ | `Mutation` | The write. `Mutation.make(name, schema)` declares a named intent; `Mutation.registry` pairs each with its optimistic `local` handler. (`Mutator` is a deprecated alias.) |
131
+ | `Client` | The one-per-thread object. `Client.make` owns an engine and returns `{ client, engine, store }`, so engine-level tools such as devtools attach to what it built; `Client.connect` joins one hosted elsewhere, such as a service worker. It offers `query`, `observe`, `subscribe`, `mutate`, `mutateWithAck`, `mutationStates` and `reset`. |
132
+ | `ClientStore` | Durable replica and journal storage. `makeMemory` is the default; `makeKeyValue` and `layerKeyValue` wrap any key-value store. The browser package supplies IndexedDB. |
133
+ | `Transport` | The carrier seam: the `ClientTransport` and `ServerHandler` services, plus `makeInMemory` (loopback), `simulateOffline` and `withFallback`. |
134
+ | `Identity` | The identity boundary. A durable replica belongs to one resolved identity; `Client.make({ identity })` and `client.reset(fingerprint)` enforce it. |
135
+ | `Hydration` | Serialisable snapshots of a client's durable state (`dehydrate`, `hydrate`, `snapshot`), for server rendering and hydration. |
136
+ | `Server` | One per backend. `Server.make({ backend, auth })` gives the protocol `handler` to mount on a carrier, `broadcast` announces out-of-band changes, and `mutate` runs a named Mutation as one of a client group's mutations (a form action's entry point, ADR-0030) and answers its receipt. |
137
+ | `Backend` | The storage contract you implement: apply mutations transactionally, evaluate a subset, and list permanent rejections (`rejections`, which a pull reports so a lost push response still settles the mutation as rejected, #365). The `Backend` service is what `Server.make` consumes. |
138
+ | `MemoryBackend` | The reference in-memory backend, for tests and prototypes: `make`, `layer`, `handler` and `validatedHandlers`. |
139
+ | `Authorization` | Deny-by-default read filters: `policy` builds them per collection (`allowAll` opts a collection in), and `IdentityResolver` and `SyncMiddleware` hook the request path. |
140
+ | `FilterPolicy` | The filterable and sortable declarations a node makes, which `Authorization` enforces on client-supplied `where` and `order`. |
141
+ | `Predicate` | The filter algebra (`eq`, `isIn`, `and`, `or`, `not`, `exists` and others). It is the full form of `Query`'s `where` and the form auth filters take. |
142
+ | Errors | Typed errors, including `StoreError`, `TransportError`, `QueryError`, `MutationRejected` and `AuthorizationError`. |
143
+
144
+ ### Adapter authors: `@thomasfosterau/legation/internal`
145
+
146
+ The engine seams live on a second entry point, `@thomasfosterau/legation/internal`: `ClientEngine`, `ClientView`, `Subset`, `Mirror`, `EngineHost`, `MirrorChannel`, `MirrorProtocol`, `SyncServer`, `Cvr`, `InvalidationBus`, `HintSource`, `Undo` and the protocol and transport codecs. The sibling packages (`@thomasfosterau/legation-browser`, `@thomasfosterau/legation-jsonapi`, `@thomasfosterau/legation-alchemy-cloudflare`) compose from it. It is not the app-facing API, and it moves faster than the root entry.
147
+
148
+ ```ts
149
+ import { ClientEngine, Subset } from "@thomasfosterau/legation/internal";
150
+
151
+ // Adapter code is typed against the seams, not the app-facing surface.
152
+ export type Engine = ClientEngine.ClientEngine;
153
+ export type Plan = Subset.Subset;
154
+ ```
155
+
156
+ ## SQL backend: `@thomasfosterau/legation/sql`
157
+
158
+ A driver-agnostic SQL reference backend, on its own entry point so an app
159
+ without a database never loads it. It needs only `effect`; the driver is yours.
160
+
161
+ - **`Sql`** — a pure translator from a `Subset` predicate to a parameterized
162
+ `WHERE` clause (and a full `SELECT`), with SQLite (`?`) and PostgreSQL (`$n`)
163
+ dialects.
164
+ - **`SqlBackend`** — an implementation of the core `Backend` contract over a
165
+ tiny `SqlExecutor` seam (`query` / `run` / `transaction`, and the optional
166
+ `transactional` flag, which an executor without transactions sets to
167
+ `false`). Bring your own
168
+ driver (`better-sqlite3`, `effect/sql`, `pg`, …); the backend supplies
169
+ subset evaluation, row fetching, and the transactional, idempotent,
170
+ gap-checked mutation-id ledger.
171
+ - **`PgExecutor`** — the `SqlExecutor` for PostgreSQL, over the `SqlClient`
172
+ service from `effect/sql` (the driver, such as `@effect/sql-pg`, is yours to
173
+ provide). See [PostgreSQL](#postgresql) below.
174
+ - **`PollingHintSource`** — a tier-2 change-data-capture hint source over the
175
+ same `SqlExecutor` seam: it polls each table on an interval for a cheap
176
+ change-detector (`COUNT(*)` plus `MAX(versionColumn)`) and pokes subscribers
177
+ when a collection changes out of band (cron, admin tools, `psql`). See the
178
+ [change-data-capture guide](../../docs/change-data-capture.md).
179
+
180
+ Reads push the predicate to the database via `WHERE` and finalise
181
+ order/limit/cursor locally with `Subset.apply`. The `WHERE` is exact on both
182
+ dialects (ADR-0028): every node compiles to the three-valued expression
183
+ `Predicate.evaluate` computes. A table mapping's `kinds` declare which columns
184
+ are text, instants, numbers or booleans: a column declared `text` gets an
185
+ explicit code-point collation (`COLLATE "C"` / `COLLATE BINARY`) on every
186
+ comparison, literals bind by the declared kind, and booleans are stored as
187
+ `0`/`1` on SQLite and decoded back by the backend. Writes run app-authored
188
+ handlers inside one transaction, and each mutation's `lastMutationId` is
189
+ claimed before its handler runs. Mutations from different client groups are
190
+ serialisable: SQLite serialises its writers, and PostgreSQL runs each
191
+ transaction at `SERIALIZABLE` (a handler whose reads went stale is refused
192
+ with a serialization failure, which the client retries). An executor without
193
+ transactions sets `transactional: false` and bumps after the handler instead
194
+ (D1), and D1 does not isolate handlers, so it does not meet that guarantee.
195
+ A permanent rejection is remembered in the client's ledger row, and a replayed
196
+ copy reports it. A copy that a concurrent call committed first is dropped
197
+ differently by dialect: on SQLite it lists its rejection directly, and on
198
+ PostgreSQL it is refused with a transient `BackendError`, and its retry lists it.
199
+
200
+ > **PostgreSQL apps must pass `dialect: Sql.postgres` to `SqlBackend.make`.**
201
+ > That dialect is what sets `SERIALIZABLE` on each mutation transaction. Leave it
202
+ > out and the backend falls back to SQLite's dialect, which runs no isolation
203
+ > statement and raises no error for that. Batches then run at PostgreSQL's default
204
+ > READ COMMITTED, and a lost update across client groups goes unreported.
205
+ > A hand-written dialect without a `serializable` statement also gets no
206
+ > isolation statement, so its executor must provide the isolation itself. The
207
+ > executor's `transaction` must also open a top-level transaction, not a savepoint
208
+ > nested in another: PostgreSQL refuses `SET TRANSACTION` there, and the batch
209
+ > fails with a `BackendError` instead of running weaker.
210
+
211
+ ### PostgreSQL
212
+
213
+ `PgExecutor.make(sql)` turns a PostgreSQL `SqlClient` into the executor
214
+ `SqlBackend` runs over. It needs only `effect`, so the package gains no
215
+ dependency; install a driver that provides `SqlClient`, such as
216
+ `@effect/sql-pg`, and pass `dialect: Sql.postgres`:
217
+
218
+ ```ts
219
+ import * as Effect from "effect/Effect";
220
+ import { SqlClient } from "effect/sql/SqlClient";
221
+ import { PgExecutor, Sql, SqlBackend } from "@thomasfosterau/legation/sql";
222
+
223
+ const makeBackend = Effect.gen(function* () {
224
+ const sql = yield* SqlClient; // provided by PgClient.layer({ url }) from @effect/sql-pg
225
+ return SqlBackend.make({
226
+ executor: PgExecutor.make(sql),
227
+ dialect: Sql.postgres,
228
+ tables,
229
+ mutators,
230
+ handlers,
231
+ });
232
+ });
233
+ ```
234
+
235
+ What the executor guarantees:
236
+
237
+ - **Each push is one transaction.** `transaction` is the client's
238
+ `withTransaction`: one `BEGIN … COMMIT` on one connection, so a batch's
239
+ mutation-id claims commit together with its handlers' writes, and a failure
240
+ rolls back both. `Sql.postgres` runs the transaction at `SERIALIZABLE`; a
241
+ handler whose reads went stale is refused (SQLSTATE 40001) with a
242
+ `BackendError`, and the client retries. Don't run the backend inside an outer
243
+ `withTransaction`: that makes the batch a savepoint, where PostgreSQL refuses
244
+ `SET TRANSACTION`, and the push fails.
245
+ - **Statements run unprepared.** Nothing depends on a named prepared statement
246
+ surviving from one query to the next, which a transaction-mode pooler does
247
+ not promise.
248
+ - **Round trips.** A push costs one transaction, however many mutations it
249
+ holds. `evaluate` costs one statement per subset, plus one per `exists` node
250
+ in its predicate. A pull goes through `evaluateMany`, which asks for all of
251
+ its subsets in one `UNION ALL` statement, plus one per `exists` node, however
252
+ many subsets it holds
253
+ ([#505](https://github.com/thomasfosterau/legation/issues/505)).
254
+ - **Rows as JSON in a pull.** Each arm of `evaluateMany`'s statement carries
255
+ its row as JSON (`row_to_json`), since mappings' columns differ, and the
256
+ backend orders, windows and versions the rows it reads back from that JSON.
257
+ **Keep integer, double and text values in typed columns (`integer`,
258
+ `double precision`, `text`), not in JSON or wide numeric types:** those are
259
+ the types JSON carries exactly as the driver reads them, so a pull over them
260
+ gives the same answer as `evaluate` in one statement.
261
+ - A number JSON cannot carry exactly (an integer past ±(2^53 − 1), such as
262
+ a wide `bigint` id or version; a `numeric` with more digits than a double
263
+ holds; such a number inside a `json` / `jsonb` column) sends the pull to
264
+ one `evaluate` per subset, so it reads each value as the driver does, and
265
+ the backend logs a warning, once per collection and column for the
266
+ backend's life, whose cause is a `SqlBackend.InexactJsonNumber` naming the
267
+ collection and the column (never the value). A scalar `bigint` or
268
+ `numeric` column then arrives exactly, as the driver's text. A number
269
+ nested in a `json` / `jsonb` column does not: the driver parses that
270
+ column with `JSON.parse` too, so `evaluate` rounds it the same way, and
271
+ the fallback only keeps the pull equal to `evaluate`. Keep such values in
272
+ typed columns, or as JSON strings, if they must be read exactly. Declare
273
+ a scalar column with a typed column in the read relation (a view can cast
274
+ it) to keep the pull one statement.
275
+ - Other types the driver does not read as JSON does (`bigint` and `numeric`
276
+ within a double's range, which the driver reads as text; `timestamptz`,
277
+ which it reads as a `Date`) reach the backend as JSON gives them, so a
278
+ `versionColumn` or ordered column of those types gives the pull versions
279
+ or an order of a different type than `evaluate` and `getRows`. Cast them
280
+ to the typed columns above in the read relation.
281
+ - SQLite has no such caveat: its `UNION ALL` columns take any type per row,
282
+ so each arm carries the relation's own columns by position.
283
+ - **Errors.** A failed statement or transaction fails as a `BackendError` with
284
+ the statement's text, never its parameters, in the message, and the driver's
285
+ `SqlError` as its `cause`.
286
+
287
+ #### Behind a pooler (Hyperdrive)
288
+
289
+ The atomicity above holds only if every statement from `BEGIN` to `COMMIT`
290
+ reaches the same server connection. A pooler that runs a transaction on one
291
+ connection keeps it; one that can split a transaction across connections breaks
292
+ Backend guarantee 1, as D1 does (see `D1Executor`). In a Worker, build the
293
+ `PgClient` layer per request, inside the request's scope, from the binding's
294
+ connection string: a Worker cannot reuse one request's connection in another.
295
+ What Hyperdrive guarantees about transactions, isolation statements and query
296
+ caching is still to be confirmed against its documentation:
297
+ [#506](https://github.com/thomasfosterau/legation/issues/506).
298
+
299
+ ### Ledger without the `rejections` column
300
+
301
+ **Action required before upgrading.** `SqlBackend` remembers each permanent
302
+ rejection in the client's ledger row, in a `rejections` column, so a replayed
303
+ copy of a rejected mutation reports the committed rejection and its reason. A
304
+ ledger created without that column fails each push with a `BackendError` that
305
+ names this migration, and that push writes nothing. Whatever version created your
306
+ ledger table (`sync_mutations`, or the `mutationsTable` you configured), check
307
+ whether it has the column, and add it only if it does not:
308
+
309
+ ```sql
310
+ -- PostgreSQL: returns a row only when the column is already there
311
+ SELECT 1 FROM information_schema.columns
312
+ WHERE table_schema = current_schema()
313
+ AND table_name = 'sync_mutations' AND column_name = 'rejections';
314
+
315
+ -- SQLite: returns a row only when the column is already there
316
+ SELECT 1 FROM pragma_table_info('sync_mutations') WHERE name = 'rejections';
317
+ ```
318
+
319
+ If the check returns no row, add the column:
320
+
321
+ ```sql
322
+ ALTER TABLE sync_mutations ADD COLUMN rejections TEXT;
323
+ ```
324
+
325
+ On PostgreSQL, `ALTER TABLE sync_mutations ADD COLUMN IF NOT EXISTS rejections TEXT;`
326
+ does the check and the change in one statement, and is safe to repeat. SQLite has
327
+ no `IF NOT EXISTS` for columns, so run the check first: the plain `ALTER TABLE`
328
+ fails with "duplicate column" on a ledger that already has the column.
329
+
330
+ The column is nullable and has no default, so existing rows are unchanged. It is
331
+ a metadata-only change on both databases, with a brief lock on the ledger table.
332
+
333
+ A new install creates the ledger with the column:
334
+
335
+ ```sql
336
+ CREATE TABLE sync_mutations (
337
+ client_group_id TEXT NOT NULL, client_id TEXT NOT NULL, last_mutation_id INTEGER NOT NULL,
338
+ rejections TEXT,
339
+ PRIMARY KEY (client_group_id, client_id)
340
+ );
341
+ ```
342
+
343
+ The `rejections` value is a JSON object that maps each remembered mutation id to
344
+ its reason. A push forgets the rejections below its lowest id per client, and a
345
+ client that never pushes again keeps its last push's rejections. A pull reports
346
+ the rejections its watermark covers, so a client whose push response was lost
347
+ settles those mutations as rejected (#365).
348
+
349
+ ## License
350
+
351
+ MIT