@thomasfosterau/legation 0.0.0 → 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/CHANGELOG.md +2054 -0
- package/LICENSE +21 -0
- package/README.md +349 -1
- package/dist/MemoryBackend-BziXwfcs.d.ts +4884 -0
- package/dist/MemoryBackend-BziXwfcs.d.ts.map +1 -0
- package/dist/MemoryBackend-DxP8t4ge.js +9477 -0
- package/dist/MemoryBackend-DxP8t4ge.js.map +1 -0
- package/dist/index-CPPfp0F4.d.ts +399 -0
- package/dist/index-CPPfp0F4.d.ts.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/internal-CxXRlFK9.d.ts +2362 -0
- package/dist/internal-CxXRlFK9.d.ts.map +1 -0
- package/dist/internal-JyoQMlfK.js +3758 -0
- package/dist/internal-JyoQMlfK.js.map +1 -0
- package/dist/internal.d.ts +3 -0
- package/dist/internal.js +3 -0
- package/dist/rolldown-runtime-D8jQpDG7.js +12 -0
- package/dist/sql/index.d.ts +294 -0
- package/dist/sql/index.d.ts.map +1 -0
- package/dist/sql/index.js +969 -0
- package/dist/sql/index.js.map +1 -0
- package/dist/src-FWXpu_vP.js +1082 -0
- package/dist/src-FWXpu_vP.js.map +1 -0
- package/package.json +61 -3
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
1
|
# @thomasfosterau/legation
|
|
2
2
|
|
|
3
|
-
|
|
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
|