@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/CHANGELOG.md
ADDED
|
@@ -0,0 +1,2054 @@
|
|
|
1
|
+
# @thomasfosterau/legation
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 2a1f63c: Add `Aggregate`: serialisable aggregate descriptors (`Count`, `Sum`, `Min`,
|
|
8
|
+
`Max`, `Avg`) over a subset's rows, as plain data plus an Effect `Schema` (so a
|
|
9
|
+
future server tier can carry them). `compute` / `computeGrouped` evaluate them
|
|
10
|
+
purely over already-filtered/sorted rows — `Sum` of an empty set is `0`, `Avg`
|
|
11
|
+
of an empty set is `undefined`, `Min`/`Max` are `undefined` when there are no
|
|
12
|
+
numeric values, and non-numeric field values are skipped.
|
|
13
|
+
|
|
14
|
+
The `ClientEngine` grows `queryAggregate` / `queryAggregateGrouped` (one-shot
|
|
15
|
+
over the current local view) and `observeAggregate` / `observeAggregateGrouped`
|
|
16
|
+
(live, re-emitting only when the value changes), with a matching `aggregate` /
|
|
17
|
+
`aggregateGrouped` atom surface in `EngineAtoms` and an `aggregate` /
|
|
18
|
+
`aggregateGrouped` store in `@thomasfosterau/legation-svelte`.
|
|
19
|
+
|
|
20
|
+
These are local-tier aggregates: they fold the rows a client has already
|
|
21
|
+
synced. A server-evaluated tier that folds the whole collection is explicit
|
|
22
|
+
future work.
|
|
23
|
+
|
|
24
|
+
- ef59fa7: A durable pull now writes once, and an interrupted pull is never recorded. The
|
|
25
|
+
key-value seam gains an optional `AtomicKeyValue` service on
|
|
26
|
+
`@thomasfosterau/legation/internal`: an all-or-nothing `apply` over
|
|
27
|
+
`set` and `remove` operations. When a host supplies it, `ClientStore` commits a
|
|
28
|
+
pull's rows, cookie, journal trim and unpulled-tail release as one batch, with
|
|
29
|
+
no release record, and a push acknowledgement's park and trim as one batch. A
|
|
30
|
+
store without the service keeps the release-record path from #387, unchanged,
|
|
31
|
+
and an open still finishes a leftover record either way.
|
|
32
|
+
|
|
33
|
+
`@thomasfosterau/legation-browser` supplies the service over IndexedDB: one
|
|
34
|
+
`readwrite` transaction on the database and object store the platform
|
|
35
|
+
`KeyValueStore` already uses, so existing databases need no migration. The
|
|
36
|
+
database is opened without a version, so one a later version created still
|
|
37
|
+
opens; the browser store's `KeyValueStore` opens it the same way, in place of
|
|
38
|
+
the platform's, which pins version 1 and fails on a newer database.
|
|
39
|
+
|
|
40
|
+
If another connection upgrades that database, or deletes it, the store's
|
|
41
|
+
connections close, since they would otherwise block the change. From then on
|
|
42
|
+
every call fails with "the IndexedDB database was upgraded by another
|
|
43
|
+
connection; reload to continue", and the store does not reopen itself. A
|
|
44
|
+
database without the `entries` store fails its calls with a message that names
|
|
45
|
+
that store, and a sealed store's message now carries the reason from its cause.
|
|
46
|
+
|
|
47
|
+
- d09dc65: `BackendContract.verifyBackendContract` checks that `getRows` reflects a backend's own writes to rows a listing has cached (rule 12, #444). The rule lists a collection so it caches a row, applies an update through the backend, then checks that `getRows` returns the post-write value without a re-list. It then applies a delete and checks that `getRows` omits the row. A backend that caches listed rows and does not bring its cache into line with its own writes now fails `verifyBackendContract`, where it passed before. `MemoryBackend`, `SqlBackend` (SQLite and PostgreSQL) and the atomic `JsonApiBackend` pass the rule unchanged. The rule uses the existing `putRow` and `delRow` contract mutators and the contract's evaluation collection, so a backend registers nothing new. A third-party backend that caches rows must update or evict its cache when it applies its own writes.
|
|
48
|
+
- db2366a: `BackendContract.verifyBackendContract` gains two optional options, `restoredLedger` and `restoredPeerLedger`, for a run over a backend restarted on a ledger that already acknowledges the contract's client. They carry the ledger ids the restart expects for the contract client and for rule 11's peer group. The contract first checks that the backend reports exactly the restored id, and fails with a rule that names a forgotten or differing ledger when it does not. It then numbers every rule's mutations from that id. A backend that forgets its ledger on restart now fails the contract when the run passes `restoredLedger`.
|
|
49
|
+
|
|
50
|
+
Without the options the run expects a fresh client group, as before: every rule and its expectations are unchanged. Two checks are new on that path: the contract client must report 0 before the first rule runs, and rule 11's peer group must report 0 before it parks its handler. A backend that reports a non-zero ledger for either group on a fresh group now fails with the matching rule.
|
|
51
|
+
|
|
52
|
+
- 97db489: Backend guarantee 1 now states that mutations from different client groups are serialisable (#353): a handler's reads see a state equivalent to some serial order of the committed mutations, so no committed write is lost to another group's concurrent commit of a row it read.
|
|
53
|
+
|
|
54
|
+
`MemoryBackend` meets this. Every row a handler reads from committed state is recorded with its version, and the commit re-checks those versions inside the same state update that applies the writes. If another group changed a row since the read, nothing is written and the handler runs again on the new state. Overlapping calls for one client group behave as before (#346, #355). A handler can therefore run more than once, so its effects outside its writer must be safe to repeat.
|
|
55
|
+
|
|
56
|
+
**Action required for custom `Backend` implementations:** `applyMutations` must meet guarantee 1, by a store-wide lock, a commit that re-checks each row the handler read and runs the handler again when one changed, or serialisable isolation on a database. A backend that does not meet it does not conform. The `BackendHandler` contract also now allows a handler to run more than once, so handler effects outside the writer must be safe to repeat. A handler still stale after 16 runs fails with a transient `BackendError` and writes nothing, so the client's retry takes over.
|
|
57
|
+
|
|
58
|
+
- cc67999: Add `Backend.transaction(steps)`. It builds a backend's `applyMutations` from its storage steps (`begin`, `open`, `run`, `commit`, `release`, `finish`, `atomically` — `Backend.withoutTransaction` for a store without one — and `maxAttempts`), and owns the mutation-id ledger rules: in-order application, idempotent skip, gap rejection, the bump travelling with each outcome (a permanent rejection commits no effects), and reporting a replay's committed rejection. `MemoryBackend` and `SqlBackend` now apply mutations through it, with no change in behaviour. A hand-assembled backend still type-checks, and `verifyBackendContract` still checks every backend (ADR-0009's #177 amendment).
|
|
59
|
+
- a94df19: Bound every in-process message hub so a stalled or slow consumer can no longer
|
|
60
|
+
grow a queue without limit (#44). Because pull is the correctness path, each hub
|
|
61
|
+
sheds load under pressure without threatening convergence:
|
|
62
|
+
|
|
63
|
+
- **Poke hub** (`Transport.makePokeHub`): now `sliding(1)` per client group with
|
|
64
|
+
replay 1. A poke storm collapses to one queued poke per group — an outstanding
|
|
65
|
+
poke absorbs the rest — and a late/reconnecting subscriber still sees the most
|
|
66
|
+
recent poke.
|
|
67
|
+
- **Invalidation bus** (`InvalidationBus.makeInProcess` and the memory broker):
|
|
68
|
+
now bounded with a `dropping` strategy at a configurable capacity. A dropped
|
|
69
|
+
hint only delays the next pull (ADR-0009); drops are counted in
|
|
70
|
+
`legation_hints_dropped_total`. New `makeInProcessWith` / `layerInProcessWith`
|
|
71
|
+
/ `makeMemoryBrokerWith` take a `capacity` option.
|
|
72
|
+
- **Engine change hub** (`ClientEngine`): now bounded `dropping` at a
|
|
73
|
+
configurable capacity (`ClientEngineOptions.changeHubCapacity`, default 1024).
|
|
74
|
+
Each view-change carries a monotonic sequence number; a consumer that sees a
|
|
75
|
+
seq gap collapses it into a full `Rebuild` that subsumes the dropped `Patch`es,
|
|
76
|
+
so the view stays correct under pressure and behaves identically under none.
|
|
77
|
+
Collapses are counted in `legation_change_collapses_total`.
|
|
78
|
+
- **Mirror change hub** (`TabMirror`): now bounded `dropping` at a configurable
|
|
79
|
+
capacity (`TabMirrorOptions.changeHubCapacity`, default 1024). A seq gap makes
|
|
80
|
+
the observer re-read its subset from the mirror's `Ref` (a full refresh,
|
|
81
|
+
lossless because the `Ref` holds the latest rows), counted in
|
|
82
|
+
`legation_mirror_refreshes_total`.
|
|
83
|
+
|
|
84
|
+
A `Patch` is never silently dropped: it is only ever shed behind a seq gap that
|
|
85
|
+
forces a `Rebuild` before the view is next read.
|
|
86
|
+
|
|
87
|
+
- 6f53fb1: Change data capture for out-of-band writes (ADR-0019). Writes that bypass the
|
|
88
|
+
mutators — a cron job, an admin tool, another service, `psql` — can now poke
|
|
89
|
+
subscribed clients through a new `HintSource` seam.
|
|
90
|
+
|
|
91
|
+
`@thomasfosterau/legation` gains `HintSource`: a small, dependency-free seam that
|
|
92
|
+
publishes `ChangeHint`s to the `InvalidationBus` on a scoped fibre alongside the
|
|
93
|
+
SyncServer. `HintSource.fromStream` lifts a stream of hint batches and
|
|
94
|
+
`HintSource.run` forks a source for the caller's scope. A source that publishes
|
|
95
|
+
value-less hints is matched at the collection level by the existing fan-out
|
|
96
|
+
matcher — sound, because over-matching a poke only costs an empty pull.
|
|
97
|
+
|
|
98
|
+
`@thomasfosterau/legation/sql` gains `PollingHintSource`: a tier-2 polling source over the
|
|
99
|
+
existing `SqlExecutor` seam (no new dependency). It polls each table on an
|
|
100
|
+
interval for a cheap change-detector — `COUNT(*)` plus `MAX(versionColumn)` when
|
|
101
|
+
a version column is mapped — and pokes a collection whose detector moved.
|
|
102
|
+
Bounded staleness equals the interval; without a monotonic version column it
|
|
103
|
+
detects only row-count changes, so map a `versionColumn` (or `updated_at`) for
|
|
104
|
+
full coverage.
|
|
105
|
+
|
|
106
|
+
Tiers shipped: tier 1 (app publishes `InvalidationBus.publish`) documented as
|
|
107
|
+
the baseline, tier 2 (polling) shipped here. Tier 3 (Postgres `LISTEN`/`NOTIFY`)
|
|
108
|
+
ships as a documented recipe — bring your own pg client — and tier 4 (logical
|
|
109
|
+
replication) is rejected (ADR-0019, citing ADR-0009). See
|
|
110
|
+
`docs/change-data-capture.md`.
|
|
111
|
+
|
|
112
|
+
- d675d2e: A push never takes the acknowledged-but-unpulled tail past its bound, and no overlay entry is released early. A push that carries more than half the bound goes out in chunks of half the bound, with a pull before each chunk after the first, and each chunk takes only the room the tail leaves. Overlapping pushes share that room, so the bound holds across them. A push whose tail has no room pulls first, and fails if that pull fails or releases no room. The cost is extra round trips for a large flush, and a push that waits on the pulls between chunks. A pull between chunks that fails or is refused fails the push with a transient `TransportError`, and a chunk that fails fails the push with its own error; the unsent mutations stay journaled for the next push.
|
|
113
|
+
|
|
114
|
+
This removes the overflow release, and with it the optional `ClientStore.commitTailRelease` member and its release-record path. Pulls still release the tail through `releaseUnpulled` and the pull's release record. Delete the member: a literal typed as `ClientStore` that still sets it fails to compile (excess property), and one that spreads it has it ignored. That is a breaking change for the interface, so this is a minor bump before 1.0.
|
|
115
|
+
|
|
116
|
+
- 1c56914: The Client facade (ADR-0022, program step 4): the one-per-thread object
|
|
117
|
+
that runs many Query reads and mutations, with where a read is answered
|
|
118
|
+
being configuration the UI never sees. `Client.make` owns the engine
|
|
119
|
+
(store + transport from context; the sync loop starts automatically —
|
|
120
|
+
the forgotten-runSync bug class ends here); `Client.connect` joins an
|
|
121
|
+
engine hosted elsewhere (service worker, Electron main, a daemon) over a
|
|
122
|
+
`MirrorChannel`. Observing subscribes: live reads register their windows
|
|
123
|
+
for the stream's lifetime through a per-window ledger (one voice per
|
|
124
|
+
window, however many observers), `subscribe` pins a read beyond
|
|
125
|
+
observers, and includes resolve through the view's auto-subscribing
|
|
126
|
+
relational read with the plan's edge windows applied. `Client.fromView`
|
|
127
|
+
builds the facade over any `ClientView`. Additive; the
|
|
128
|
+
server-fallthrough tier is a later step.
|
|
129
|
+
- 615e792: `Client.make` gains sensible defaults with full extensibility. Zero wiring works: `Client.make({ nodes, mutations })` is a purely local Client — in-memory store, offline transport (reads and optimistic writes work, the journal retains every intent), sync loop off. `transport: { url }` builds the standard remote setup (sync RPCs over WebSocket with HTTP fallback, via the new `RpcTransport.fromOrigin`); `store`/`transport` accept full implementations; a `ClientStore`/`ClientTransport` in context is still honoured (options win over context, context over defaults), so existing layer-composed apps are unchanged. `clientId`/`clientGroupId` default to random ids — pass stable ones alongside a persistent store. Also adds `Transport.offline`, and `Client.make` no longer _requires_ the store/transport services in its environment type.
|
|
130
|
+
- c8d9afc: Breaking: `Client.make` now returns `{ client, engine, store }` (`Client.Made`) instead of the Client alone (#142). The engine and store are the ones the Client runs over, so an app can attach devtools (`Devtools.make(engine, store, …)`) without building a `ClientEngine` by hand. Migrate with `const { client } = yield* Client.make(…)`. `Client.connect` is unchanged: a Client joined to a hosted engine owns neither. The package is 0.x, so this is a minor.
|
|
131
|
+
- 4709f41: One client surface over engine and mirror (ADR-0021): core gains
|
|
132
|
+
`ClientView` — the narrow surface component bindings consume (query/observe,
|
|
133
|
+
subscribe/unsubscribe, mutate/mutateWithAck with awaited-outcome semantics) —
|
|
134
|
+
with `fromEngine` and a `layer` deriving it from the in-context engine.
|
|
135
|
+
`TabMirror` satisfies it structurally, aggregates derive over any view's
|
|
136
|
+
`observe` (`Aggregate.observe`/`observeGrouped`), and `Related`'s read side
|
|
137
|
+
takes a `ClientView.Reader`, so joins and aggregates work over mirrors
|
|
138
|
+
unchanged.
|
|
139
|
+
|
|
140
|
+
For `@thomasfosterau/legation-svelte`, the seam is what lets the same components run over a
|
|
141
|
+
main-thread engine and a service-worker-hosted mirror with zero component
|
|
142
|
+
changes — the Client the hooks read from context is built over either.
|
|
143
|
+
|
|
144
|
+
- f0348b4: `BackendContract.verifyBackendContract` probes cross-group lost updates (guarantee 1, #353). A new rule 11 parks a handler in one client group after it reads a counter, lets another group increment the same row and commit, then resumes the parked handler. Both increments must land, so the counter must read 2. A backend whose handler read is not validated at commit writes the stale count over the other group's commit and fails the rule. A backend that serialises the two (a lock, or serialisable isolation) passes, whether it orders them or refuses one with a transient `BackendError`, which the harness retries. A backend whose peer call waits behind the parked one passes too: the rule releases the parked handler after a bounded number of scheduler turns, so it does not deadlock.
|
|
145
|
+
|
|
146
|
+
The probe runs through a new contract mutator, `BackendContract.countUp` (`contract/count-up`), with args `{ collection, id, gate }`. Its handler reads the counter, calls `BackendContract.park(gate)` between the read and the write, then writes the counter plus one. `park` is a no-op unless the harness has registered `gate`, so a registered handler that never calls it fails rule 11: the probe then has nothing to test. `park` is exported from `@thomasfosterau/legation/internal`, and the gate is named by a string in the args, so the mutation stays serialisable.
|
|
147
|
+
|
|
148
|
+
**Action required:** backend authors who run `verifyBackendContract` must register `BackendContract.countUp`, with a handler that reads, calls `park(gate)`, then writes. An unregistered mutator fails rule 11. `BackendContract.memoryHandlers` already includes it. A non-transactional executor (D1-style) does not meet guarantee 1 (#342), so it is not run through this contract.
|
|
149
|
+
|
|
150
|
+
- a053736: `BackendContract.verifyBackendContract` probes write-then-`BackendError` (#343).
|
|
151
|
+
A new contract mutator, `BackendContract.writeThenFail` (`contract/write-then-fail`),
|
|
152
|
+
writes a row and then fails transiently. The new rule 10 checks that the row keeps
|
|
153
|
+
its value and version, no new row appears, and `lastMutationId` is not bumped, so
|
|
154
|
+
the client's retry applies the mutation again. Guarantee 1 (`Backend.ts`) requires
|
|
155
|
+
this, and `failAlways` does not probe it because it writes nothing first.
|
|
156
|
+
|
|
157
|
+
Visibility before the bump is documented in `BackendContract` as a per-backend
|
|
158
|
+
obligation, not a generic rule: the harness cannot observe a backend mid-mutation.
|
|
159
|
+
|
|
160
|
+
The unknown-mutator `BackendError` that `MemoryBackend`, `SqlBackend` and
|
|
161
|
+
`JsonApiBackend` raise now carries a typed cause, built by `UnknownMutator.make`
|
|
162
|
+
(`@thomasfosterau/legation/internal`). Rule 10 and rule 4 (`contract/fail`, #382)
|
|
163
|
+
recognise an unregistered mutator by that cause, so a backend that words the message
|
|
164
|
+
differently is still caught. The exact message `Unknown mutator: <name>` stays as a
|
|
165
|
+
fallback for a third-party backend that does not use the helper yet. Rule 4 now also
|
|
166
|
+
needs the probe to fail with a `BackendError`: a `failAlways` that dies with a defect
|
|
167
|
+
fails the rule instead of passing it.
|
|
168
|
+
|
|
169
|
+
**Action required:** backend authors who run `verifyBackendContract` (imported from
|
|
170
|
+
`@thomasfosterau/legation/internal`) must register `BackendContract.failAlways`
|
|
171
|
+
and `BackendContract.writeThenFail`. The second is a handler that writes a row and then
|
|
172
|
+
fails with `BackendError`, called with args `{ collection, id }`. An unregistered mutator
|
|
173
|
+
fails rule 4 or rule 10. `BackendContract.memoryHandlers` already includes both. A
|
|
174
|
+
non-transactional executor (D1-style) fails rule 10 by design, since each statement
|
|
175
|
+
commits on its own (#342), so it is not run through this contract.
|
|
176
|
+
|
|
177
|
+
- 8a8adfc: Add `BusBrokers.makeBroadcastChannelBroker`: a concrete `BusBroker` over a
|
|
178
|
+
`BroadcastChannel` for same-machine cross-context fan-out (browser tabs, Node
|
|
179
|
+
worker threads), joining the existing Redis Streams and WebSocket/Durable-Object
|
|
180
|
+
brokers. It honors the bus contract's self-delivery (a `BroadcastChannel` omits
|
|
181
|
+
the sender, so `publish` also offers to this broker's own subscribers).
|
|
182
|
+
- 87fa7a5: Add `LiveView`: a modular, incrementally-maintained materialized view — a
|
|
183
|
+
subset's result (filtered, sorted, windowed) kept current by row deltas instead
|
|
184
|
+
of re-scanning the collection. `put`/`del` place or remove a single row at its
|
|
185
|
+
sorted position and the visible page is sliced from the maintained order, so a
|
|
186
|
+
change outside the page never re-sorts it and pagination boundaries are handled
|
|
187
|
+
(an insert pushes a row out of the page; a delete pulls the next one in).
|
|
188
|
+
|
|
189
|
+
`ClientEngine.observe` now maintains a `LiveView` per query: on the read path
|
|
190
|
+
(empty journal) it applies the pull's per-row patch deltas incrementally and
|
|
191
|
+
drops re-emits when the page didn't move; it falls back to a full rebuild only
|
|
192
|
+
when the optimistic overlay shifts. `Subset` gains reusable `comparator`,
|
|
193
|
+
`sortFiltered`, and `window` helpers (with `apply` composed from them).
|
|
194
|
+
|
|
195
|
+
- 822001c: Add **named queries** (`Query`): a read defined once and resolved twice, like a
|
|
196
|
+
mutator. The client resolves a name + args to a `Subset` it renders locally; the
|
|
197
|
+
server resolves the same name + args to the _authoritative_ subset it syncs,
|
|
198
|
+
with the request's identity in hand — so query shaping and permission move to
|
|
199
|
+
the server (it can narrow, scope to a tenant, or add a filter the client can't
|
|
200
|
+
override) while the client still renders from the rows it was sent.
|
|
201
|
+
|
|
202
|
+
- `Query.make` + `Query.registry` (client subset factories) + `Query.resolver` /
|
|
203
|
+
`Query.QueryResolver` service (server, context-aware, args-validated).
|
|
204
|
+
- `PullRequest` gains an optional `namedSubscriptions` field; `SyncServer`
|
|
205
|
+
resolves each via the `QueryResolver`, then authorizes and evaluates it
|
|
206
|
+
identically to a client-authored subscription.
|
|
207
|
+
- `ClientEngine` gains `subscribeQuery` / `unsubscribeQuery` / `queryNamed` /
|
|
208
|
+
`observeNamed` and an optional `queries` registry. Backward compatible —
|
|
209
|
+
engines without named queries are unchanged.
|
|
210
|
+
|
|
211
|
+
- 4c59860: Add `observeChanges(subset, options?)`: a public row-level change feed
|
|
212
|
+
(`RowChange`, #115) on `ClientEngine`, `ClientView`, and `Mirror` alike
|
|
213
|
+
(ADR-0021), for keyed-list bindings, side-effect subscriptions, and delta
|
|
214
|
+
pipelines that today have to diff `observe()`'s pages by hand. Batched per
|
|
215
|
+
view-step:
|
|
216
|
+
|
|
217
|
+
- `enter`/`exit` are anchored to the subset's windowed result — a predicate
|
|
218
|
+
or limit-boundary crossing (an insert pushing the last visible row out, a
|
|
219
|
+
delete pulling the next one in) produces the pair a consumer diffing two
|
|
220
|
+
pages would derive; `update` marks a same-membership value change.
|
|
221
|
+
- A `Rebuild` (the optimistic overlay shifted) or a seq-gap collapse (a
|
|
222
|
+
bounded change hub dropped one or more changes) emits a `reset` marker
|
|
223
|
+
followed by fresh `enter`s for the current window, rather than a diff
|
|
224
|
+
against unknown prior state.
|
|
225
|
+
- `{ includeInitial: true }` emits the current window as `enter`s in the
|
|
226
|
+
first batch, with the same subscribe-before-snapshot guard `observe`
|
|
227
|
+
already uses (#84) — a late subscriber sees exactly one batch, no gap or
|
|
228
|
+
duplicate row.
|
|
229
|
+
|
|
230
|
+
`ClientEngine` derives batches from the same incrementally-maintained
|
|
231
|
+
`LiveView` window `observe` already keeps (surfaced, not recomputed);
|
|
232
|
+
`Mirror` — which has no local `LiveView` — diffs the whole-page updates its
|
|
233
|
+
host already broadcasts, so both hosts emit identical batches for identical
|
|
234
|
+
page transitions. `@thomasfosterau/legation-browser`'s `ElectedHost` switches an observer
|
|
235
|
+
onto whichever `ClientView` (mirror or owned engine) is currently active, so
|
|
236
|
+
`observeChanges` works unchanged across a leader-election transition too.
|
|
237
|
+
|
|
238
|
+
A confirmed-row pull and a plain local mutation (#109's delta-maintained
|
|
239
|
+
overlay tracks a mutator's local handler directly and publishes exactly what
|
|
240
|
+
it did) both diff precisely; `Rebuild` is reserved for cases the overlay
|
|
241
|
+
genuinely can't diff — a pull racing a still-pending mutation, an unknown
|
|
242
|
+
mutator, a reset (`Clear`) patch — or a seq-gap collapse.
|
|
243
|
+
|
|
244
|
+
Move events are not yet part of the contract: `LiveView` rows carry a stable
|
|
245
|
+
position (#112) at runtime, but a row repositioned by a neighbor's
|
|
246
|
+
insert/delete without its own value changing isn't distinguished from
|
|
247
|
+
"unchanged" here — a consumer that needs order re-derives it from a paired
|
|
248
|
+
`observe()` or the position of `enter`s within a batch, pending that
|
|
249
|
+
follow-up. See `docs/observing-row-changes.md` for the framework-binding
|
|
250
|
+
guide (a Svelte 5 keyed-list example) and the `runOnChanges`-style
|
|
251
|
+
side-effect pattern.
|
|
252
|
+
|
|
253
|
+
- 961e604: Delta-maintain the optimistic overlay instead of rebuilding it on every
|
|
254
|
+
mutate. `mutate` now runs the mutator's local handler through a tracked
|
|
255
|
+
writer that patches the cached overlay in place and publishes exactly the
|
|
256
|
+
`put`/`del` ops it performed, so observers fold the delta the same way
|
|
257
|
+
`LiveView` already folds confirmed-row patches — no journal replay, and no
|
|
258
|
+
full re-materialization, per mutation. A pull with pending mutations no
|
|
259
|
+
longer forces a global `Rebuild`: rebase now scopes to the union of the
|
|
260
|
+
patch's collections and whatever the pending-before-this-pull mutations
|
|
261
|
+
touch, falling back to a full rebuild only on a reset (`Clear`) or an
|
|
262
|
+
unrecognized mutator. This is the write-path follow-through anticipated by
|
|
263
|
+
the read-path selective re-evaluation work: per-mutation cost is now
|
|
264
|
+
proportional to the rows a mutation touches, not collection size × journal
|
|
265
|
+
length.
|
|
266
|
+
- 5937efe: Add the relational `Predicate.exists` node: a correlated relationship test
|
|
267
|
+
(this row matches if a row exists in another collection whose `where` holds and
|
|
268
|
+
whose `foreignField` equals this row's `localField`). Backends resolve it to an
|
|
269
|
+
allow-set with `Predicate.resolveExists` before evaluation — MemoryBackend over
|
|
270
|
+
its rows, the SQL backend via a `SELECT DISTINCT` subquery (the `Sql` translator
|
|
271
|
+
also compiles `Exists` to a correlated `IN (SELECT …)`). This closes the deeper
|
|
272
|
+
parts of the relational-query and relationship-scoped-auth gaps: server-side
|
|
273
|
+
joins and "visible if you're a member of the project" auth **without
|
|
274
|
+
denormalizing** the join key onto the row. The JSON:API backend can't resolve
|
|
275
|
+
correlated joins over REST and fails closed on `Exists`.
|
|
276
|
+
- 08ac9cd: Relationship-scoped read authorization. Add an `IdentityResolver` seam: an
|
|
277
|
+
optional service `(clientGroupId) => Effect<identity>` that `SyncServer` uses to
|
|
278
|
+
populate `SyncContext.identity` on every push and pull, so auth filters and
|
|
279
|
+
middleware see a real, server-resolved principal (completing the auth-token
|
|
280
|
+
story). With identity in hand, relationship-scoped visibility ("an issue is
|
|
281
|
+
visible if you're a member of its project") is expressed as an allow-set
|
|
282
|
+
predicate: the auth filter resolves the principal's permitted keys and returns
|
|
283
|
+
`Predicate.isIn(key, allowed)` over a denormalized key on the row. ADR-0015
|
|
284
|
+
documents the pattern and the deferred join-without-denormalization seam.
|
|
285
|
+
- 1fc3725: Add relationship declarations to collections and a protocol-agnostic `Related`
|
|
286
|
+
read over the mirror. A collection can now declare `one`/`many` relationships by
|
|
287
|
+
field mapping (`localField`/`foreignField`); `Collection.registry` rejects a
|
|
288
|
+
duplicate relationship name within a collection and a relationship pointing at an
|
|
289
|
+
unknown collection at build time. The new core `Related` module resolves and
|
|
290
|
+
incrementally maintains those joins over any `ClientEngine` mirror —
|
|
291
|
+
`query`/`observe` (a write to a related row re-hydrates without re-resolving the
|
|
292
|
+
base) and `related` (auto-subscribes the narrowed `foreignField IN (...)` foreign
|
|
293
|
+
subsets and tears them down with the scope). The incremental-join plumbing is
|
|
294
|
+
exposed as a strategy seam (`queryWith`/`observeWith`/`relatedWith` plus
|
|
295
|
+
`fieldMappingStrategy`), and the JSON:API `Relationships` module now delegates to
|
|
296
|
+
it with a linkage-based strategy — no behaviour change for existing users.
|
|
297
|
+
`Related.resolveExistsLocally` renders `Predicate.exists` on the client by
|
|
298
|
+
rewriting each node to an `In` over the synced foreign collection.
|
|
299
|
+
- 8995115: `RowChange` (#115) gains `move` and a `position` field on `enter`/`update`
|
|
300
|
+
(#133 item 2, #112's follow-through): `LiveView`'s stable fractional-index
|
|
301
|
+
position, present whenever the source can compute one. A row whose value is
|
|
302
|
+
unchanged but whose neighbors shifted enough to move it — a sibling
|
|
303
|
+
entering/leaving the window re-ranks it — now emits `move` (`position` +
|
|
304
|
+
`previousPosition`) instead of being silently dropped as "unchanged," so a
|
|
305
|
+
keyed-list consumer can place `enter`s and re-sort on `move` rather than
|
|
306
|
+
only appending.
|
|
307
|
+
|
|
308
|
+
`ClientEngine.observeChanges` (backed by `LiveView`'s incrementally-
|
|
309
|
+
maintained window) emits `position`/`move`; `Mirror.observeChanges` does
|
|
310
|
+
not — a mirror has no maintained window of its own, only whole-page
|
|
311
|
+
broadcasts to diff, so it can't derive a stable position from a snapshot
|
|
312
|
+
alone. Documented, not silent (`docs/observing-row-changes.md`), which also
|
|
313
|
+
gains an updated Svelte 5 keyed-list example ordering by `position`.
|
|
314
|
+
|
|
315
|
+
Purely additive: existing `enter`/`update`/`exit`/`reset` consumers that
|
|
316
|
+
don't read `position` are unaffected.
|
|
317
|
+
|
|
318
|
+
- 8a8adfc: Live queries re-evaluate selectively: a change event now names the collections
|
|
319
|
+
it touched, so an observer re-runs only when its collection changed, instead of
|
|
320
|
+
on every local change. Pulls publish collection-scoped changes when the journal
|
|
321
|
+
is empty; overlay-shifting events (a mutation, push, or pull with pending
|
|
322
|
+
mutations) still re-evaluate everywhere. A first step toward incremental view
|
|
323
|
+
maintenance — full per-row delta maintenance remains a follow-up.
|
|
324
|
+
- 025fabb: Add **`SpecExport`**: emit OpenAPI 3.1 and AsyncAPI 3.0 descriptions of an app's
|
|
325
|
+
sync surface so bespoke clients can be generated in any language (ADR-0017). The
|
|
326
|
+
schema-defined protocol (push/pull/poke) is value-agnostic by design, so a
|
|
327
|
+
generic spec would describe an opaque envelope; `SpecExport` instead
|
|
328
|
+
**specializes** it from the app's own definitions — a `Mutation` variant per
|
|
329
|
+
mutator (its `args` schema), a patch `Put` per collection (its row schema), a
|
|
330
|
+
named subscription per query — so a generated client gets concrete, per-name
|
|
331
|
+
types instead of `any`.
|
|
332
|
+
|
|
333
|
+
The pipeline is two steps so new formats are cheap: `build(api)` produces a
|
|
334
|
+
format-neutral `SyncSpec` (a shared JSON-Schema component pool plus each
|
|
335
|
+
message's component name), and `toOpenApi` / `toAsyncApi` (or your own `toX`)
|
|
336
|
+
project it. `openApi(api)` / `asyncApi(api)` do both in one call. OpenAPI emits
|
|
337
|
+
push/pull as the logical `POST {path}/push` and `POST {path}/pull`; AsyncAPI adds
|
|
338
|
+
the streamed poke channel. The emitted schemas are the encoded (wire) form, and
|
|
339
|
+
the documents are plain JSON-serializable values. Needs nothing beyond `effect`,
|
|
340
|
+
so it lives in core (ADR-0006).
|
|
341
|
+
|
|
342
|
+
- 0b8fccb: Add `Subscriptions`: subscription lifecycle on top of `ClientEngine` —
|
|
343
|
+
reference-counted `acquire`/`release`, a TTL grace period before an unused
|
|
344
|
+
subscription is dropped (default 5 minutes), and `preload`/`preloadQuery` to
|
|
345
|
+
pin a subscription for the session. Releasing keeps the rows for the grace
|
|
346
|
+
window so a quick re-subscribe reuses them; if the grace elapses the engine
|
|
347
|
+
unsubscribes and the next pull drops the rows. This is the read-side lifecycle
|
|
348
|
+
Zero expresses as query `ttl` and `preload()`, built on named queries or raw
|
|
349
|
+
subsets.
|
|
350
|
+
- e7369e1: Add Effect-native observability (`Telemetry`): `legation_*` metrics and
|
|
351
|
+
`legation.*` spans wired into the engine's hot paths — client push/pull,
|
|
352
|
+
server push/pull, and fan-out. Counters (pulls, pushes, mutations pushed,
|
|
353
|
+
server pulls, pokes) and a patch-size histogram surface through whatever
|
|
354
|
+
`MetricRegistry`/`Tracer` exporter the host installs (OpenTelemetry,
|
|
355
|
+
Prometheus, …) with no extra dependency. Closes the observability gap from the
|
|
356
|
+
Zero feature-parity audit.
|
|
357
|
+
- 8995115: Engine-level reactive window retargeting (#133 item 3): `ClientEngine.
|
|
358
|
+
observeWindow(subset)` returns a `WindowObservation` (`rows` + `retarget`)
|
|
359
|
+
whose `retarget({ limit?, cursor? })` widens or advances the SAME open
|
|
360
|
+
observation's window in place — reusing `LiveView`'s maintained order
|
|
361
|
+
(`LiveView.retarget`, #112) instead of tearing the subscription down and
|
|
362
|
+
rebuilding it from scratch — and keeps the engine's tracked subscription in
|
|
363
|
+
step, so the next `pull` fetches the wider window from the server too.
|
|
364
|
+
`ClientView.observeWindow` exposes it over the `ClientView` seam for an
|
|
365
|
+
engine-backed view (present only there — a `Mirror` has no maintained
|
|
366
|
+
window of its own to retarget). `Subscriptions.acquireWindow` composes the
|
|
367
|
+
usual refcounted subscribe/release with it for callers that want lifecycle
|
|
368
|
+
management alongside retargeting.
|
|
369
|
+
|
|
370
|
+
`@thomasfosterau/legation-svelte`'s `useConnection` (`hooks.svelte.ts`) now retargets
|
|
371
|
+
through this seam for the common case (no `include`d edges, an engine-backed
|
|
372
|
+
view): `loadMore`/`reset` widen/shrink the same open observation instead of
|
|
373
|
+
letting `pages` force a resubscribe. A joined read, or a mirror-backed
|
|
374
|
+
connection, falls back to the original resubscribe-per-page behavior,
|
|
375
|
+
unchanged. No public API changes — `useConnection`'s signature and observed
|
|
376
|
+
behavior (growing window, `hasMore`, `reset`) are the same; only the
|
|
377
|
+
underlying re-registration is gone.
|
|
378
|
+
|
|
379
|
+
- c856d66: A covered read is judged and answered from one snapshot of its root rows, so a
|
|
380
|
+
pull between the coverage judgment and the answer can no longer serve a root row
|
|
381
|
+
whose edge windows are not pulled yet (#426). `Client.query` and the façade read
|
|
382
|
+
the root once. `Client.coverageOf` takes an optional `{ snapshot }` (the root rows
|
|
383
|
+
already read, with their subset) that the verdict is judged over for that subset;
|
|
384
|
+
`Query.runRows` answers a read from rows already read. A mirror that predates
|
|
385
|
+
`PulledRegistrations` still judges coverage from its registrations alone, so the
|
|
386
|
+
#422 race persists for it until it is upgraded.
|
|
387
|
+
- 01a8c64: Coverage by conjunct subsumption with strict windows (ADR-0029 section 3, issue #162). `Client.query` no longer requires an exact subset-hash match to answer from the replica: a read is served locally when some subscription in the Client's ledger covers it. A subscription covers a read when it is on the same collection; its window is absent (neither limit nor cursor), or the read asks for a prefix of the identical window (same order, same cursor, and a limit at most the subscription's when it has one); and, canonicalised to sets of top-level conjuncts, the subscription's set is a subset of the read's (an unwindowed subscription) or the same set (a windowed one, which holds only a window of its own predicate's rows). Every `include`d edge must be among the subscription's edge selects, with a paginated edge's `first` at most the subscription's. `Or` and `Not` subtrees compare structurally as whole conjuncts; nothing is inferred through them, and full implication is not built.
|
|
388
|
+
|
|
389
|
+
Behaviour change for the mirror and the owned engine alike: more reads are answered locally. A `Client.query` for a filtered page under an unwindowed subscription of the collection now stays local instead of falling through; under a windowed subscription (the commerce shell's `{ collection: "products", order: ["id"], first: 100 }`) a narrower read still falls through, because the strict window never covers a different predicate, a different sort, a different cursor or an unwindowed read. An explicit pin registers only its root window, so a read with `include` is not covered by it until #159 registers a pin's edge windows. Explicit pins are now counted, so two pins of one root release one at a time. The rule lives in `Coverage` (`@thomasfosterau/legation/internal`), and `Client.coverageOf(read)` reports which subscription covers a read, for devtools.
|
|
390
|
+
|
|
391
|
+
- 3362c37: Add `CrudMutation`: the graph-derived CRUD mutations (ADR-0030 §5). For every
|
|
392
|
+
node, `{type}/create`, `{type}/update` and `{type}/delete` with args in the
|
|
393
|
+
graph row form (`{ type, id, attributes }`, `one` edges as their `via` keys)
|
|
394
|
+
and the client handler that writes them; `update` is a no-op when the row is
|
|
395
|
+
absent, so a patch behind a rejected create leaves no phantom row.
|
|
396
|
+
`CrudMutation.entries(nodes, { writes })` spreads into `Mutation.registry`.
|
|
397
|
+
- 62dcb1c: `Hydration.dehydrate` now reads the store as one state. It used to read the
|
|
398
|
+
confirmed rows, the cookie, the journal and the unpulled tail in four separate
|
|
399
|
+
calls, so a pull or a push acknowledgement that landed between them could leave
|
|
400
|
+
a snapshot whose rows lacked an acknowledged mutation's effect, while that
|
|
401
|
+
mutation sat in neither the journal nor the tail. `ClientStore` gains an
|
|
402
|
+
optional `snapshot` member that reads all four together: under the store's lock
|
|
403
|
+
for the memory and key-value stores, and in one read transaction on IndexedDB.
|
|
404
|
+
`dehydrate` uses it when the store offers it and reads member by member
|
|
405
|
+
otherwise, so a third-party store behaves as before.
|
|
406
|
+
|
|
407
|
+
The `AtomicKeyValue` seam on `@thomasfosterau/legation/internal` gains an
|
|
408
|
+
optional `readAll`, which the browser's IndexedDB binding supplies as one
|
|
409
|
+
`readonly` transaction over the entries store. A key-value store with that seam
|
|
410
|
+
decodes its snapshot from that single read.
|
|
411
|
+
|
|
412
|
+
- be4be06: Add `Derive`: client-local **derived live queries** as bare, composable
|
|
413
|
+
combinators over observed sources — `filter` (arbitrary JS predicate),
|
|
414
|
+
`map`/`select`, `join` (arbitrary key equality, inner + left), `groupBy`
|
|
415
|
+
returning grouped rows, `distinct`, and `orderedWindow` (orderBy + limit). This
|
|
416
|
+
is the client-side expressiveness tier (#114): the arbitrary joins, DISTINCT,
|
|
417
|
+
row-level GROUP BY, and computed/cross-field predicates the serializable
|
|
418
|
+
`Subset`/`Predicate` wire language deliberately does not carry, done in memory
|
|
419
|
+
over already-synced rows.
|
|
420
|
+
|
|
421
|
+
Each combinator takes any source satisfying the `ClientView.observe` shape — an
|
|
422
|
+
owned engine, a mirror, or **another combinator**, so queries compose from
|
|
423
|
+
queries — and is maintained **incrementally** from keyed row deltas by pure
|
|
424
|
+
engines (`Incremental`), never refolded per change. Per ADR-0027 these are
|
|
425
|
+
#114's combinator engines, not a separate operator core: each mirrors a proven
|
|
426
|
+
seam's technique — `orderedWindow` the `LiveView`/`OrderedTree` order, `join`
|
|
427
|
+
`Related.makeJoinEngine`'s keyed buckets + reverse index, `groupBy`
|
|
428
|
+
`Aggregate`'s running-fold-plus-`byId`. Each carries a delta-in/delta-out
|
|
429
|
+
property test against a naive from-scratch recompute.
|
|
430
|
+
|
|
431
|
+
Results are an Effect `Stream` and, via `Derive.atom`, an Atom on the standard
|
|
432
|
+
reactivity surface, so framework bindings need nothing new. Leaf subscriptions
|
|
433
|
+
compose through the refcounted `Subscriptions` manager (`Derive.observed`) and
|
|
434
|
+
release on scope close, tearing the whole chain down together.
|
|
435
|
+
|
|
436
|
+
**The boundary rule (ADR-0027): derived combinators never widen what syncs.**
|
|
437
|
+
They see only the rows a client already holds; the serializable Query/Subset
|
|
438
|
+
tier alone decides what the server ships. Both `Incremental` and `Derive` live
|
|
439
|
+
behind `@thomasfosterau/legation/internal` (read-layer machinery, no new public noun).
|
|
440
|
+
ADR-0027 (client-local IVM delta model) records the two-tier read model and the
|
|
441
|
+
never-widens-sync rule these consume.
|
|
442
|
+
|
|
443
|
+
- be0a44d: Make the acknowledged-but-unpulled tail durable in `ClientStore` (#336). A push acknowledgement parks the applied mutations in the store, out of the pending journal, so they are never pushed again. Their effects stay overlaid until a pull reports them, and now also across a restart, a hydration snapshot and the BackgroundSync push-only flush. A restart between a push acknowledgement and the next pull no longer shows the confirmed rows without the mutation.
|
|
444
|
+
|
|
445
|
+
- **Pull release.** A pull records its whole release in the store before it writes its rows: the watermark, the cookie and the patch. An open that finds the record finishes the pull (it re-applies the patch, which is idempotent, writes the cookie, trims the journal and releases the tail), so a crash at any point shows the pull's effects, never the rows without them. A release that fails part-way is finished in the same process, including one whose record write reports failure after it landed. If that roll-forward also fails after its read of the record, the store seals itself until it is reopened; a failed read alone fails that call and the next write or pull finishes the record.
|
|
446
|
+
- **Row writes during a release.** While a release is outstanding, `putRow`, `delRow` and `clearRows` finish it first, under the same store lock, and fail if they cannot. A failed read of the record fails only that write and seals nothing. Any failure after that read seals the store until it is reopened, and the next open finishes the release. That includes a failed journal trim with no record outstanding: a parked copy left in both the journal and the tail would be double-counted by reads and pushed again. The engine's `seed`, its `clearRows` and its identity reset hold the overlay lock for their row writes. A hydration snapshot finishes an outstanding release before it writes.
|
|
447
|
+
- **Journal and tail.** `appendMutation` refuses an entry the unpulled tail already holds, with a `StoreError` and nothing written: a journal copy of a parked entry would be double-counted and pushed again. `Hydration.hydrate` skips the snapshot's journal entries the store's tail holds, so a hydration never makes that copy. `beginRelease` finishes an outstanding release before it records its own, so a pull is not refused by a release a failed roll-forward left behind.
|
|
448
|
+
- **Identity reset.** The tail is cleared with the rows it was overlaid on, in the store's open-time reset and in the engine's runtime reset. The store drops any release record with it.
|
|
449
|
+
- **Trim.** A push whose journal trim fails leaves the parked entries out of the journal in the same process, as an open does, so they are not pushed again or replayed twice.
|
|
450
|
+
- **Hydration.** `Hydration.Snapshot` gains an optional `unpulled` field. `dehydrate` writes it when the tail is not empty; `hydrate` parks it. A snapshot without the field loads with an empty tail.
|
|
451
|
+
- **Engine option.** `ClientEngine.make` takes an internal `unpulledLimit` (default 1024): the most acknowledged-but-unpulled mutations the overlay keeps (#286); a push never takes the tail past it (#429). Tests lower it to reach the bound quickly.
|
|
452
|
+
- **IndexedDB.** The tail uses the existing key-value layout, so there is no new object store or index and the database version does not change.
|
|
453
|
+
|
|
454
|
+
Action required: a custom `ClientStore` must implement six new members: `unpulled`, `parkUnpulled`, `beginRelease`, `releaseUnpulled`, `finishRelease` and `clearUnpulled`. `ClientStore.makeMemory`, `ClientStore.makeKeyValue` and `IndexedDbClientStore` already do. `beginRelease` finishes an outstanding release first, whichever engine on the store began it, and fails when that finish or its read of the record fails. `finishRelease` rolls an outstanding release forward from the record `beginRelease` stored, and a store that cannot do so must fail its later calls, as a sealed store does. `beginRelease` may be a no-op only for a store that cannot survive a restart. In a store that does survive a restart, a no-op `beginRelease` double-counts after a crash mid-release, rather than reverting. Implementing the tail members as no-ops in such a store silently reverts the overlay after a restart: the tail is lost and the confirmed rows lack the acknowledged mutations until the next pull.
|
|
455
|
+
|
|
456
|
+
- e818dc4: Server-side cache eviction from committed writes (#206). `InvalidationSink` (on `@thomasfosterau/legation/internal`) subscribes a sink to the `InvalidationBus` and passes it the collections and rows each hint batch touched, on a fibre of its own, so an eviction can neither fail nor delay the write. `EdgeInvalidation` in `@thomasfosterau/legation-alchemy-cloudflare` provides the sinks: KV deletes of `{collection}:{id}`, Workers cache purges by tag, and `forward` / `receive` to carry an invalidation from the API Worker to the front-door Worker that holds the cache over a service binding's RPC method. Failures, including a purge that resolves `{ success: false }`, are logged. The `collection:{name}` / `collection:{name}:{id}` tag spelling moves to `HttpCache` (`tagFor`, `fineTagFor`, `evictionTags`) in the core; `HttpCacheInvalidation.defaultTagFor`, `defaultFineTagFor` and `Touched` in `@thomasfosterau/legation-browser` now refer to it, unchanged.
|
|
457
|
+
- d1b01a4: Tested against `effect` 4.0.0 (and the matching `@effect/*` companions); the
|
|
458
|
+
peer floor is unchanged at `effect >=4.0.0-rc.118`.
|
|
459
|
+
|
|
460
|
+
`@thomasfosterau/legation` adapts to 4.0.0's stricter, type-only `Schema.brand`:
|
|
461
|
+
|
|
462
|
+
- `Node.Id` keeps its signature and runtime behaviour; its internal brand
|
|
463
|
+
identifier is cast to satisfy the new concrete-identifier check.
|
|
464
|
+
- `Node.AnyNode`'s `Id` property is now
|
|
465
|
+
`Schema.Codec<string & Brand.Brand<string>, string>` instead of `Id<string>`.
|
|
466
|
+
4.0.0's `brand<String, B>` is type-only and no longer assignable to a wider
|
|
467
|
+
``brand<String, `${string}/id`>``, so no concrete `Node<"todos", …>` was
|
|
468
|
+
assignable to `AnyNode`. Every concrete node is assignable again, and `Id`
|
|
469
|
+
is still a string-encoded branded id schema; only code that reads `.Id` off
|
|
470
|
+
an `AnyNode`-typed value sees the wider brand.
|
|
471
|
+
|
|
472
|
+
Checked the 4.0.0 behavioural changes against the packages and found no impact:
|
|
473
|
+
no use of `partition`/`separate`/`Option.partitionMap`, `Queue.State`,
|
|
474
|
+
`MessageStorage` or `RpcMessage.ExitEncoded`. The RPC client's missed-pong
|
|
475
|
+
handling now fails in-flight WebSocket calls rather than reporting a transient
|
|
476
|
+
error; `RpcTransport.fromOrigin` already treats a failed WebSocket call as a
|
|
477
|
+
`TransportError` and falls back to HTTP. The RPC server's WebSocket route now
|
|
478
|
+
closes with 1000/1001/1011, which nothing in the packages inspects.
|
|
479
|
+
|
|
480
|
+
- 5496ddf: Bump the pinned Effect version from `4.0.0-beta.84` to `4.0.0-beta.107` across
|
|
481
|
+
all packages (catalog and exact `peerDependencies`), and move
|
|
482
|
+
`@thomasfosterau/legation-jsonapi` and the commerce example from
|
|
483
|
+
`@thomasfosterau/effect-jsonapi@^0.6.0` to `^0.12.0` (#152).
|
|
484
|
+
|
|
485
|
+
**Why beta.107.** effect-jsonapi 0.12 requires `effect >= 4.0.0-beta.104` and
|
|
486
|
+
develops against `4.0.0-beta.107` exactly, so that is the version its typings
|
|
487
|
+
are known to line up with. The later `4.0.0-rc.*` releases were not attempted
|
|
488
|
+
in this change: the exact-pin policy (ADR-0003) prefers the version the
|
|
489
|
+
adopted library is built against, and every `effect/unstable/*` module this
|
|
490
|
+
repo uses (`httpapi`, `http`, `rpc`, `reactivity`, `persistence`, `sql`)
|
|
491
|
+
compiles and tests clean on beta.107.
|
|
492
|
+
|
|
493
|
+
**Effect API drift absorbed (no behavioural change):**
|
|
494
|
+
|
|
495
|
+
- `Schedule.either` / `Schedule.both` are gone; the default retry policy is now
|
|
496
|
+
`Schedule.max([Schedule.min([exponential, spaced]), recurs(5)])`, which is
|
|
497
|
+
the same policy (`min` recurs while any schedule recurs at the shortest
|
|
498
|
+
delay, `max` recurs while all recur at the longest delay).
|
|
499
|
+
- `Schema.TaggedErrorClass` is now `Schema.TaggedError` (the wire
|
|
500
|
+
`SyncRpcError` is unchanged on the wire).
|
|
501
|
+
- `SchemaRepresentation.fromASTs` is now `SchemaRepresentation.toRepresentations`
|
|
502
|
+
(spec export output is unchanged).
|
|
503
|
+
- `HttpApiGroup.Any` is gone and `HttpApi`'s group parameter is now
|
|
504
|
+
invariant, so `JsonApiSync.layer`, `JsonApiSync.inspect` and
|
|
505
|
+
`Convention.reflectResources` are generic over the concrete `HttpApi`
|
|
506
|
+
instead of accepting a widened alias. The exported `JsonApiSync.JsonApi`
|
|
507
|
+
alias is now `HttpApi.Constraint` (identifier and groups erased); pass your
|
|
508
|
+
concrete api to the functions rather than annotating it with the alias.
|
|
509
|
+
|
|
510
|
+
**effect-jsonapi 0.6 → 0.12 at the ADR-0016 boundary.** `Document`, `Query`,
|
|
511
|
+
`Resource`, `Relationship` and `Endpoint` lost no exports; `Query` gained
|
|
512
|
+
`IncludeOption`/`IncludePathsOf`/`OffsetPageStruct`/`bracketPageKeys` and
|
|
513
|
+
`Resource` gained `IncludeDepth`/`IncludePathsTo`. `schema.ts`, `types.ts` and
|
|
514
|
+
`CollectionLoader.ts` compile unchanged. One typing regression was noticed in
|
|
515
|
+
0.12: endpoints with `:id` params (`Endpoint.get`/`delete`/`update`) declare
|
|
516
|
+
their params as `Schema.Struct<Schema.Struct.Fields & { id }>`, which leaks
|
|
517
|
+
`unknown` into the requirements of the corresponding `HttpApiClient` calls.
|
|
518
|
+
The commerce example narrows this with a small `run` helper in
|
|
519
|
+
`server/client.ts`; nothing in the published packages is affected.
|
|
520
|
+
|
|
521
|
+
The bump is `minor` rather than `patch` because the exact `effect` peer pin
|
|
522
|
+
means every consumer must move `effect` in the same change, and the
|
|
523
|
+
`@thomasfosterau/legation-jsonapi` type signatures above changed shape.
|
|
524
|
+
|
|
525
|
+
- 19eb4b0: Bump the pinned Effect version from `4.0.0-beta.65` to `4.0.0-beta.75` across all packages.
|
|
526
|
+
|
|
527
|
+
`@thomasfosterau/legation-browser` now builds its IndexedDB cache store on
|
|
528
|
+
`@effect/platform-browser` (`BrowserKeyValueStore.layerIndexedDb` + core's
|
|
529
|
+
`KeyValueStoreAdapter`) instead of a hand-rolled IndexedDB wrapper.
|
|
530
|
+
`@effect/platform-browser` is a new peer dependency of the browser package, and
|
|
531
|
+
`IndexedDbCacheStore.make` is now a scoped resource (requires `Scope`); the
|
|
532
|
+
`storeName` option was removed (the underlying object store is managed by
|
|
533
|
+
platform-browser).
|
|
534
|
+
|
|
535
|
+
- 38c3147: Effect-idiomatic API refactor:
|
|
536
|
+
|
|
537
|
+
- Protocol messages (`ClientMessage`, `ServerMessage`, `PushResult`, `WireKeyFilter`), cache
|
|
538
|
+
events (`CacheEvent`), and outbox events (`OutboxEvent`) are now `Data.TaggedEnum`s with
|
|
539
|
+
generated constructors, `$is` guards, and exhaustive `$match` matchers
|
|
540
|
+
(e.g. `Protocol.ClientMessage.Subscribe({...})`). The types are structurally unchanged, so
|
|
541
|
+
existing object-literal construction still compiles.
|
|
542
|
+
- `SyncCacheDefaults` `Context.Reference` for ambient cache timing defaults
|
|
543
|
+
(staleTime / timeToLive / staleWhileRevalidate).
|
|
544
|
+
- The cache's in-flight dedupe map uses `MutableHashMap`; background stale-while-revalidate
|
|
545
|
+
refresh failures are now logged instead of silently swallowed.
|
|
546
|
+
- The WebSocket transports validate incoming frames with `ProtocolSchema` codecs instead of
|
|
547
|
+
unchecked `JSON.parse` casts, and bridge socket events into Effect without `Effect.runFork`
|
|
548
|
+
inside callbacks.
|
|
549
|
+
- All tests use `@effect/vitest` (`it.effect` / `it.live` / `it.layer`, `TestClock`).
|
|
550
|
+
|
|
551
|
+
- fed4634: Peer the Effect v4 release-candidate line through an open floor rather than
|
|
552
|
+
an exact pin (#222), so an app can install `@thomasfosterau/legation-*` beside
|
|
553
|
+
`@thomasfosterau/effect-auth` and `@thomasfosterau/effect-jsonapi` and resolve
|
|
554
|
+
a single copy of `effect`. The published floor is `>=4.0.0-rc.118` (see
|
|
555
|
+
`effect-rc118-floor.md`), on `effect` and on the `@effect/platform-browser` /
|
|
556
|
+
`@effect/platform-node` peers.
|
|
557
|
+
|
|
558
|
+
**`@thomasfosterau/legation-jsonapi`**: a repeated shorthand filter key
|
|
559
|
+
(`?filter[name]=a&filter[name]=b`) is read as the comma list it spells
|
|
560
|
+
(`filter[name]=a,b`, an `In` predicate) rather than rejected with a 400,
|
|
561
|
+
because `@thomasfosterau/effect-jsonapi` accepts the repeated-key spelling at
|
|
562
|
+
every list-valued position.
|
|
563
|
+
|
|
564
|
+
The packages run in browsers and workers and deliberately declare no `engines`
|
|
565
|
+
(ADR-0003).
|
|
566
|
+
|
|
567
|
+
- fcc52eb: Target Effect `4.0.0-rc.118`: every package peers `effect >=4.0.0-rc.118`
|
|
568
|
+
(#261), and the `@effect/platform-browser` / `@effect/platform-node` peers use
|
|
569
|
+
the same floor. Install `effect` and every `@effect/*` companion at
|
|
570
|
+
`4.0.0-rc.118` or later together.
|
|
571
|
+
|
|
572
|
+
The packages import Effect's modules at their rc.118 paths (`effect/rpc`,
|
|
573
|
+
`effect/http`, `effect/http-api`, …); rc.118 removed the `effect/unstable/*`
|
|
574
|
+
export paths, so the packages don't resolve beside an earlier `effect`.
|
|
575
|
+
|
|
576
|
+
**`@thomasfosterau/legation-jsonapi`** depends on `@thomasfosterau/effect-jsonapi@^0.16.1`,
|
|
577
|
+
the first release that peers `effect >=4.0.0-rc.118`.
|
|
578
|
+
|
|
579
|
+
**`@thomasfosterau/legation-svelte`** depends on `@thomasfosterau/effect-svelte@^0.4.0`, for
|
|
580
|
+
the same reason.
|
|
581
|
+
|
|
582
|
+
- 52c614f: Batch a pull's subsets (#505). `Backend` gains an optional `evaluateMany(context, subsets)`,
|
|
583
|
+
one entry list per subset, each equal to what `evaluate` gives for it. The sync server's pull
|
|
584
|
+
evaluates its subsets through the new `Backend.evaluateAll`, which calls `evaluateMany` when the
|
|
585
|
+
backend has it and `evaluate` per subset otherwise, so `MemoryBackend`, `JsonApiBackend` and any
|
|
586
|
+
backend of your own work unchanged. `BackendContract` gains rule 14: a backend's `evaluateMany`
|
|
587
|
+
must equal its per-subset `evaluate` over the contract's subsets, the generated corpus included.
|
|
588
|
+
|
|
589
|
+
`SqlBackend` implements `evaluateMany` as one `UNION ALL` statement per pull, plus one per `exists`
|
|
590
|
+
node. `Sql.Dialect` gains the optional `unionRows` member (`Sql.UnionRows`) saying how each arm
|
|
591
|
+
carries its row: `Sql.sqlite` carries the relation's own columns by position (looked up once per read
|
|
592
|
+
relation), so values reach the backend exactly as `evaluate` reads them; `Sql.postgres` carries the
|
|
593
|
+
row as JSON (`row_to_json`), and a pull whose rows hold an integer past 2^53 falls back to
|
|
594
|
+
`evaluate` per subset. A hand-written dialect without `unionRows` keeps one statement per subset.
|
|
595
|
+
See the README for the PostgreSQL column types JSON does not carry as the driver reads them.
|
|
596
|
+
|
|
597
|
+
- 94d6327: Server-fallthrough reads (ADR-0025, ADR-0022 program step 6): a third protocol operation, `read` — a point-in-time batch subset evaluation under the same deny-by-default auth filters as pull, with no CVR involvement. `Client.query` now answers from the replica when the read's root window is in the Client's ledger and falls through to the server otherwise (degrading back to the local answer when the transport offers no `read` or the server is unreachable); results never enter the replica. Includes resolve through the fallthrough in at most two round trips with the same plan and windows as local reads. The hosted tier forwards reads over the mirror protocol (`Read`/`ReadResult`). `ServerHandler.handleRead` is required; `ClientTransport.read` and the new `SyncMiddleware.onRead` hook are optional.
|
|
598
|
+
- 91f07db: ADR-0023's topic-derived fan-out lands as a core seam (internal tier): `FanoutTopics` (the pure topic algebra — coarse `collection` topics plus fine `collection/field=value` topics restricted to the node's derivable set, with write derivation over old and new values), `FanoutBroker` (topic pub/sub with leased registrations; in-process reference implementation), and `FanoutBus` (an `InvalidationBus` over a broker, so `Server.make({ bus })` gets topic-precise cross-room fan-out unchanged). `ChangeHint` gains an additive `oldValues` field — the memory backend supplies it, so an update that moves a row between parents pokes both edge windows and a delete derives from the old row.
|
|
599
|
+
- 6c2c802: Close the sync tier's filter surface with a per-node **filterable set** (ADR-0028
|
|
600
|
+
rule d, #154).
|
|
601
|
+
|
|
602
|
+
- `Node.make` gains `filterable` (per attribute: the operators a client may use
|
|
603
|
+
— `eq ne lt lte gt gte in nin isnull` — optionally with an explicit literal
|
|
604
|
+
kind) and `sortable`. The resolved declaration is on `Node.filterable` /
|
|
605
|
+
`Node.sortable` and travels onto the derived `Collection` like `indexes`, so
|
|
606
|
+
the server enforces it without Node definitions.
|
|
607
|
+
- Defaults: `id` and every `one`-edge `via` field are filterable by `eq` / `in`
|
|
608
|
+
(kind `text`; a declaration naming a key field widens it, never replaces
|
|
609
|
+
it); `sortable` always includes `id`, and `Node.registry` adds every
|
|
610
|
+
paginated edge's `order` fields to its target's `sortable`, since the
|
|
611
|
+
per-parent edge windows sort by them. The literal kind of a declared
|
|
612
|
+
attribute is derived from its schema's encoded form (`String` → `text`,
|
|
613
|
+
`Number` → `double`, `Schema.Int` → `integer`, `Boolean` → `boolean`); an
|
|
614
|
+
`instant` must be declared explicitly. A declaration naming a field the row
|
|
615
|
+
does not carry, or an attribute whose kind cannot be derived, throws a
|
|
616
|
+
`FilterDeclarationError` at definition time.
|
|
617
|
+
- `Authorization.policy(filters, { collections })` carries the declarations;
|
|
618
|
+
`authorize` checks every client-authored `where` / `orderBy` against them
|
|
619
|
+
**before** the auth filter is AND-ed in and before `Backend.evaluate` runs:
|
|
620
|
+
an undeclared field, a declared field with an undeclared operator, a literal
|
|
621
|
+
of the wrong kind, a client-supplied `Exists`, or an unsortable `orderBy`
|
|
622
|
+
fails the pull or read with an `AuthorizationError` naming the collection,
|
|
623
|
+
field and operator. Today's wire forms `Not(In)` and a null-literal
|
|
624
|
+
`Compare` are checked as `nin` and `isnull`. The check runs on the request
|
|
625
|
+
as the client sent it: the auth filter's own predicate, what a
|
|
626
|
+
`SyncMiddleware` rewrites in, and named-query resolver output are
|
|
627
|
+
server-authored and exempt. A policy built without `collections` keeps the
|
|
628
|
+
previous open behaviour.
|
|
629
|
+
- `NodeBridge.fromResource` / `registry` accept `filterable` / `sortable` per
|
|
630
|
+
resource until effect-jsonapi carries the per-attribute annotation.
|
|
631
|
+
|
|
632
|
+
- ebcc6aa: Carry the enhanced form submit across the mirror protocol (#536). `Client.beginFormMutation` (and `/kit`'s `enhanceFormMutation`) now works over `Client.connect`: the mirror asks its host to begin the form mutation, and settles or abandons it on the receipt. The host's engine holds the in-flight entry, so its overlay and unpulled tail serve every mirror, and the effect is still released by the pull that covers the form client, not by the action's response. A mirror that disconnects or is swept abandons the entries it held.
|
|
633
|
+
|
|
634
|
+
`ClientEngine` gains `beginFormMutationEncoded`, the form submit's counterpart of `mutateEncoded`: the host decodes the args the mirror encoded rather than encoding them again, so a Mutation whose args schema transforms (such as `Schema.NumberFromString`) works over a mirror. When the host's store fails to park an acknowledged entry, the engine now still rebuilds its overlay and pulls, and the failure reaches the caller's `settle` as a `StoreError`.
|
|
635
|
+
|
|
636
|
+
Wire format: the begin travels as a `Mutate` naming the reserved mutator `MirrorProtocol.FORM_MUTATION_MUTATOR`, and the new `EndFormMutation` (mirror → host) and `FormMutationBegun` / `FormMutationFailed` / `FormMutationEnded` (host → mirror) messages carry the rest. The form replies name the mirror they answer, since host messages reach every mirror and each numbers its requests from zero. A host that predates this answers the begin as an unknown mutator, and the mirror reports that as a `TransportError` saying the host must be upgraded, instead of waiting for an answer that never comes. No app mutator may use the reserved name.
|
|
637
|
+
|
|
638
|
+
- c105fbf: Drive HTTP-cache invalidation from collection changes (#49).
|
|
639
|
+
|
|
640
|
+
- **`PullCompleted` enrichment** (`@thomasfosterau/legation`): the `PullCompleted`
|
|
641
|
+
`EngineEvent` now carries `collections` — the distinct collections the pulled
|
|
642
|
+
patch touched (a `Clear` stands for every collection the client currently
|
|
643
|
+
holds, since it resets the whole view). The field is always present. This lets
|
|
644
|
+
a collection-scoped reaction fire even on a client with an empty journal (the
|
|
645
|
+
common case, where a pull emits a `Patch` change and no `ViewRebuilt`).
|
|
646
|
+
|
|
647
|
+
- **`HttpCacheInvalidation`** (`@thomasfosterau/legation-browser`): `invalidateOnChanges` forks
|
|
648
|
+
a scoped fibre that evicts every cached response tagged for a changed
|
|
649
|
+
collection via the store's `invalidateTags`; a store error is logged and
|
|
650
|
+
swallowed, never fatal. `collectionChanges` derives the change feed from an
|
|
651
|
+
engine's `engineEvents` (mapping `PullCompleted.collections` and scoped
|
|
652
|
+
`ViewRebuilt.collections`, dropping empties). Wire it in a service worker by
|
|
653
|
+
forking `invalidateOnChanges` alongside `ServiceWorkerHost.run` (recipe in the
|
|
654
|
+
host's doc comment).
|
|
655
|
+
|
|
656
|
+
The tag convention is `collection:{name}` (the default `tagFor`): a response
|
|
657
|
+
tagged `collection:todos` is evicted whenever `todos` changes. This is a
|
|
658
|
+
collection-level granularity ceiling — every response tagged for a changed
|
|
659
|
+
collection is evicted; a per-row `collection:{name}:{id}` convention could
|
|
660
|
+
follow once patch-op row ids are plumbed through to the change feed (not
|
|
661
|
+
implemented here).
|
|
662
|
+
|
|
663
|
+
- e1a028a: Per-row HTTP-cache tags (#207): row-granular cache invalidation, closing the
|
|
664
|
+
collection-level ceiling noted in `.changeset/http-cache-invalidation.md`.
|
|
665
|
+
|
|
666
|
+
- **Change feed carries row ids** (`@thomasfosterau/legation`): the `PullCompleted`
|
|
667
|
+
`EngineEvent` now also carries `rows` — the `(collection, id)` pairs the
|
|
668
|
+
pulled patch's `Put`/`Del` ops touched (a `Clear` contributes to
|
|
669
|
+
`collections` only, since it has no finite row list). Always present,
|
|
670
|
+
possibly empty.
|
|
671
|
+
|
|
672
|
+
- **Fine tag convention** (`@thomasfosterau/legation-browser`): `HttpCacheInvalidation` gains
|
|
673
|
+
`defaultFineTagFor` — `collection:{name}:{id}`, one level finer than the
|
|
674
|
+
existing `collection:{name}` (`defaultTagFor`). `collectionChanges` now
|
|
675
|
+
yields `Touched` (`{ collections, rows }`) and `invalidateOnChanges` evicts
|
|
676
|
+
the union of both tag sets on every change. The coarse eviction is kept
|
|
677
|
+
deliberately even though every touched row's collection is already implied:
|
|
678
|
+
an aggregate document can be staled by a row it does not yet contain, which
|
|
679
|
+
has no fine tag to match.
|
|
680
|
+
|
|
681
|
+
- **Response tagging + budget** (`@thomasfosterau/legation-browser`): `tagsFor(collection,
|
|
682
|
+
rowIds)` builds a response's tags — coarse plus one fine tag per row, up to
|
|
683
|
+
`FINE_TAG_ROW_BUDGET` (43, derived in a comment against the real ceiling on
|
|
684
|
+
the `x-legation-cache-meta` header these tags ride on). Beyond the budget, a
|
|
685
|
+
response is tagged coarse-only with `budgetExceeded: true` on the result.
|
|
686
|
+
`tagsForObserved` pairs the same computation with a logged warning and the
|
|
687
|
+
new `legation_cache_tag_budget_exceeded_total` counter
|
|
688
|
+
(`Telemetry.cacheTagBudgetExceededTotal`), so the fallback is observable
|
|
689
|
+
rather than silent.
|
|
690
|
+
|
|
691
|
+
Both granularities share one tag spelling across the client-side
|
|
692
|
+
invalidation path and response tagging, so the edge/KV invalidation sink
|
|
693
|
+
(#206) can reuse it unchanged.
|
|
694
|
+
|
|
695
|
+
- 9260efb: Carry each client's mutation id counter in a hydration snapshot (#271). Once
|
|
696
|
+
acknowledgements had drained the journal, a `Hydration.Snapshot` held no record
|
|
697
|
+
of the ids the backend had already applied, so a store hydrated from it and used
|
|
698
|
+
under the same `clientId` issued those ids again; the backend skipped them as
|
|
699
|
+
duplicates and the client acknowledged them, losing the writes silently.
|
|
700
|
+
|
|
701
|
+
`Hydration.dehydrate` now writes an optional `counters` field (each client's
|
|
702
|
+
highest issued id, left out when there are none), and `Hydration.hydrate` raises
|
|
703
|
+
the store's counters to it, never lowering one, so a reused `clientId` continues
|
|
704
|
+
above its acknowledged ids. A snapshot without `counters` still loads as before.
|
|
705
|
+
|
|
706
|
+
`ClientStore` (on `./internal`) grows two required members, `mutationCounters`
|
|
707
|
+
and `raiseMutationCounters`, and `ReplicaState` a `counters` field; the memory
|
|
708
|
+
and key-value stores implement them. A custom `ClientStore` must add both.
|
|
709
|
+
|
|
710
|
+
- 0644af5: SSR hydration, engine seeding, and subset prefetch (#41). A client's durable
|
|
711
|
+
state can now cross a serialisation boundary, and the local view can be warmed
|
|
712
|
+
before the first network round-trip.
|
|
713
|
+
|
|
714
|
+
- `Hydration.dehydrate` reads a `ClientStore` into a plain `Snapshot` (confirmed
|
|
715
|
+
rows flattened with their collection, the pull cookie, and the journal);
|
|
716
|
+
`Hydration.hydrate` writes one back, validating it through `Snapshot` on
|
|
717
|
+
the way in so a `dehydrate → JSON → hydrate` round-trip is schema-checked and a
|
|
718
|
+
malformed snapshot fails as a `StoreError`. `SnapshotFromJson` folds the JSON
|
|
719
|
+
step into the decode. The module doc walks the SSR flow (server pulls once,
|
|
720
|
+
`dehydrate`s; client `hydrate`s and continues from the same cookie — no
|
|
721
|
+
refetch) and the seeded-versions caveat.
|
|
722
|
+
- `ClientEngine.seed` writes confirmed rows and reveals them with a single rebuild
|
|
723
|
+
scoped to the seeded collections, so observers render synchronously before any
|
|
724
|
+
transport call. Versions must be genuine backend versions for CVR diffing to
|
|
725
|
+
converge on the next pull; self-invented versions are safe but degrade to the
|
|
726
|
+
reset path.
|
|
727
|
+
- `ClientEngine.ensureSubset` prefetches a subset: it subscribes, and if the
|
|
728
|
+
subset yields no local rows, pulls once. Concurrent calls for the same subset
|
|
729
|
+
dedupe on `Subset.hash`, so a burst of prefetches triggers at most one pull.
|
|
730
|
+
- `ClientStore` gains `listCollections` (the enumeration `dehydrate` walks),
|
|
731
|
+
implemented for both the in-memory and KeyValueStore-backed stores.
|
|
732
|
+
|
|
733
|
+
- 4b05d55: SSR primitives in `Hydration` (ADR-0020): `snapshot` produces a page snapshot
|
|
734
|
+
over a request-scoped engine (in-memory store + in-process transport to the
|
|
735
|
+
in-context `ServerHandler`, subscribe → pull → dehydrate) under the browser's
|
|
736
|
+
client group id, so the snapshot cookie continues that group's CVR
|
|
737
|
+
conversation on the client's first pull; `isEmpty` / `hydrateIfEmpty` gate
|
|
738
|
+
client boot so a stale snapshot never regresses a durable store's cookie or
|
|
739
|
+
journalled offline mutations; `rowsFor` projects a snapshot onto a subset
|
|
740
|
+
with `Subset.apply` — the synchronous initial value for component hooks
|
|
741
|
+
during SSR and first client render.
|
|
742
|
+
- Scope the durable replica to a resolved identity (#212), closing the gap where
|
|
743
|
+
adopting legation would make a multi-tenant app on a shared device _less_ safe
|
|
744
|
+
than what it replaced. The invariant, now in `CONTEXT.md` beside the HTTP-cache
|
|
745
|
+
section: **a durable replica belongs to exactly one resolved identity, and an
|
|
746
|
+
identity change is a reset, not a rebase.**
|
|
747
|
+
|
|
748
|
+
Identity is resolved server-side per pull, which is what makes the auth filter
|
|
749
|
+
trustworthy — but the replica the authorised rows land in outlives the session
|
|
750
|
+
that authorised them. A signs out, B signs in, and B's Client boots onto A's
|
|
751
|
+
rows because they are the freshest thing on the device and nothing recorded
|
|
752
|
+
which identity authorised them. A's unpushed journal made it worse: durable,
|
|
753
|
+
causally ordered, and about to go out under B's cookies.
|
|
754
|
+
|
|
755
|
+
- **`Identity` (`@thomasfosterau/legation`, new)**: `Fingerprint`, the opaque
|
|
756
|
+
app-supplied token a replica is stamped with — a session-subject hash, **never
|
|
757
|
+
PII and never a credential**, as the type's own docs say. `anonymous` is the
|
|
758
|
+
signed-out principal; `unstamped` is a replica that has never been scoped, and
|
|
759
|
+
equals nothing. `transitionOf` is the whole rule, in one place so boot and
|
|
760
|
+
runtime cannot disagree: `Unchanged`, `Upgrade` (anonymous → signed in, the
|
|
761
|
+
one benign transition), or `Reset`.
|
|
762
|
+
|
|
763
|
+
- **`ClientStore` identity boundary**: a new `identity` option stamps the
|
|
764
|
+
fingerprint and compares it on open. A mismatch quarantines the unpushed
|
|
765
|
+
journal under the **superseded** fingerprint, then drops confirmed rows and
|
|
766
|
+
the pull cookie. New surface: `getIdentity` / `setIdentity`,
|
|
767
|
+
`quarantinePending` / `quarantinedMutations` / `quarantines` /
|
|
768
|
+
`releaseQuarantine` / `discardQuarantine`, and `clearCookie`. A boundary that
|
|
769
|
+
cannot be established **seals** the store — every operation fails loudly,
|
|
770
|
+
nothing is served, nothing is destroyed, and the next open retries — rather
|
|
771
|
+
than reporting a boundary that isn't there.
|
|
772
|
+
|
|
773
|
+
- **Quarantine is its own keyspace**, which is the security property rather than
|
|
774
|
+
an implementation detail: `pendingMutations` reads the journal and `push`
|
|
775
|
+
reads `pendingMutations`, so a quarantined entry is structurally unreachable
|
|
776
|
+
from any push path — a background flush included — under any identity but the
|
|
777
|
+
one it is keyed by. Mutation ids are preserved rather than renumbered, so an
|
|
778
|
+
entry that was in flight at reset is at worst acknowledged and skipped by the
|
|
779
|
+
backend's exactly-once ledger, never applied twice. Only
|
|
780
|
+
`discardQuarantine` destroys one: no reset and no boot does.
|
|
781
|
+
|
|
782
|
+
- **`Client.reset(fingerprint)`** applies the same rule at runtime, dropping the
|
|
783
|
+
Client's window ledger along with the rows so coverage cannot answer the new
|
|
784
|
+
principal from the old replica. `Client.make` takes an `identity` and adopts
|
|
785
|
+
it before the sync loop starts. `identity`, `quarantines`,
|
|
786
|
+
`quarantinedMutations` and `discardQuarantine` round out the surface; a
|
|
787
|
+
Client built with `connect` says the host owns the replica rather than faking
|
|
788
|
+
a reset.
|
|
789
|
+
|
|
790
|
+
- **The sync client id is scoped to the principal** while the boundary is on
|
|
791
|
+
(derived from the configured `clientId` plus the fingerprint). Mutation ids
|
|
792
|
+
are a per-client sequence the backend requires to be contiguous, so a second
|
|
793
|
+
principal continuing the first's numbering would push a gap and restarting it
|
|
794
|
+
would collide with ids already applied; scoping gives each principal its own
|
|
795
|
+
sequence, and gives a returning one back exactly the sequence they left.
|
|
796
|
+
|
|
797
|
+
- **`IndexedDbClientStore` (`@thomasfosterau/legation-browser`)** takes an `identity`, and
|
|
798
|
+
`layerNamespaced` offers the per-identity replica as the alternative to
|
|
799
|
+
resetting — namespace for a device with a few regular users who switch back
|
|
800
|
+
and forth, reset where sign-ins are one-off or unbounded.
|
|
801
|
+
|
|
802
|
+
Opt-in: with no `identity` configured, nothing is stamped, nothing is compared,
|
|
803
|
+
and behaviour is unchanged. Turning it on resets an unstamped replica once,
|
|
804
|
+
which is the safe direction — an unstamped replica is exactly the state this
|
|
805
|
+
boundary exists to distrust.
|
|
806
|
+
|
|
807
|
+
- 82911cc: `observeAggregate` / `observeAggregateGrouped` now maintain their fold
|
|
808
|
+
incrementally from row-level deltas instead of re-scanning the subset on
|
|
809
|
+
every emission (#110). `Count`/`Sum`/`Avg` update in O(1) per row delta
|
|
810
|
+
(`Avg` as a running `{sum, count}`); `Min`/`Max` update in O(1) except when
|
|
811
|
+
the row removed or updated away held the current extremum, in which case
|
|
812
|
+
only the surviving values are refolded. Grouped aggregates touch only a
|
|
813
|
+
changed row's old and new group — every other group's value is left
|
|
814
|
+
untouched, both in cost and by reference.
|
|
815
|
+
|
|
816
|
+
This applies to unwindowed subsets (no `limit`/`cursor` — the common case:
|
|
817
|
+
aggregating over everything a client has synced) whenever a `Patch` view
|
|
818
|
+
change is available — a confirmed pull's per-row deltas, or (now that #109
|
|
819
|
+
has landed) a `mutate`'s optimistic overlay deltas. A windowed subset, or a
|
|
820
|
+
change too large to diff safely (`Rebuild` — resets, an unrecognized
|
|
821
|
+
mutator, seq-gap collapses), still refolds from scratch, as before. Public
|
|
822
|
+
API and dedup guarantees ("streams do not re-emit when a change leaves the
|
|
823
|
+
aggregate unchanged") are unchanged.
|
|
824
|
+
|
|
825
|
+
A benchmark (`bun run bench`) exercises three live aggregates over a 10k-row
|
|
826
|
+
collection under single-row churn.
|
|
827
|
+
|
|
828
|
+
- 7b21d11: Keep a secondary index in step with the confirmed-row writes that bypass a pull.
|
|
829
|
+
|
|
830
|
+
- `reset` (the identity `Reset`) now drops the built indexes along with the rows it clears. An indexed query after a reset could previously consult an index that still named the dropped rows.
|
|
831
|
+
- `Hydration.hydrate` into a store a running `ClientEngine` is built over now tells that engine to re-read: it drops its built indexes and its materialized view, and reveals the result. The re-read runs even when a write fails partway, and it reaches an engine attached while the hydrate was writing. It reaches only engines built over that exact store object. Before, an indexed query could return a row whose indexed field the hydrate had changed, while the row stayed present.
|
|
832
|
+
- A new `ClientEngine.clearRows` (on the `/internal` seam) drops every confirmed row, then the view and the built indexes with them. Use it on a running engine instead of `ClientStore.clearRows`, which left the engine showing the cleared rows.
|
|
833
|
+
- The re-read behind `hydrate` is `ClientEngine.resyncAttachedEngines` on the `/internal` seam. It is internal: app code calls `Hydration.hydrate`, not this.
|
|
834
|
+
|
|
835
|
+
- 6f54de8: Add engine and server introspection (#45).
|
|
836
|
+
|
|
837
|
+
- **Unified `EngineEvent` stream**: `ClientEngine.engineEvents` emits the whole
|
|
838
|
+
sync lifecycle from a single internal hub — `MutationJournaled` /
|
|
839
|
+
`MutationAcknowledged` / `MutationRejected` (the #36 events), `PushStarted` /
|
|
840
|
+
`PushCompleted`, `PullStarted` / `PullCompleted`, `PokeReceived`, and
|
|
841
|
+
`ViewRebuilt` (carrying the scoped collections). `mutationEvents` (#36) is now
|
|
842
|
+
a filtered projection of this one hub, so there is a single source of truth;
|
|
843
|
+
its behaviour is unchanged. The hub stays unbounded for the reason #44 kept
|
|
844
|
+
the mutation hub unbounded — lifecycle events are low-volume, consumer-driven,
|
|
845
|
+
and lossless (observability, not reconstructable via pull).
|
|
846
|
+
|
|
847
|
+
- **`EngineIntrospection`** (`Introspection.ts`): a read-only view over a
|
|
848
|
+
`ClientEngine` and its `ClientStore` — `subscriptions` (active subsets,
|
|
849
|
+
including resolved named subscriptions), `journal`, `cookie`, `collections`,
|
|
850
|
+
and `events`. Built over the engine's public read accessors (new
|
|
851
|
+
`activeSubscriptions` / `engineEvents` members) and the store's public reads,
|
|
852
|
+
so it never touches private state. Every member is a plain `Effect`/`Stream`,
|
|
853
|
+
so it drives cleanly through `PromiseFacade`.
|
|
854
|
+
|
|
855
|
+
- **Server introspection** (`SyncServer.makeWithIntrospection`): a read-only
|
|
856
|
+
`ServerIntrospection` over the live fan-out state — active client groups with
|
|
857
|
+
their authorised subsets and CVR sizes, plus poke counters. Fan-out behaviour
|
|
858
|
+
is unchanged.
|
|
859
|
+
|
|
860
|
+
- **New `legation_*` metrics**: `legation_journal_depth` (gauge, sampled on
|
|
861
|
+
append/acknowledge), `legation_rebase_duration_ms` (histogram, around the
|
|
862
|
+
overlay rebuild), `legation_fanout_latency_ms` (histogram, the server fan-out
|
|
863
|
+
leg), `legation_pull_patch_ops` and `legation_push_mutations` (histograms).
|
|
864
|
+
|
|
865
|
+
- 792d8f5: Sampling the journal-depth gauge no longer reads the whole journal (#260).
|
|
866
|
+
`ClientStore` gains `pendingCount`, the number of pending journal entries
|
|
867
|
+
without reading them: the memory store takes its journal's length, and the
|
|
868
|
+
key-value store (and so `@thomasfosterau/legation-browser`'s IndexedDB store) counts its
|
|
869
|
+
journal index, reading no mutation bodies. The engine samples
|
|
870
|
+
`legation_journal_depth` from it on every append, acknowledgement and paced
|
|
871
|
+
compaction, where it used to load every pending body to take
|
|
872
|
+
`pendingMutations.length`. A `mutate` also stops reading the journal when the
|
|
873
|
+
overlay cache is warm: the pending snapshot it took is needed only to rebuild
|
|
874
|
+
a cold cache. Custom `ClientStore` implementations must provide
|
|
875
|
+
`pendingCount`, and should count without loading the entries.
|
|
876
|
+
- a3b2ee4: The JSON:API façade (ADR-0029, issue #166): a fetch handler that answers a
|
|
877
|
+
covered JSON:API `GET` from the replica and stays out of the way otherwise.
|
|
878
|
+
|
|
879
|
+
- `JsonApiFacade.fetchHandler({ nodes, api, view, coverageOf, cookie?, resources?, baseUrl?, prefix?, online? })`
|
|
880
|
+
in `@thomasfosterau/legation-jsonapi` builds the façade from the app's `HttpApi`
|
|
881
|
+
(`Convention.reflectResources` gives the `list` / `getOne` routes it
|
|
882
|
+
recognises; those whose type is no node are left to the server), the
|
|
883
|
+
node graph, the replica's view and a coverage source. It presumes the
|
|
884
|
+
api's read endpoints are declared with the graph's codec
|
|
885
|
+
(`JsonApiQuery.querySchema` / `singleRowSchema`) and served by the
|
|
886
|
+
derived handlers, which is what lets a parse failure be answered locally.
|
|
887
|
+
`matches(request)` says whether a request is its business — a `GET` whose
|
|
888
|
+
`Accept` names `application/vnd.api+json` on a reflected route under
|
|
889
|
+
`baseUrl` — and `handle(request)` answers: a covered request gets `200`
|
|
890
|
+
with the document the one writer produces (byte-equal in content to the
|
|
891
|
+
derived REST handler's for the same declarations, the optimistic overlay
|
|
892
|
+
visible as `Client.query` sees it), `Cache-Control: no-store` and
|
|
893
|
+
`x-legation-served-at` set to the replica's CVR cookie (the store's,
|
|
894
|
+
empty until the first pull); a URL the grammar rejects gets the codec's
|
|
895
|
+
`400` document (`RestHandlers.queryErrorDocument`), except an `include`
|
|
896
|
+
the graph does not declare, which the server rules on; an uncovered
|
|
897
|
+
request while offline gets the `503` `offline` error document of
|
|
898
|
+
ADR-0029 §4 (`handler.offline`, with the cookie, also for a forward that
|
|
899
|
+
rejected at the transport). Everything else is `undefined` for the caller
|
|
900
|
+
to forward unchanged: a non-candidate, an uncovered request while online,
|
|
901
|
+
a collection no bound resource declares, and a single-resource request
|
|
902
|
+
whose row the replica does not hold (the 404 is the endpoint's own).
|
|
903
|
+
Coverage is every read `ReplicaDocuments.toCover` names, each judged by
|
|
904
|
+
`coverageOf` — `client.coverageOf`, or the host's registrations below.
|
|
905
|
+
The module lives in `@thomasfosterau/legation-jsonapi` by ADR-0006: it needs this
|
|
906
|
+
package's codec, writer and route reflection plus the standard
|
|
907
|
+
`Request` / `Response` globals, and `navigator.onLine` is an option with a
|
|
908
|
+
guarded default, so `@thomasfosterau/legation-browser` stays free of effect-jsonapi.
|
|
909
|
+
- `EngineHost.run` returns a `Host` whose `registrations` lists what the
|
|
910
|
+
host holds on its mirrors' behalf — root windows, relational registrations
|
|
911
|
+
with their edge selects, both halves of a `SubscribeUrl` with its canonical
|
|
912
|
+
URL while a party holds it by URL — as `Coverage.Subscription`s: the
|
|
913
|
+
ledger a façade's coverage check scans in a worker, where no `Client`'s
|
|
914
|
+
own ledger sees the tabs' pins. A registration is listed once its engine
|
|
915
|
+
registration exists (a relational one once its reconcile loop has
|
|
916
|
+
started; one naming an edge the registry does not declare registers
|
|
917
|
+
nothing) and dropped when the last mirror releases it or is swept.
|
|
918
|
+
`Coverage.verdictOf` and
|
|
919
|
+
`Coverage.coverageOf(nodes, subscriptions)` give the same `Verdict` as
|
|
920
|
+
`Client.coverageOf` over any such ledger; `Client` uses the former.
|
|
921
|
+
- `ServiceWorkerHost.start` serves exactly as `run` does and returns the
|
|
922
|
+
`Host` — the engine and its registrations — for the worker to build the
|
|
923
|
+
façade from; `run` is `start` then serve forever.
|
|
924
|
+
- The commerce worker composes façade, then its unchanged HTTP cache, then
|
|
925
|
+
the network (`examples/commerce/src/service-worker.ts`,
|
|
926
|
+
`src/lib/facade.ts`, `src/lib/host.ts`); the api's shape moves to
|
|
927
|
+
`server/api.ts` so the worker reflects the routes the server serves; and
|
|
928
|
+
the replica test runs the façade over the same seed: every URL the
|
|
929
|
+
shell's pins cover is answered with the served document's bytes, and the
|
|
930
|
+
rest go to the network. `Convention.segmentsOf` and `Convention.isParam`
|
|
931
|
+
are exported.
|
|
932
|
+
|
|
933
|
+
- c0ab703: Keyed reactive mutation-state surface (#116) — TanStack Query's
|
|
934
|
+
`useMutationState` analogue. `ClientEngine.mutationStates` maintains a map
|
|
935
|
+
keyed by `mutationId`, seeded from the journal at boot (a reload shows
|
|
936
|
+
pending intents) and folded forward as mutations are journaled, acknowledged,
|
|
937
|
+
or permanently rejected — regardless of whether they went through `mutate`,
|
|
938
|
+
`mutateEncoded`, or `mutateWithAck` (previously only `mutateWithAck`-registered
|
|
939
|
+
mutations ever settled on `mutationEvents`/`engineEvents`; this fixes that
|
|
940
|
+
gap so the whole journal is observable). Terminal (acknowledged/rejected)
|
|
941
|
+
entries are retained only up to a configurable `mutationStateRetention.maxTerminal`
|
|
942
|
+
bound (default 200), evicted oldest-settled-first, so a mutation storm cannot
|
|
943
|
+
grow the map without limit — the journal stays the durable source of truth
|
|
944
|
+
for pending mutations.
|
|
945
|
+
|
|
946
|
+
Exposed at every layer:
|
|
947
|
+
|
|
948
|
+
- `ClientEngine.mutationStates: Stream<MutationStateMap>` and `MutationState.ts`'s
|
|
949
|
+
pure fold (`seed`/`journaled`/`acknowledged`/`rejected`).
|
|
950
|
+
- `ClientView`/`Client` gain `mutationStates`, so it works identically over an
|
|
951
|
+
owned engine and a connected mirror (ADR-0021) — a minimal mirror-protocol
|
|
952
|
+
extension (`SubscribeMutationStates`/`UnsubscribeMutationStates` mirror→host,
|
|
953
|
+
`MutationStatesUpdate` host→mirrors) broadcasts the whole map, refcounted
|
|
954
|
+
like a subset subscription.
|
|
955
|
+
- `EngineAtoms.mutationStates` (the whole map) and `EngineAtoms.mutationsFor(mutator)`
|
|
956
|
+
(a derived per-mutator selector).
|
|
957
|
+
- React `useMutationState(mutator?)` — always a plain array, oldest-journaled
|
|
958
|
+
first, no `AsyncResult` handling needed.
|
|
959
|
+
- Svelte `useMutationState(mutator?)` rune (`@thomasfosterau/legation-svelte`), reactive
|
|
960
|
+
to a `mutator` thunk like `useQuery`'s `read`.
|
|
961
|
+
|
|
962
|
+
The todo example's journal panel is written over this surface,
|
|
963
|
+
replacing its bespoke `Introspection`-based wiring, and demonstrates bounded
|
|
964
|
+
terminal retention.
|
|
965
|
+
|
|
966
|
+
- 8c65627: Form actions run named Mutations (ADR-0030, amendment #215).
|
|
967
|
+
|
|
968
|
+
`@thomasfosterau/legation-svelte/kit` adds:
|
|
969
|
+
|
|
970
|
+
- `mintFormClient` for the page that renders a form (a fresh form client id
|
|
971
|
+
with mutation id `1`, rendered in the `FORM_CLIENT_FIELD` and
|
|
972
|
+
`FORM_MUTATION_FIELD` hidden fields);
|
|
973
|
+
- `formMutationTarget`, which reads the client group from its cookie (never
|
|
974
|
+
minted there) and the form client from the posted fields;
|
|
975
|
+
- `runFormMutation`, which hands the form layer's decoded args to
|
|
976
|
+
`server.mutate` and answers the receipt the action returns under
|
|
977
|
+
`RECEIPT_KEY`;
|
|
978
|
+
- `enhanceFormMutation`, the enhanced submit for `use:enhance`. Each submit
|
|
979
|
+
mints a fresh form client, overlays the Mutation's effect, and settles on
|
|
980
|
+
the receipt. SvelteKit's post-action `load` re-run is off by default.
|
|
981
|
+
|
|
982
|
+
An action without the client-group cookie stays an out-of-band writer. The
|
|
983
|
+
client-group cookie's default name is now exported as `CLIENT_GROUP_COOKIE`.
|
|
984
|
+
|
|
985
|
+
`@thomasfosterau/legation` adds the enhanced submit's client half:
|
|
986
|
+
|
|
987
|
+
- `ClientEngine.beginFormMutation` and `Client.beginFormMutation` apply a
|
|
988
|
+
Mutation's local handler to the overlay under a form client's id without
|
|
989
|
+
journaling it, and return a submission to settle on the action's receipt.
|
|
990
|
+
- An `acknowledged` receipt moves the entry to the unpulled tail. The pull
|
|
991
|
+
whose `lastMutationIds` covers the form client releases it, as it does a
|
|
992
|
+
pushed mutation.
|
|
993
|
+
- A `rejected` receipt drops the entry, and `abandon` drops it when the action
|
|
994
|
+
did not answer. Either way a pull is signalled.
|
|
995
|
+
|
|
996
|
+
The entry holds a place in the tail's bound from the start, and only in
|
|
997
|
+
memory. Over a connected host (a mirror) `Client.beginFormMutation` fails with
|
|
998
|
+
a `TransportError`.
|
|
999
|
+
|
|
1000
|
+
- d7cafd2: Add the `@thomasfosterau/legation/sql` entry point: the driver-agnostic SQL reference backend (`Sql`, `SqlBackend`, `PgExecutor`, `PollingHintSource`), merged in from the former `@thomasfosterau/legation-sql` package, which was never published (ADR-0033). It needs only `effect`, and the root and `./internal` entries don't load it. Import these modules from `@thomasfosterau/legation/sql`.
|
|
1001
|
+
|
|
1002
|
+
`@thomasfosterau/legation-node` is gone (also never published): no consumer runs a Node server. Its `HttpSyncServer` layers were `effect/rpc`'s `RpcServer.layer(RpcTransport.SyncRpcs)` over `RpcTransport.layerHandlers` and an HTTP or WebSocket protocol with NDJSON framing; an app serving the sync RPCs from Node composes those itself (`examples/commerce/server/carriers.ts`).
|
|
1003
|
+
|
|
1004
|
+
- c47786c: `@legation/sveltekit` is merged into `@thomasfosterau/legation-svelte` as the
|
|
1005
|
+
`@thomasfosterau/legation-svelte/kit` subpath, and `Hydration` moves to the public
|
|
1006
|
+
`@thomasfosterau/legation` surface.
|
|
1007
|
+
|
|
1008
|
+
The split was never a dependency boundary: the SvelteKit package contained no
|
|
1009
|
+
Svelte code, and its whole `@sveltejs/kit` coupling was two `import type
|
|
1010
|
+
{ Cookies }` statements — type-only, erased at build. `@sveltejs/kit` is now an
|
|
1011
|
+
**optional** peer dependency of `@thomasfosterau/legation-svelte`, so a non-Kit Svelte app
|
|
1012
|
+
pulls nothing extra, and only `@thomasfosterau/legation-svelte/kit` needs it.
|
|
1013
|
+
|
|
1014
|
+
The bindings shrink to one surface, on one entry point. The `Readable`-store
|
|
1015
|
+
tier (`Query.query` / `namedQuery` / `aggregate` / `related`,
|
|
1016
|
+
`Mutation.getMutate`, `undoControls`, and `Runtime.setEngineRuntime`) is
|
|
1017
|
+
removed: it duplicated the rune hooks, and the package's own `svelte >= 5.40`
|
|
1018
|
+
peer already ruled out the Svelte 4 audience it existed for. The hooks move
|
|
1019
|
+
from the `/Runes` subpath to the package root — they are the API, so they are
|
|
1020
|
+
not something to reach for separately — and gain **`useUndo(manager)`**, the
|
|
1021
|
+
rune equivalent of the store-based `undoControls`, so nothing is lost. The
|
|
1022
|
+
root is now a `.svelte.js` module with a `svelte` export condition; the
|
|
1023
|
+
namespaces are gone, so `setClientRuntime` and the hooks are flat named
|
|
1024
|
+
exports.
|
|
1025
|
+
|
|
1026
|
+
Hydration is now a core concept rather than a SvelteKit one. `Hydration` is
|
|
1027
|
+
exported from `@thomasfosterau/legation` (it was behind `/internal`) and speaks
|
|
1028
|
+
`Query.Read` instead of `Subset`: `snapshot({ nodes, reads })` and
|
|
1029
|
+
`rowsFor(snapshot, nodes, read)`. `@thomasfosterau/legation-svelte/kit` keeps only the
|
|
1030
|
+
framework's share — the client-group cookie, a `load`-shaped wrapper, and
|
|
1031
|
+
`clientRuntime`, which replaces `engineRuntime` and returns the `Client`
|
|
1032
|
+
runtime `setClientRuntime` expects (built on `Client.make`, so it starts the
|
|
1033
|
+
sync loop itself).
|
|
1034
|
+
|
|
1035
|
+
Migration: `@thomasfosterau/legation-svelte/Runes` → `@thomasfosterau/legation-svelte`;
|
|
1036
|
+
`Runtime.setClientRuntime` → `setClientRuntime`;
|
|
1037
|
+
`@legation/sveltekit` → `@thomasfosterau/legation-svelte/kit`;
|
|
1038
|
+
`engineRuntime({ engine: {...} })` → `clientRuntime({ nodes, mutations, ... })`;
|
|
1039
|
+
`setEngineRuntime` → `setClientRuntime`; `query(subset)` → `useQuery(() => read)`;
|
|
1040
|
+
`getMutate()` → `useMutation()`; `undoControls(m)` → `useUndo(m)`;
|
|
1041
|
+
`Hydration.rowsFor(snapshot, subset)` → `Hydration.rowsFor(snapshot, nodes, read)`.
|
|
1042
|
+
|
|
1043
|
+
- 501b12c: The mirror seam sheds its browser-tab coupling: the party served by
|
|
1044
|
+
`EngineHost` is any client context — a browser tab, an Electron window,
|
|
1045
|
+
an extension, a CLI instance — so the vocabulary now says what the
|
|
1046
|
+
contract means. `TabMirror` is `Mirror` (module, namespace, and type),
|
|
1047
|
+
`tabId` is `mirrorId` (wire field included), `TabMessage` is
|
|
1048
|
+
`MirrorMessage` (`decodeTabMessage` → `decodeMirrorMessage`), the host's
|
|
1049
|
+
liveness option `tabTimeout` is `mirrorTimeout`, and
|
|
1050
|
+
`MirrorChannel.makeLoopbackPair` returns `{ host, mirror }`. Clean
|
|
1051
|
+
break, no aliases; `@thomasfosterau/legation-browser` re-exports the new names.
|
|
1052
|
+
- 7f1efac: Named queries and auto-subscribing relational reads cross the mirror
|
|
1053
|
+
protocol (ADR-0021 follow-up). `ClientView` gains
|
|
1054
|
+
`subscribeQuery`/`unsubscribeQuery`/`queryNamed`/`observeNamed` and
|
|
1055
|
+
`related` — both hosts satisfy the widened seam (`ClientViewError` now
|
|
1056
|
+
includes `QueryError`). The tab↔host protocol adds
|
|
1057
|
+
`SubscribeNamed`/`UnsubscribeNamed` (the host engine owns the server
|
|
1058
|
+
round-trip) and `SubscribeRelated`/`UnsubscribeRelated`, where the host runs
|
|
1059
|
+
the reconcile loop AND the join and the tab receives resolved rows
|
|
1060
|
+
(`RelatedUpdate`) — data work stays off the main thread. `EngineHost` takes
|
|
1061
|
+
an optional `registry` for relational resolution and refcounts all
|
|
1062
|
+
registration kinds per tab. A new `HostReady` announcement makes
|
|
1063
|
+
registrations survive host restarts: a starting host broadcasts it and
|
|
1064
|
+
every mirror replays the registrations it holds — covering killed service
|
|
1065
|
+
workers and tabs that raced the worker's boot. On a mirror, observing IS
|
|
1066
|
+
subscribing: `TabMirror.observe`/`observeNamed`/`related` auto-register
|
|
1067
|
+
their subject with the host for the stream's lifetime through a per-key
|
|
1068
|
+
interest ledger (a mirror only carries keys some tab registered, and the
|
|
1069
|
+
host refcounts per tab, so the ledger posts `Subscribe` on first interest
|
|
1070
|
+
and `Unsubscribe` only when the last observer ends and no explicit
|
|
1071
|
+
`subscribe` pins the key). In `@thomasfosterau/legation-svelte`, `namedQuery`,
|
|
1072
|
+
`useNamedQuery`, and `useRelated({ subscribe: true })` now work over
|
|
1073
|
+
mirror-only runtimes (the internal engine-lookup fallback is removed).
|
|
1074
|
+
- cf0b26c: The mirror seam moves to core, where ADR-0006's dependency rule places it:
|
|
1075
|
+
`MirrorProtocol`, the `MirrorChannel` interface + loopback pair,
|
|
1076
|
+
`EngineHost`, and `TabMirror` are exported from `@thomasfosterau/legation` — none
|
|
1077
|
+
of them needs a browser global, so Electron windows, extensions, and CLI
|
|
1078
|
+
processes can host or consume mirrors without `@thomasfosterau/legation-browser`. The
|
|
1079
|
+
browser package keeps what is actually browser-coupled — the new
|
|
1080
|
+
`BroadcastMirrorChannel.make(name)` binding (formerly
|
|
1081
|
+
`MirrorChannel.makeBroadcastChannel`) and `ServiceWorkerHost` — and
|
|
1082
|
+
re-exports the moved modules for compatibility.
|
|
1083
|
+
- ddb1fda: Restructure the repository into a monorepo of sync-engine packages, with package boundaries drawn by dependency footprint:
|
|
1084
|
+
|
|
1085
|
+
- `@thomasfosterau/legation` — the Effect-native sync engine. Everything that needs only a dependency on `effect`: sync keys, cache stores, the write-through cache engine (read-through, dedupe, stale-while-revalidate, invalidation, events), queries, pagination, mutations with optimistic updates, a durable offline outbox, the client/server sync protocol, transports, conflict resolution, the TanStack-Query-style client (queries/mutations/subscriptions/offline sync), the service-worker/edge HTTP cache engine with KeyValueStore persistence, the server-side sync hub with fan-out, and promise facades for non-Effect consumers.
|
|
1086
|
+
- `@thomasfosterau/legation-jsonapi` — the previous `@thomasfosterau/effect-chancery` JSON:API helpers, migrated into the monorepo as a protocol adapter with Effect tagged errors and query/mutation descriptor builders that plug into the core engine.
|
|
1087
|
+
|
|
1088
|
+
- f54f568: Per-mutation outcome surface (#36). Callers can now await a mutation's
|
|
1089
|
+
authoritative backend outcome — applied, or permanently rejected with a reason
|
|
1090
|
+
(#35) — rather than only its journal ack.
|
|
1091
|
+
|
|
1092
|
+
- `ClientEngine.mutateWithAck` returns a `MutationHandle` whose `acknowledged`
|
|
1093
|
+
effect resolves with the `MutationOutcome` once the backend settles the
|
|
1094
|
+
mutation via a push or pull acknowledgement. `ClientEngine.mutationEvents`
|
|
1095
|
+
streams the lifecycle (`journaled`, then `acknowledged` or `rejected`). Plain
|
|
1096
|
+
`mutate`/`mutateEncoded` are unchanged and register no deferred; pending
|
|
1097
|
+
acknowledgements are interrupted on engine scope close, so they never hang.
|
|
1098
|
+
- Svelte gains `useMutationWithAck` (over an additive
|
|
1099
|
+
`EngineAtoms.mutationWithAck` atom), returning a promise of the outcome.
|
|
1100
|
+
- The tab mirror forwards the outcome: `MirrorProtocol` gains an additive
|
|
1101
|
+
`MutateSettled` host→tab message, `EngineHost` awaits the backend verdict on a
|
|
1102
|
+
scoped fibre and posts it, and `TabMirror.mutateWithAck` awaits it (the
|
|
1103
|
+
existing journal-ack `mutate` is unchanged).
|
|
1104
|
+
|
|
1105
|
+
- fc880dc: Fix a gap in `ClientEngine.mutationStates` (#116): a mutation compacted out of
|
|
1106
|
+
the journal by a paced strategy (`Paced.ts`'s debounce/throttle/coalesce, via
|
|
1107
|
+
`dropIfPending`) never transitioned off `"pending"` — the entry stayed stuck
|
|
1108
|
+
showing an in-flight mutation forever, even though the journal no longer had
|
|
1109
|
+
it. `MutationPhase` gains a `"dropped"` phase (and a matching
|
|
1110
|
+
`MutationDropped` `EngineEvent`/`MutationEvent`), and `dropIfPending` now folds
|
|
1111
|
+
every compacted id to it, so a mutation-timeline consumer (a devtools journal
|
|
1112
|
+
panel, #120) never shows a phantom pending entry for a compacted mutation.
|
|
1113
|
+
- 9cbd160: The Node/Edge resource model lands (ADR-0022, program step 2): `Node.make`
|
|
1114
|
+
defines a typed resource — attributes plus edges (`Edge.one` with its
|
|
1115
|
+
foreign-key `via`, `Edge.many`/`Edge.paginated` naming their `inverse`,
|
|
1116
|
+
order as part of a paginated edge's identity) — with per-type branded ids
|
|
1117
|
+
(one-edge fields carry the target's brand) and declared `hintKeys`.
|
|
1118
|
+
`Node.registry` closes and validates the graph (targets exist, inverses
|
|
1119
|
+
resolve to a matching one-edge) and derives the engine's
|
|
1120
|
+
`CollectionRegistry`, so existing engines, `Related` joins, and auth
|
|
1121
|
+
filters consume Node definitions today; `Node.derivableSet` exposes the
|
|
1122
|
+
ADR-0023 fan-out fields. Additive — nothing existing changes.
|
|
1123
|
+
- 0bebedf: `LiveView` (the incrementally-maintained live-query result behind paginated
|
|
1124
|
+
queries) no longer rebuilds its ordered set as a copied array on every change
|
|
1125
|
+
(issue #112). The maintained order and its id index are now persistent
|
|
1126
|
+
weight-balanced trees (`OrderedTree`, a new internal module): `put`/`del` cost
|
|
1127
|
+
O(log n) instead of O(n), and reslicing a window is O(log n + window) — a
|
|
1128
|
+
100k-row maintained set with steady single-row churn no longer pays for a
|
|
1129
|
+
full array copy on every unrelated-row update (see the new
|
|
1130
|
+
`live-view-churn.bench.ts`).
|
|
1131
|
+
|
|
1132
|
+
In-window rows now carry a stable `FractionalIndex` `position`:
|
|
1133
|
+
assigned when a row enters the window, dropped when it leaves, and left
|
|
1134
|
+
untouched when the row didn't move relative to its neighbors — the primitive
|
|
1135
|
+
a future row-level delta feed needs to express a reorder as a move instead of
|
|
1136
|
+
a page diff. `LiveView.retarget` widens a `limit` or advances a `cursor` over
|
|
1137
|
+
the _same_ maintained order, with no re-materialization — the pure half of
|
|
1138
|
+
the "load more widens one window" story (`useConnection`, ADR-0024); wiring
|
|
1139
|
+
this into an open `ClientEngine` subscription so the client never
|
|
1140
|
+
re-registers on "load more" is flagged as future work in `LiveView.ts`.
|
|
1141
|
+
|
|
1142
|
+
`Subset.comparator` now tie-breaks on row id, making it a genuine total order
|
|
1143
|
+
even with an empty `orderBy` (previously every row tied, and `LiveView`'s
|
|
1144
|
+
delta-location degraded to a full scan). Because every backend
|
|
1145
|
+
(`MemoryBackend`, `@thomasfosterau/legation/sql`'s `SqlBackend`,
|
|
1146
|
+
`@thomasfosterau/legation-jsonapi`'s `JsonApiBackend`) already finalizes order/limit/cursor
|
|
1147
|
+
locally via `Subset.apply`, this same comparator is the sole ordering
|
|
1148
|
+
authority for both client and server — a subset with no declared `orderBy`
|
|
1149
|
+
now sorts by id ascending everywhere instead of an unspecified order. The
|
|
1150
|
+
collation decision (why this rules out a client-local collation option, and
|
|
1151
|
+
what a real one would require) is recorded in
|
|
1152
|
+
`docs/adr/0026-subset-ordering-and-collation.md`.
|
|
1153
|
+
|
|
1154
|
+
- b4d2b80: Paced mutations (#117): `Paced.debounce`/`Paced.throttle`/`Paced.coalesce`
|
|
1155
|
+
(`@thomasfosterau/legation/internal`) pace rapid mutation intents — a text field
|
|
1156
|
+
autosaving per keystroke, a slider, a cart-quantity stepper — into a handful
|
|
1157
|
+
of journal entries instead of one per call, built entirely on
|
|
1158
|
+
`ClientEngine.mutateWithAck` and Effect's own timing primitives (no new
|
|
1159
|
+
dependency, no general-purpose pacer module).
|
|
1160
|
+
|
|
1161
|
+
Design: every call always journals immediately through the ordinary
|
|
1162
|
+
`mutateWithAck` path (full durability, the usual optimistic overlay render),
|
|
1163
|
+
then a compaction step drops the caller's own previous still-pending entry
|
|
1164
|
+
for the same key via the new `ClientEngine.dropIfPending`. `debounce`/
|
|
1165
|
+
`throttle` additionally buffer intermediate calls in memory between a leading
|
|
1166
|
+
and a trailing/periodic flush, so a 50-call burst produces at most a couple of
|
|
1167
|
+
journal writes — the tradeoff being that the buffered args between flushes
|
|
1168
|
+
are lost on a crash inside the window (`coalesce` has no such window: every
|
|
1169
|
+
call is durable, at the cost of short-lived extra entries before the next
|
|
1170
|
+
call's compaction).
|
|
1171
|
+
|
|
1172
|
+
`dropIfPending` never touches an entry a `push` may already have sent:
|
|
1173
|
+
`ClientStore.compactPending` (a new store primitive, symmetric to
|
|
1174
|
+
`acknowledge` but by explicit id set) only ever removes ids the engine has confirmed are
|
|
1175
|
+
still in its pending set with no push currently in flight — the ADR-0008 hard
|
|
1176
|
+
boundary (never mutate or reorder a pushed journal entry) is enforced inside
|
|
1177
|
+
the engine, not trusted to callers.
|
|
1178
|
+
|
|
1179
|
+
- 2201b4c: Paginated-edge sync windows (ADR-0024, ADR-0022 program step 5): an included paginated edge's `first` now registers one windowed subset per parent row — `(foreignField = parent, edge order, limit)` — so the replica syncs the window instead of the whole dependent collection. `Collection.Relationship` gains the `"paginated"` kind with its declared order (derived from `Edge.paginated` by `Node.registry`), relational reads select edges as `{ name, first? }` (bare names still accepted), and the mirror protocol's related registrations carry the window (`SubscribeRelated.edges` replaces `names`). Windows follow parents entering and leaving the root window; displaced rows leave the replica through ordinary CVR union diffs.
|
|
1180
|
+
- 0cdefe7: Per-row and per-mutation durable storage for the KeyValueStore-backed `ClientStore`. Confirmed rows now persist one key each at `row/{collection}/{id}` behind a small `rowIndex/{collection}` id-array, and pending mutations one key each at `journal/{clientId}/{mutationId}` behind a compact ordered `journalIndex`. A `putRow`/`appendMutation` rewrites a single value plus a tiny index instead of the whole collection or journal, and `acknowledge` drops acked mutation bodies without rewriting the survivors.
|
|
1181
|
+
|
|
1182
|
+
`ClientStore` gains a batched `applyRowOps`, through which the engine's pull path now routes: a patch is grouped by collection so each collection's id-index is rewritten at most once per patch and each row value written once — patch-apply value I/O scales with patch size, not collection size (a checked-in `bun run bench` counts the KVS writes to demonstrate it).
|
|
1183
|
+
|
|
1184
|
+
A store opened at the previous whole-blob layout is migrated forward transparently on first open (rows, journal, counters, cookie split losslessly), stamped with a `__layout` version key; a corrupt legacy blob falls back to clearing rows and the cookie so the client re-pulls. A new `journalHighWater` option (KVS-only) makes `appendMutation` fail with a clear `StoreError` rather than ever silently dropping a mutation intent; the failure surfaces through `mutate`.
|
|
1185
|
+
|
|
1186
|
+
- 66f3b5b: Add `PgExecutor`, the `SqlExecutor` for PostgreSQL (#203). `PgExecutor.make(sql)`
|
|
1187
|
+
runs `SqlBackend` over any PostgreSQL `SqlClient` from `effect/sql` (provided by
|
|
1188
|
+
a driver such as `@effect/sql-pg`), with `dialect: Sql.postgres`. Each push is one
|
|
1189
|
+
`withTransaction` at SERIALIZABLE, so a mutation's id claim commits with its
|
|
1190
|
+
handler's writes; statements run unprepared, so nothing depends on a named
|
|
1191
|
+
prepared statement surviving behind a transaction-mode pooler; and a failure is a
|
|
1192
|
+
`BackendError` carrying the driver's `SqlError`. The package gains no dependency:
|
|
1193
|
+
`effect/sql` ships in the `effect` peer. The README's new PostgreSQL section
|
|
1194
|
+
covers the executor's round trips and what it needs from a pooler such as
|
|
1195
|
+
Hyperdrive.
|
|
1196
|
+
- 7e11e61: Permanent mutation rejection semantics (#35). A mutator handler can now fail
|
|
1197
|
+
with the new `MutationRejected` error to mark a failure permanent (failed
|
|
1198
|
+
validation, revoked permission, violated business rule). The backend records
|
|
1199
|
+
the mutation as processed — bumps `lastMutationId` atomically **without**
|
|
1200
|
+
applying effects — so a poison mutation is acknowledged and skipped rather than
|
|
1201
|
+
wedging the queue: mutations behind it still apply, and the client drops the
|
|
1202
|
+
optimistic effect on the next rebase with no app code. A `BackendError` keeps
|
|
1203
|
+
today's transient behaviour (abort the batch, client retries).
|
|
1204
|
+
|
|
1205
|
+
- `Backend.ApplyResult` gains an optional `rejected` list; `PushResponse`
|
|
1206
|
+
gains an additive optional `rejected` field (older clients ignore it, and the
|
|
1207
|
+
per-mutation outcome surface #36 reads it).
|
|
1208
|
+
- `MemoryBackend` rolls a rejected mutation's staged writes back (snapshot),
|
|
1209
|
+
`SqlBackend` via a per-mutation `SAVEPOINT`/`ROLLBACK TO SAVEPOINT`, and
|
|
1210
|
+
`JsonApiBackend` classifies HTTP 4xx (except 408/429) as permanent rejections.
|
|
1211
|
+
- `BackendContract.verifyBackendContract` gains a rule covering both failure
|
|
1212
|
+
classes.
|
|
1213
|
+
- `CONTEXT.md` documents the "transient failure" vs "permanent rejection"
|
|
1214
|
+
glossary entries.
|
|
1215
|
+
|
|
1216
|
+
- 6175e38: Predicate-aware fan-out matching. A committed write now pokes a client group
|
|
1217
|
+
only when some hinted change plausibly affects one of its subscriptions,
|
|
1218
|
+
rather than every group whose subscriptions touch the hinted collection.
|
|
1219
|
+
|
|
1220
|
+
Change hints gain an optional, wire-compatible `values` field carrying the
|
|
1221
|
+
current row values of the PUTs in a change (`MemoryBackend`, `SqlBackend` and
|
|
1222
|
+
`JsonApiBackend` populate it best-effort; a backend that can't cheaply produce
|
|
1223
|
+
a value omits it). An old, value-less hint decodes and behaves exactly as
|
|
1224
|
+
before. The `SyncServer` fan-out loop matches a value-carrying PUT against the
|
|
1225
|
+
subscription's predicate, a delete against the group's last-synced CVR, and
|
|
1226
|
+
falls back to the collection-level match for value-less hints and `Exists`
|
|
1227
|
+
predicates (soundness first — over-matching is safe, under-matching would
|
|
1228
|
+
delay convergence). A new `legation_pokes_suppressed_total` metric counts the
|
|
1229
|
+
pokes predicate matching avoids.
|
|
1230
|
+
|
|
1231
|
+
- caf4761: `Predicate` evaluates under Postgres's three-valued semantics (ADR-0028,
|
|
1232
|
+
#155). This is a behaviour change for rows whose filtered field is `null` or
|
|
1233
|
+
absent.
|
|
1234
|
+
|
|
1235
|
+
- `Predicate.evaluate(predicate, row)` returns `Option<boolean>`: `Some(true)`,
|
|
1236
|
+
`Some(false)`, or `None` for unknown, with Kleene `And`/`Or`/`Not`. An
|
|
1237
|
+
absent attribute is NULL. `Predicate.test` is now
|
|
1238
|
+
`Option.getOrElse(evaluate(...), () => false)`, so every caller keeps its
|
|
1239
|
+
boolean signature but a row matches only when the result is definitely
|
|
1240
|
+
true. Rows with a NULL or absent field therefore stop matching `ne`,
|
|
1241
|
+
`Not(eq)`, a `Not(In)` miss and `NotIn`, exactly as they do in SQL. An app
|
|
1242
|
+
that relied on `ne` returning NULL rows must add an explicit `isNull`
|
|
1243
|
+
disjunct.
|
|
1244
|
+
- Two wire-additive nodes: `IsNull { field, negated? }` and
|
|
1245
|
+
`NotIn { field, values }`, with constructors `Predicate.isNull`,
|
|
1246
|
+
`Predicate.isNotNull` and `Predicate.notIn` (also on the typed
|
|
1247
|
+
`Subset.for` builder). `Predicate.eq(f, null)` desugars to `isNull(f)` and
|
|
1248
|
+
`Predicate.ne(f, null)` to `isNotNull(f)`, so existing call sites keep
|
|
1249
|
+
their meaning — and `eq(f, null)` now also selects rows where the field is
|
|
1250
|
+
absent. A wire `Compare` or `In` carrying a `null` literal evaluates to
|
|
1251
|
+
unknown, as in Postgres; that form is deprecated.
|
|
1252
|
+
- Text compares by Unicode code point (`Predicate.compareText`), the order of
|
|
1253
|
+
Postgres `COLLATE "C"` and SQLite `BINARY`, instead of UTF-16 code unit;
|
|
1254
|
+
the two differ only where astral characters meet U+E000–U+FFFF. Booleans
|
|
1255
|
+
order `false < true` under `lt`/`lte`/`gt`/`gte`.
|
|
1256
|
+
- `Subset.comparator` sorts NULL and absent values after every non-null
|
|
1257
|
+
value ascending and before them descending (Postgres's default); it used
|
|
1258
|
+
to sort them first in both directions. The row-id tie-break is a code-point
|
|
1259
|
+
comparison. Existing cursor pages over text or nullable sort keys may
|
|
1260
|
+
re-order once on upgrade.
|
|
1261
|
+
- `CollectionIndex` keeps one bucket for NULL-or-absent that `eq`/`In`/range
|
|
1262
|
+
lookups never return and `IsNull` reads directly; `ne`, `NotIn` and
|
|
1263
|
+
`IS NOT NULL` stay residual conjuncts. Sorted runs use the code-point
|
|
1264
|
+
comparator. The index is proven equal to the full scan over the
|
|
1265
|
+
differential corpus.
|
|
1266
|
+
- `@thomasfosterau/legation/sql`: `Sql.compileWhere` compiles `IsNull` to `IS NULL` /
|
|
1267
|
+
`IS NOT NULL` and `NotIn` to `NOT IN (…)` (empty list → `1 = 1`). The
|
|
1268
|
+
`BackendContract` corpus now generates NULL, absent and astral text.
|
|
1269
|
+
|
|
1270
|
+
- 49fd066: A pull now carries the permanent rejections its watermark covers, so a client whose push response was lost settles the rejected mutation as rejected, not applied (#365). Before, the pull's watermark acknowledged the rejected id as applied, and its effect was dropped with no way to learn the outcome.
|
|
1271
|
+
|
|
1272
|
+
`PullResponse` gains an optional `rejected` list (`{ clientId, mutationId, reason }`), as `PushResponse` already has (#355). The client settles those entries before the watermark acknowledges them. The field is additive: an older client ignores it, and a pull from an older server omits it, so no protocol version changes.
|
|
1273
|
+
|
|
1274
|
+
`Backend` gains a required `rejections(context)` method, which lists the group's remembered rejections with their reasons (Backend guarantee 4). A custom backend must implement it before it upgrades. `MemoryBackend`, `SqlBackend` and `JsonApiBackend` already remember their rejections and implement it. `BackendContract` rule 13 checks that the list agrees with the reported outcome.
|
|
1275
|
+
|
|
1276
|
+
- 06405ed: A covered read is answered from the replica only once the windows its rows link to have been pulled (#426). A window is pulled once a pull that started after it was held has applied, or once an already-pulled window covers it. Each window is marked on its own, so a new edge window does not reset the windows already pulled. A read needs the edge windows its root rows link to, and an unpulled window of a sibling key does not uncover a read that links only to other keys. A relational registration whose root is pulled but whose edge windows are not yet held covers nothing. Before its windows are pulled, `Client.query` falls through to the server (offline, it answers locally, as for any uncovered read), and the JSON:API façade forwards the request online and answers `503` offline.
|
|
1277
|
+
|
|
1278
|
+
The pull state is recomputed, not persisted: after a restart each window starts unpulled until a pull lands. `ClientEngine` gains a read-only `pullCounts`; `ClientView.pulled(key)` answers with a per-window summary (`Coverage.Pulled`); `Coverage` gains `pulledFor` and `windowKey`; the mirror protocol gains a host message, `PulledRegistrations`, which an older mirror ignores (it is an addition, and replaces no message). A host now pulls promptly after a plain subscribe, as it already did for named queries.
|
|
1279
|
+
|
|
1280
|
+
- a1cea1c: Share the rejection-memory forgetting bound. `floorsOf` (a push's lowest mutation
|
|
1281
|
+
id per client) and `forgetBelow` (drop a client's remembered rejections below its
|
|
1282
|
+
floor) now live in a new `RejectionMemory` module, exported on
|
|
1283
|
+
`@thomasfosterau/legation/internal` for adapter authors. `MemoryBackend` and
|
|
1284
|
+
`JsonApiBackend` bound their rejection memory with `forgetBelow`; `SqlBackend` shares
|
|
1285
|
+
only `floorsOf` and keeps its own forgetting loop. No behaviour changes.
|
|
1286
|
+
- 7e94abc: `Related.observeWith` now propagates row deltas through the join instead of
|
|
1287
|
+
re-grouping whole collections (issue #111, part of the #108 IVM umbrella): a
|
|
1288
|
+
foreign-row change updates only the one or two `fk → rows` buckets its old and
|
|
1289
|
+
new key select and re-hydrates only the base rows keyed to those buckets,
|
|
1290
|
+
instead of rebuilding the collection's whole group index and re-hydrating the
|
|
1291
|
+
full base page. A base-row change re-hydrates only that row. A linkage shift
|
|
1292
|
+
(which foreign collections are targeted) diffs the targeted set against the
|
|
1293
|
+
previous one — the same diff-and-release shape `reconcile` already uses for
|
|
1294
|
+
subscriptions — and starts/stops only the newly (un)targeted collections'
|
|
1295
|
+
observers, instead of tearing every observer down and re-priming from scratch.
|
|
1296
|
+
The public API (`related`/`observeWith`/`relatedWith`) and ADR-0024
|
|
1297
|
+
auto-subscription reconciliation are unchanged. The new `Related.makeJoinEngine`
|
|
1298
|
+
is the pure, Effect-free incremental engine behind this, exported so its
|
|
1299
|
+
equivalence to a from-scratch hydrate (`Related.hydrateFromScratch`) can be
|
|
1300
|
+
property-tested directly.
|
|
1301
|
+
- 9d2f21b: Rename the project to **Legation**. Every package moves to the `@legation`
|
|
1302
|
+
scope: the engine is now `@legation/effect` (formerly
|
|
1303
|
+
`@thomasfosterau/effect-sync-core`), and the adapters/bindings are
|
|
1304
|
+
`@legation/browser`, `@legation/node`, `@legation/jsonapi`, `@legation/sql`,
|
|
1305
|
+
and `@legation/svelte` (formerly `@thomasfosterau/effect-sync-*`).
|
|
1306
|
+
|
|
1307
|
+
Runtime identifiers follow the brand, which is **breaking for persisted local
|
|
1308
|
+
state and dashboards**:
|
|
1309
|
+
|
|
1310
|
+
- `Context.Service` identifiers: `@thomasfosterau/effect-sync-core/*` →
|
|
1311
|
+
`@legation/effect/*` (and per-package equivalents)
|
|
1312
|
+
- Client-store key prefix: `effect-sync/` → `legation/`; CVR store prefix:
|
|
1313
|
+
`effect-sync/cvr/` → `legation/cvr/`. Existing IndexedDB/KV state under the
|
|
1314
|
+
old prefix is orphaned — clients rehydrate via a full pull; export any
|
|
1315
|
+
pending journal before upgrading offline clients.
|
|
1316
|
+
- Metrics: `effect_sync_*` → `legation_*`; spans: `effect-sync.*` →
|
|
1317
|
+
`legation.*`
|
|
1318
|
+
- Default BroadcastChannel name: `effect-sync` → `legation`; Redis hint
|
|
1319
|
+
stream: `effect-sync:hints` → `legation:hints`; cache metadata header:
|
|
1320
|
+
`x-effect-sync-cache-meta` → `x-legation-cache-meta`
|
|
1321
|
+
|
|
1322
|
+
- 3016170: Retry/backoff policies for the sync loop (#34). `ClientEngineOptions.retry`
|
|
1323
|
+
takes per-leg `Schedule`s for `push`, `pull`, and the `pokes` stream; unset
|
|
1324
|
+
legs use `defaultRetryPolicy` (exponential from 1s, factor 2, delay capped at
|
|
1325
|
+
30s, bounded to 5 retries — mirroring TanStack Query's 1s→30s, no jitter so it
|
|
1326
|
+
composes predictably).
|
|
1327
|
+
|
|
1328
|
+
`runSync` now applies the policy instead of swallowing failures: only
|
|
1329
|
+
`TransportError` is retried (`StoreError`/`MutatorError` are not transient and
|
|
1330
|
+
propagate immediately), and the poke stream re-establishes with backoff via
|
|
1331
|
+
`Stream.retry`. Terminal failures after policy exhaustion are surfaced on a new
|
|
1332
|
+
`ClientEngine.syncFailures` stream and counted in
|
|
1333
|
+
`Telemetry.syncFailuresTotal`, so bindings can show a "sync degraded" state.
|
|
1334
|
+
|
|
1335
|
+
The retry policy composes with the #33 scheduler: the scheduler decides when to
|
|
1336
|
+
attempt, the policy decides how an attempt survives transient failure.
|
|
1337
|
+
|
|
1338
|
+
- f4a1a60: Schema constants are named after the domain type they decode, with a
|
|
1339
|
+
same-named type beside each: `Protocol.MutationSchema` is now
|
|
1340
|
+
`Protocol.Mutation` (value and type), and likewise `PushRequest`,
|
|
1341
|
+
`PushResponse`, `RejectedMutation`, `PatchOp`, `NamedSubscription`,
|
|
1342
|
+
`PullRequest`, `PullResponse`, `ReadRequest`, `ReadResponse` and `Poke`;
|
|
1343
|
+
`Subset.Subset` and `Subset.OrderBy`; `ClientStore.StoredRow`;
|
|
1344
|
+
`Hydration.Snapshot` and `Hydration.SnapshotRow`; `Cvr.WireCvr`;
|
|
1345
|
+
`MirrorProtocol.EdgeSelect` and `MirrorProtocol.MutationStateEntry` (was
|
|
1346
|
+
`MutationStateSchema`); the JSON:API `JsonApiSchema.Links`, `LinkObject`,
|
|
1347
|
+
`ResourceIdentifier`, `Relationship`, `ResourceObject`, `JsonApiError` and
|
|
1348
|
+
`JsonApiDocument`; and the Cloudflare `RoomProtocol.ShardPoke`, `Register` and
|
|
1349
|
+
`Publish`. `Derive.CreateArgs` / `UpdateArgs` / `DeleteArgs`, the room
|
|
1350
|
+
protocol's bodies and `Hydration.SnapshotFromJson` gain their matching types.
|
|
1351
|
+
- c671ccf: Schema-version negotiation and fleet cookie-reset (#48) — the runtime mechanism
|
|
1352
|
+
for ADR-0014's schema-evolution policy (ADR-0018). ADR-0014 stays the policy;
|
|
1353
|
+
this ships the hooks.
|
|
1354
|
+
|
|
1355
|
+
- **Protocol.** `PushRequest`/`PullRequest` gain an optional `schemaVersion`.
|
|
1356
|
+
Additive and wire-compatible: an old client omits it and still decodes, a new
|
|
1357
|
+
server tolerates its absence.
|
|
1358
|
+
- **Server acceptor.** `SyncServer.makeWith` / `layerWith` take
|
|
1359
|
+
`acceptSchemaVersion?`; a rejected version fails `handlePush`/`handlePull` up
|
|
1360
|
+
front with a typed `VersionNotSupportedError` — before any mutation is applied
|
|
1361
|
+
or patch computed, so the journal is never poisoned. Distinct from a
|
|
1362
|
+
`TransportError` across the in-memory transport seam (the RPC carrier degrades
|
|
1363
|
+
it to a `TransportError`, noted).
|
|
1364
|
+
- **Client hook.** `ClientEngineOptions` gain `schemaVersion?` (sent on every
|
|
1365
|
+
push/pull) and `onUpdateNeeded?`, fired exactly once per ongoing rejection (a
|
|
1366
|
+
latch that resets on the next successful request), alongside an `UpdateNeeded`
|
|
1367
|
+
engine event. Pending mutations are retained across a rejection.
|
|
1368
|
+
- **Local `storeVersion` buster.** `KeyValueOptions.storeVersion?` clears
|
|
1369
|
+
confirmed rows + the cookie on a mismatch (so the next pull rehydrates fully)
|
|
1370
|
+
while preserving the pending journal.
|
|
1371
|
+
- **Fleet reset.** `SyncServer` takes `cookieNamespace?`; bumping it on deploy
|
|
1372
|
+
makes every previously-issued cookie unknown, so each client's next pull is a
|
|
1373
|
+
reset patch (`Clear` + full puts) — one place to reset the fleet.
|
|
1374
|
+
|
|
1375
|
+
- 16889f0: Rename every package from the `@legation/*` scope to
|
|
1376
|
+
`@thomasfosterau/legation-*` (#303): `@legation/effect` is now
|
|
1377
|
+
`@thomasfosterau/legation`, and likewise for `browser`, `cloudflare`,
|
|
1378
|
+
`devtools`, `jsonapi`, `node`, `sql` and `svelte`. Subpath entries keep their
|
|
1379
|
+
paths (`@thomasfosterau/legation/internal`,
|
|
1380
|
+
`@thomasfosterau/legation-svelte/kit`,
|
|
1381
|
+
`@thomasfosterau/legation-devtools/svelte`). Update imports and dependency
|
|
1382
|
+
names accordingly; no `@legation/*` name was ever published to npm.
|
|
1383
|
+
|
|
1384
|
+
`Context.Service` identifiers and `Symbol.for` keys follow the package name
|
|
1385
|
+
(`@legation/effect/Client` → `@thomasfosterau/legation/Client`, and
|
|
1386
|
+
per-package equivalents). The reserved identity sentinels
|
|
1387
|
+
(`@legation/anonymous`, `@legation/unstamped`), the `legation/` client-store
|
|
1388
|
+
prefixes, metric and span names are unchanged.
|
|
1389
|
+
|
|
1390
|
+
- fb2a992: Add secondary field-value indexes over the local store (#113): `eq`/`In`
|
|
1391
|
+
predicates resolve via an in-memory hash index and range `Compare`s via an
|
|
1392
|
+
ordered index, instead of a full collection scan, in `ClientEngine.query`,
|
|
1393
|
+
live-query rebuilds (`observe`, seq-gap collapse), and `Related`'s foreign-index
|
|
1394
|
+
priming (which now also narrows its query to the pinned `foreignField IN (...)`
|
|
1395
|
+
values instead of reading the whole foreign collection).
|
|
1396
|
+
|
|
1397
|
+
Declare indexed fields on a `Node`/`Collection` (`indexes: ["done", "authorId"]`,
|
|
1398
|
+
mirroring `hintKeys`), or pass `autoIndex: true` to `Client.make`/
|
|
1399
|
+
`ClientEngine.make` to auto-discover indexable fields from registered subsets'
|
|
1400
|
+
predicates. Both are opt-in — zero-config behavior is unchanged, and any
|
|
1401
|
+
predicate the index can't narrow (`Or`, `Not`, `ne`, non-indexed fields) falls
|
|
1402
|
+
back to today's full scan. Indexes are in-memory only, maintained from
|
|
1403
|
+
confirmed-row patch deltas and rebuilt lazily from the store; they are used
|
|
1404
|
+
only while a collection's journal is empty (a pending optimistic mutation
|
|
1405
|
+
falls back to a full scan for correctness).
|
|
1406
|
+
|
|
1407
|
+
A benchmark (`secondary-index.bench.ts`) shows a ~25-30x speedup for a
|
|
1408
|
+
selective point predicate (1/100th match rate) over a 50k-row collection.
|
|
1409
|
+
Memory overhead is roughly 300-400 bytes per indexed row per declared field
|
|
1410
|
+
(measured via heap snapshots), i.e. ~15-20MB for a 50k-row collection with one
|
|
1411
|
+
declared index.
|
|
1412
|
+
|
|
1413
|
+
- 652ae7b: The publish of a push's change hints is bounded for every broker now. `makeSerialized` (and `layerSerialized`) bounds a custom `BusBroker`'s `publish` with a new `timeout` option, default 5 seconds, so a hung broker can no longer hold a push's commit-and-publish region open. A timed-out publish is logged and dropped, like the other bus failures. The `FanoutBus` `timeout` option now also documents that a `publish` makes two bounded calls (the broker publish, then a lease registration), and that, on Durable Objects, one broker publish is many shard calls under one bound. The Cloudflare `Room` gains a `publishTimeout` option (default 5 seconds) that bounds its shard calls and lease registrations.
|
|
1414
|
+
- 83bcf76: The Server facade (ADR-0022, program step 7): `Server.make({ backend,
|
|
1415
|
+
auth, queries?, middleware?, cvr?, bus? })` builds the one server-side
|
|
1416
|
+
object — the protocol handler (push/pull with CVR diffing, mount it on
|
|
1417
|
+
any router or carrier) plus `server.broadcast({ collection, ids? })`, the
|
|
1418
|
+
single verb for "these resources changed". Pushed mutations broadcast
|
|
1419
|
+
implicitly through the push path's hints; `broadcast` covers out-of-band
|
|
1420
|
+
writers (cron, admin tools, anything bypassing mutations — ADR-0019 in
|
|
1421
|
+
one line). CVR storage and the fan-out bus default to in-memory /
|
|
1422
|
+
in-process and swap for scale-out (ADR-0011) without app-code changes;
|
|
1423
|
+
ADR-0023's topic-derived fan-out lands behind `broadcast` without
|
|
1424
|
+
changing its signature. Additive.
|
|
1425
|
+
- 20e57d3: Add `server.mutate` (ADR-0030, amendment #215): run one named Mutation
|
|
1426
|
+
server-side as one of a client group's mutations, the entry point a form
|
|
1427
|
+
action calls. It takes the client group, a form client id with its mutation id
|
|
1428
|
+
(`1` for a freshly rendered form), the Mutation and its decoded args, and takes
|
|
1429
|
+
the push path minus the transport: identity is resolved through the
|
|
1430
|
+
`IdentityResolver`, the middleware's `onPush` runs, the backend applies the
|
|
1431
|
+
mutation with its id bumped in the same transaction as its effects, and the
|
|
1432
|
+
write is broadcast. It answers a `Server.Receipt`,
|
|
1433
|
+
`{ clientId, mutationId, phase, reason? }`, with `phase` `acknowledged` or
|
|
1434
|
+
`rejected` with the backend's reason. A resubmission of the same
|
|
1435
|
+
`(clientId, mutationId)` is skipped by the ledger and answers the original
|
|
1436
|
+
ruling. Args that do not encode against the Mutation's schema are a defect of
|
|
1437
|
+
the form layer's hand-over, not a field error.
|
|
1438
|
+
- f010749: Breaking: an auth filter now returns a `ReadScope` (ADR-0015's #205 amendment). The three outcomes are `Authorization.AllowAll`, `Authorization.Deny` and `Authorization.filter(predicate, sets)`. A bare `Predicate` is still accepted and means a filter with no named sets. `Authorization.allowAll` now returns `AllowAll` rather than an always-true predicate. Code that calls an `AuthFilter` itself must handle the wider return type (`Authorization.scopeOf` normalises it).
|
|
1439
|
+
|
|
1440
|
+
New: named, session-resolved sets. A predicate refers to one by name with `Predicate.inSet(field, name)`. The set is declared as an `Authorization.NamedSet` with a `source` collection, a `resolve` function and a `maxAge`. The sync server binds each set per session and caches it. A write to the set's source marks it stale and pokes the session, and a set older than its `maxAge` is resolved again on the next pull. Each per-session cookie carries a scope epoch, a hash of the bound sets, so a session whose set changed resets on its next pull. A set that cannot be resolved binds to nothing: it narrows, and never widens. An undeclared collection stays denied.
|
|
1441
|
+
|
|
1442
|
+
- e40c73a: `@thomasfosterau/legation/sql`'s compilers are exact on Postgres and SQLite (ADR-0028 §4 and
|
|
1443
|
+
§6, #156): every predicate node compiles to a SQL boolean expression whose
|
|
1444
|
+
three-valued result per row equals `Predicate.evaluate`, so the `WHERE`
|
|
1445
|
+
returns precisely the rows the JS evaluator returns `Some(true)` for. The
|
|
1446
|
+
differential harness proves it against a real Postgres (PGlite) and SQLite
|
|
1447
|
+
over the whole generated corpus, set and per-row verdict both. `Sql.ts` no
|
|
1448
|
+
longer describes itself as a superset pre-filter.
|
|
1449
|
+
|
|
1450
|
+
- **Literal kinds.** `Sql.compileWhere` and `Sql.compileSelect` take an
|
|
1451
|
+
optional `kinds` map (field → `Predicate.LiteralKind`: `text`, `integer`,
|
|
1452
|
+
`double`, `boolean`, `instant`) and a `foreignKinds` map (collection →
|
|
1453
|
+
kinds) for the inner `where` of an `Exists`. `Dialect` gains
|
|
1454
|
+
`textCollation` and `boolean`.
|
|
1455
|
+
- **Collation clause.** A `Compare`, `In` or `NotIn` on a field declared
|
|
1456
|
+
`text` carries an explicit code-point collation on the column side:
|
|
1457
|
+
`"col" COLLATE "C" = $1` on Postgres, `"col" COLLATE BINARY = ?` on
|
|
1458
|
+
SQLite. The clause requires the declaration — a string literal alone never
|
|
1459
|
+
collates, since it may be bound against a `uuid`, `date`, enum or
|
|
1460
|
+
`timestamptz` column. The schema rule stands: declare filterable and
|
|
1461
|
+
sortable text columns `COLLATE "C"` (Postgres) or leave them `BINARY`
|
|
1462
|
+
(SQLite).
|
|
1463
|
+
- **Typed binds.** A literal binds by the declared kind: strings for `text`
|
|
1464
|
+
and `instant`, numbers for `integer` and `double`, booleans per dialect. A
|
|
1465
|
+
literal of another JS type is a cross-type comparison (rule (d)) and binds
|
|
1466
|
+
as `NULL`, so its verdict is unknown exactly as in `Predicate.evaluate`
|
|
1467
|
+
and SQLite's affinity coercion never sees it; in a list such literals are
|
|
1468
|
+
dropped.
|
|
1469
|
+
- **SQLite booleans are `0`/`1`.** The SQLite dialect binds boolean literals
|
|
1470
|
+
as `1`/`0` (the `node:sqlite` driver refuses a raw boolean), and
|
|
1471
|
+
`SqlBackend` decodes `0`/`1` back to booleans for columns whose new
|
|
1472
|
+
`TableMapping.kinds` entry says `boolean`; other columns stay exactly as
|
|
1473
|
+
the driver returns them. `SqlBackend` resolves an `Exists` like
|
|
1474
|
+
`MemoryBackend` does: NULL foreign values are left out and the rest are
|
|
1475
|
+
decoded by kind.
|
|
1476
|
+
- `@thomasfosterau/legation`: `Predicate.LiteralKind` is the one home of the kind
|
|
1477
|
+
vocabulary; `FilterCorpus.Column.kind` uses it and the corpus's `"string"`
|
|
1478
|
+
kind is renamed `"text"` to match ADR-0028.
|
|
1479
|
+
|
|
1480
|
+
- ca38874: `evaluateMany` on PostgreSQL now recognises every number its JSON rows cannot
|
|
1481
|
+
carry exactly, not only integers past ±(2^53 − 1): a `numeric` with more digits
|
|
1482
|
+
than a double holds, and such a number inside a `json` / `jsonb` column. A pull
|
|
1483
|
+
holding one falls back to one `evaluate` per subset, as a wide integer already
|
|
1484
|
+
did, and the backend logs a warning once per collection and column, whose cause
|
|
1485
|
+
is the new `SqlBackend.InexactJsonNumber` (it names the collection and column,
|
|
1486
|
+
never the value). The README now says integer, double and text values belong
|
|
1487
|
+
in typed columns (`integer`, `double precision`, `text`) rather than JSON.
|
|
1488
|
+
- 27e1040: Action required: an executor without transactions must set `transactional: false`. The new optional `SqlExecutor.transactional` flag defaults to `true`, and with it `SqlBackend` now claims each mutation in the client's ledger row before its handler runs. An executor without transactions that leaves the default gets that claim-first order, where a crash can leave an id claimed and unapplied and the retry skips it. Set `transactional: false` to keep the handler-then-bump order.
|
|
1489
|
+
|
|
1490
|
+
`SqlBackend` claims each mutation with a conditional update from `id - 1` to `id`, instead of reading the ledger, running the handler and then bumping it unconditionally. Overlapping calls for one mutation now apply it once, and the watermark can no longer move backwards under READ COMMITTED (PostgreSQL's default). A permanent rejection still keeps the claim, and a transient failure gives it back. On the handler-then-bump order the bump takes the larger id, so a late call cannot move the watermark back. `Sql.Dialect` gains `greatest` for that bump. See the module docs for each dialect.
|
|
1491
|
+
|
|
1492
|
+
- e68eb3e: New subpath `@thomasfosterau/legation/sql`: a driver-agnostic SQL reference
|
|
1493
|
+
backend. `Sql` translates a `Subset` predicate to a parameterized `WHERE`
|
|
1494
|
+
(SQLite `?` and PostgreSQL `$n` dialects); `SqlBackend` implements the core
|
|
1495
|
+
`Backend` contract over a minimal `SqlExecutor` seam (`query`/`run`/
|
|
1496
|
+
`transaction`) — subset evaluation, row fetching by id, and a transactional,
|
|
1497
|
+
idempotent, gap-checked mutation-id ledger — leaving the driver (`bun:sqlite`,
|
|
1498
|
+
`effect/sql`, `pg`, …) to the app. Addresses the audit's turnkey-backend
|
|
1499
|
+
gap (#23).
|
|
1500
|
+
- 3be5abc: `SqlBackend` now reports a committed rejection to every copy of the mutation, as Backend guarantee 1 requires (#366). A replayed copy of a rejected mutation lists it in `rejected` with the stored reason, and so does a copy that a concurrent call committed first: on SQLite that copy is listed directly, and on PostgreSQL it is refused with a transient `BackendError` whose retry lists it. Before, such a copy reported nothing, so a client whose push response was lost settled the rejected mutation as applied. The rejection is remembered in the ledger row, in the same transaction as the claim (or, on an executor without transactions, before the bump). A push forgets the rejections below its lowest id per client, as `MemoryBackend` does.
|
|
1501
|
+
|
|
1502
|
+
Action required: this changes the `sync_mutations` ledger table. Run the `ALTER TABLE` for your database before upgrading. A ledger without the column fails each push with a `BackendError` that names this migration, and writes nothing.
|
|
1503
|
+
|
|
1504
|
+
```sql
|
|
1505
|
+
-- SQLite
|
|
1506
|
+
ALTER TABLE sync_mutations ADD COLUMN rejections TEXT;
|
|
1507
|
+
|
|
1508
|
+
-- PostgreSQL
|
|
1509
|
+
ALTER TABLE sync_mutations ADD COLUMN IF NOT EXISTS rejections TEXT;
|
|
1510
|
+
```
|
|
1511
|
+
|
|
1512
|
+
The sql README's "Ledger without the `rejections` column" section gives a check for the column, the same DDL, and the table a new install creates.
|
|
1513
|
+
|
|
1514
|
+
- 97db489: `SqlBackend` now guarantees serialisable isolation across client groups (Backend guarantee 1, #353). Two behaviour changes follow, and the `Sql.Dialect` interface gains an optional field.
|
|
1515
|
+
|
|
1516
|
+
- **PostgreSQL** (`Sql.postgres`): each `applyMutations` transaction runs `SET TRANSACTION ISOLATION LEVEL SERIALIZABLE` before its first read. A handler that read a row another transaction changed first is refused with SQLSTATE 40001, rolls back, and reports a transient `BackendError`, so the client's retry re-runs it on the committed state. Overlapping calls for one client group that READ COMMITTED would have let through now produce the same serialization failure, then skip on retry. **Action required** for PostgreSQL apps: a serialization failure must reach `SqlBackend` as a `BackendError`, as an executor built over `@effect/sql-pg` does. The client's retry of that batch is expected, not an error to report.
|
|
1517
|
+
- **SQLite** is unchanged. Its writers already serialise (`BEGIN IMMEDIATE`), and it conforms.
|
|
1518
|
+
- **D1** (`transactional: false`) does not conform, since it has no transactions to isolate. The README and the `D1Executor` docs now say so (#342).
|
|
1519
|
+
|
|
1520
|
+
`Sql.Dialect` gains an optional `serializable` statement, which `Sql.postgres` sets. The `SqlExecutor` seam docs state the isolation a third-party executor must provide.
|
|
1521
|
+
|
|
1522
|
+
**PostgreSQL apps must pass `dialect: Sql.postgres` to `SqlBackend.make`.** Without it the backend uses the SQLite dialect, which runs no isolation statement and raises no error, so batches run at READ COMMITTED. A hand-written dialect without a `serializable` statement gets no isolation statement either. Also, the executor's `transaction` must open a top-level transaction: PostgreSQL refuses `SET TRANSACTION` inside a nested one (a savepoint), and `applyMutations` then fails with a `BackendError` rather than running at a weaker isolation.
|
|
1523
|
+
|
|
1524
|
+
- 4a23603: `TableMapping` can be derived from a `Collection` and can separate the
|
|
1525
|
+
relation reads evaluate against from the table mutator handlers write to,
|
|
1526
|
+
and can mark a row a tombstone (#204):
|
|
1527
|
+
|
|
1528
|
+
- **`SqlBackend.fromCollection(collection, options)`** derives a mapping's
|
|
1529
|
+
`kinds` from the collection's declared filterable set
|
|
1530
|
+
(`Collection.filterDeclarationOf`, #154) instead of restating column names
|
|
1531
|
+
and kinds by hand. `table`, and optionally `readRelation` / `idColumn` /
|
|
1532
|
+
`versionColumn` / `tombstone`, are still given explicitly; the existing
|
|
1533
|
+
hand-written record shape remains the override path.
|
|
1534
|
+
- **`TableMapping.readRelation`** names the relation `evaluate`, `getRows`
|
|
1535
|
+
and `Exists` resolution read from when it differs from `table` — the real
|
|
1536
|
+
shape of an app whose current-state relation is a Postgres view over a
|
|
1537
|
+
root version table and a leaf, joined at exactly the leaf's current
|
|
1538
|
+
version. `SqlBackend` never writes rows itself, so `table` stays the
|
|
1539
|
+
write target mutator handlers address.
|
|
1540
|
+
- **`TableMapping.tombstone`** is a `Predicate` (the engine's own filter
|
|
1541
|
+
vocabulary, not a bespoke shape): a row matching it is dropped from
|
|
1542
|
+
`evaluate`'s returned entries, so it is simply absent from the next CVR
|
|
1543
|
+
view and `Cvr.diff` reports it as a delete, exactly like a row that
|
|
1544
|
+
vanished from the relation outright (the fail-closed case the model
|
|
1545
|
+
already relied on, now pinned by a test).
|
|
1546
|
+
|
|
1547
|
+
- f441941: SSR snapshots over a `ClientTransport`, and a Cloudflare service-binding
|
|
1548
|
+
carrier to take them (#211).
|
|
1549
|
+
|
|
1550
|
+
`Hydration.snapshot` no longer requires an in-process `ServerHandler`: pull is
|
|
1551
|
+
the correctness path, so subscribe → pull → dehydrate works identically
|
|
1552
|
+
against a remote Server. Pass `transport` and the render needs nothing from
|
|
1553
|
+
context; omit it and the in-process path is unchanged — a loop-back transport
|
|
1554
|
+
over the in-context handler. `loadSnapshot` in `@thomasfosterau/legation-svelte/kit` takes
|
|
1555
|
+
the same option, so an Effect-based `load` can render against a backend in
|
|
1556
|
+
another Worker.
|
|
1557
|
+
|
|
1558
|
+
`@thomasfosterau/legation-alchemy-cloudflare` provides `BindingTransport`: the sync protocol over a
|
|
1559
|
+
service binding (`env.API.fetch`), for the ordinary two-Worker shape where the
|
|
1560
|
+
Worker rendering the page is not the Worker holding the database. It forwards
|
|
1561
|
+
the inbound cookie and origin so the bound Worker resolves the same caller —
|
|
1562
|
+
and therefore the same room — the browser will reach on its own first pull.
|
|
1563
|
+
Getting that wrong is silent: the snapshot still carries the right rows while
|
|
1564
|
+
its cookie belongs to another room's CVR, degrading every first pull to the
|
|
1565
|
+
reset path. The package's tests pin both sides of it.
|
|
1566
|
+
|
|
1567
|
+
- a01f43b: The public surface is the six concepts (ADR-0022 step 10b). `@thomasfosterau/legation`'s root index shrinks from 43 namespaces to the resource-graph API — `Node`, `Edge`, `Collection`, `Query`, `Aggregate`, `Mutation` (with `Mutator` as a deprecated alias), `Client`, `ClientStore`, `Transport`, `Server`, `Backend`, `MemoryBackend`, `Authorization`, `Predicate`, and the error classes. Everything else — the engine, the mirror seam, plan vocabulary (`Subset`/`Related`), protocol codecs, reactivity plumbing, and server assembly parts — moves to the new `@thomasfosterau/legation/internal` subpath for adapter authors, which the sibling `@thomasfosterau/legation-*` packages now compose from.
|
|
1568
|
+
- 9a6426b: Sync-loop scheduling (#33). `ClientEngine` grows a configurable scheduler on
|
|
1569
|
+
`runSync`, driven by `Effect.Schedule` and the existing `statusChanges` seam:
|
|
1570
|
+
|
|
1571
|
+
- `syncLoop.pushDebounce` auto-pushes after `mutate`/`mutateEncoded`, debounced
|
|
1572
|
+
so a burst of mutations coalesces into a single push.
|
|
1573
|
+
- `syncLoop.pullInterval` pulls periodically as the correctness backstop, so a
|
|
1574
|
+
client converges over pull alone even without pokes.
|
|
1575
|
+
- The baseline triggers (pull on poke, push+pull on reconnect) are retained,
|
|
1576
|
+
and every scheduling fibre is scoped — interrupted when the engine scope
|
|
1577
|
+
closes.
|
|
1578
|
+
|
|
1579
|
+
`@thomasfosterau/legation-browser` adds `BrowserConnectivity.withBrowserConnectivity`, which
|
|
1580
|
+
layers `navigator.onLine` + `online`/`offline` + `visibilitychange` onto any
|
|
1581
|
+
`ClientTransport` so `statusChanges` becomes truthful for carriers (like the RPC
|
|
1582
|
+
transport) that otherwise report themselves permanently connected, and a
|
|
1583
|
+
return-to-visible surfaces a poke.
|
|
1584
|
+
|
|
1585
|
+
`@thomasfosterau/legation-jsonapi` `JsonApiSync` now configures the core loop (`pullInterval`,
|
|
1586
|
+
`pushDebounce` options, 5-second default interval) instead of running its own
|
|
1587
|
+
`Effect.forever` poll; `JsonApiSync.start` just runs `runSync`.
|
|
1588
|
+
|
|
1589
|
+
- fc880dc: Add `Transport.simulateOffline`: a decorator wrapping any `ClientTransport`
|
|
1590
|
+
with a toggleable offline gate (push/pull/read fail with a `TransportError`
|
|
1591
|
+
while toggled offline, mirroring a real network drop, so the engine keeps
|
|
1592
|
+
mutations in the journal and its sync loop flushes them on reconnect).
|
|
1593
|
+
Generalises the offline-gate transport the demo examples each hand-rolled
|
|
1594
|
+
into reusable substrate — it now
|
|
1595
|
+
backs both examples' offline toggle, and `@thomasfosterau/legation-devtools`' actions panel
|
|
1596
|
+
(#120) offline-toggle action.
|
|
1597
|
+
- e34bc24: Connection-status realism at the transport seam: `Transport.withInferredStatus`
|
|
1598
|
+
derives real connectivity from observed push/pull behaviour ("connecting"
|
|
1599
|
+
until the first call settles, "connected" on success, "disconnected" on a
|
|
1600
|
+
`TransportError`) — wrapping the default RPC carrier, which reports itself
|
|
1601
|
+
permanently "connected". `Transport.withFallback(primary, fallback)` layers a
|
|
1602
|
+
fallback carrier (e.g. plain HTTP under WebSockets) with a sideline cooldown
|
|
1603
|
+
(`restoreAfter`, default 30s), an optional per-call primary time budget
|
|
1604
|
+
(`primaryTimeout` — a hanging primary is interrupted, sidelined, and the call
|
|
1605
|
+
falls to the fallback instead of stalling sync), merged pokes that survive a
|
|
1606
|
+
failing primary poke stream, inferred status ("connected" while either
|
|
1607
|
+
carrier serves), and version rejections propagating without falling back.
|
|
1608
|
+
`BrowserConnectivity.withBrowserConnectivity` now combines `navigator.onLine`
|
|
1609
|
+
with the wrapped transport's own status (worst of the two wins) instead of
|
|
1610
|
+
replacing it.
|
|
1611
|
+
- 0470e1c: Add a typed query surface over the existing untyped `Subset`/`LocalRow` (purely
|
|
1612
|
+
additive — the protocol-level and dynamic surface is unchanged).
|
|
1613
|
+
|
|
1614
|
+
- `Subset.for(collection).make({ where, orderBy, limit, cursor })` builds a
|
|
1615
|
+
`TypedSubset<A>`: field names and literal types in `where` (via a
|
|
1616
|
+
field-checked `TypedPredicate<A>`) and `orderBy` are checked against the
|
|
1617
|
+
collection's row type. It erases to a plain `Subset` at runtime.
|
|
1618
|
+
- `ClientEngine.query`/`observe`, `EngineAtoms.liveQuery`, the React `useQuery`
|
|
1619
|
+
and the Svelte `query` store now refine their row type to `A` when handed a
|
|
1620
|
+
`TypedSubset<A>` (a trust-cast — the read path still never decodes, per
|
|
1621
|
+
ADR-0014), so the row type flows through without `row.value as T` casts. Plain
|
|
1622
|
+
`Subset` callers are unaffected.
|
|
1623
|
+
- `ClientEngine.observe(subset, { select, equals? })` returns a projected
|
|
1624
|
+
stream that re-emits only when the projected value changes (reference
|
|
1625
|
+
equality by default), so a change to a field the projection ignores no longer
|
|
1626
|
+
wakes the observer.
|
|
1627
|
+
- Named-query cache/subscription keys (`ClientEngine`'s named subscriptions and
|
|
1628
|
+
`EngineAtoms.namedQuery`) now key on `Hash.canonicalJson` rather than
|
|
1629
|
+
`JSON.stringify`, so two structurally-equal args objects with different key
|
|
1630
|
+
order resolve to the same subscription entry and the same Atom.
|
|
1631
|
+
|
|
1632
|
+
- 439f449: Add `Undo`: an optional, additive undo/redo module layered over anything that
|
|
1633
|
+
can `mutate(name, args)` — no engine surgery. It follows the **paired-intents**
|
|
1634
|
+
design (the `@rocicorp/undo` shape): the caller supplies both directions of a
|
|
1635
|
+
change — an `execute` intent and its reversing `undo` intent — and
|
|
1636
|
+
`Undo.make(handle)` returns an `UndoManager` whose `run` / `undo` / `redo`
|
|
1637
|
+
replay through the handle's ordinary `mutate` pipeline. A `ClientEngine` and a
|
|
1638
|
+
`Client` both satisfy the accepted `MutateHandle` shape structurally; because
|
|
1639
|
+
their `mutate` methods don't share an error channel, both `MutateHandle` and
|
|
1640
|
+
`UndoManager` are generic in that error type, so a failed mutation is still
|
|
1641
|
+
observable as whatever the supplied handle fails with. Because undo and redo
|
|
1642
|
+
are just mutations, they are optimistic, journaled, rebased, and offline-safe
|
|
1643
|
+
like any write; the module only owns the history.
|
|
1644
|
+
|
|
1645
|
+
History is **in-memory and per-client** (one manager per engine, a pair of
|
|
1646
|
+
stacks in a `SubscriptionRef`) — it does not survive a reload, though the
|
|
1647
|
+
journal still carries any pending undo/redo mutations to the backend. `run`
|
|
1648
|
+
clears the redo stack (a new action invalidates redo); `undo` and `redo` are
|
|
1649
|
+
no-ops (never errors) when their stack is empty. `group(effect)` coalesces every
|
|
1650
|
+
`run` made while `effect` runs into a single step: one `undo` replays each
|
|
1651
|
+
member's `undo` in reverse order, one `redo` replays each member's `execute` in
|
|
1652
|
+
order. `canUndo` / `canRedo` are one-shot effects; `canUndoChanges` /
|
|
1653
|
+
`canRedoChanges` are `SubscriptionRef.changes`-backed streams for bindings.
|
|
1654
|
+
|
|
1655
|
+
The **delete-then-undo** criterion is met by the paired-intents pattern: capture
|
|
1656
|
+
the row's pre-delete value from the local view at run time and pass it as the
|
|
1657
|
+
undo's re-create args, so an undo restores the row exactly.
|
|
1658
|
+
|
|
1659
|
+
`@thomasfosterau/legation-svelte` gains `useUndo(manager)` — `canUndo` / `canRedo` as runes
|
|
1660
|
+
over the manager's change streams, plus `undo` / `redo` handlers. The `todo`
|
|
1661
|
+
example wires ⌘Z / Ctrl+Z (Shift for redo) through the manager and documents
|
|
1662
|
+
the recipe.
|
|
1663
|
+
|
|
1664
|
+
- 3a88118: The unified Query read (ADR-0022, program step 3): `Query.make({
|
|
1665
|
+
collection, where, include, order, first, after })` declares a read shaped
|
|
1666
|
+
like the data — an equality-map `where` (full `Predicate` as the escape
|
|
1667
|
+
hatch), edges by name in `include` (with per-edge `first` windows), order
|
|
1668
|
+
tokens, cursor pagination — and `Query.plan` compiles it against the node
|
|
1669
|
+
graph to a root `Subset` plus edge plans (`Subset`/`Predicate` become the
|
|
1670
|
+
plan's internal representation). `Query.run`/`Query.observe` execute reads
|
|
1671
|
+
over any `ClientView.Reader`, resolving includes through the existing
|
|
1672
|
+
`Related` machinery and applying paginated-edge order + `first` windows to
|
|
1673
|
+
the resolved join (true per-edge sync windows land with the
|
|
1674
|
+
paginated-edge step). Named queries are the same concept with a name —
|
|
1675
|
+
`Query.named` (the old `(name, args)` form of `Query.make` still works via
|
|
1676
|
+
overload).
|
|
1677
|
+
- 1b8b120: URL-keyed subscriptions over the mirror channel (ADR-0029 section 2, issue #159).
|
|
1678
|
+
|
|
1679
|
+
- `Client.subscribe` / `Client.unsubscribe` accept a JSON:API URL as well as a `Query.Read`. A Client built with the new `urls` option (`Client.UrlReads`: `parse(url)` and `canonical(read)`) parses the URL, plans the read and canonicalises it, so `subscribe(url)` and `subscribe(read)` of one query — and any non-canonical spelling of the URL (parameter order, a repeated `include` key, an API prefix, a `fields[]` projection) — share one ledger entry keyed by the canonical URL beside the subset hash. A URL the bridge refuses (an unknown collection, an unparsable filter, an undeclared edge) fails with a `QueryError` naming the offending parameter and registers nothing; a Client built without `urls` fails a URL call with a `QueryError`, never a defect. `Client.subscriptions` lists the canonical URLs of the explicit pins for devtools, and `Client.coverageOf` names the covering subscription's canonical URL (`Verdict.url`) while a pin holds it. `Client.make`, `Client.connect` and `Client.fromView` take `urls`; `@thomasfosterau/legation` itself does not know the URL grammar (ADR-0006), so `@thomasfosterau/legation-jsonapi` builds the seam from the node graph: `JsonApiQuery.urlReads(nodes)`.
|
|
1680
|
+
- An explicit pin now registers its edge windows. `Client.subscribe(read)` with `include` holds the foreign windows the read's edges select — `ClientView.pinEdges`, a new member of the view: `Related.trackEdges` (the relational read's reconcile loop without its join) over an owned engine, the relational registration with the host over a connected one — until the last pin releases, and records the edge selects on the ledger entry, so a read with `include` is covered by a pin that declared the edge (ADR-0029 rule 4). The commerce shell's `myCartItems` pin (`include: { variant: true }`) now syncs its edge window. Release stays symmetric with the pin count; the ledger update and the registration it decides are one critical section.
|
|
1681
|
+
- A relational registration no longer waits on the network: `Related.relatedWith` (behind `observe` with `include`, `related` and now pins) runs its pulls in the background, one at a time with one queued, so a stalled transport delays rows and never blocks the caller; a failed pull only delays rows (an offline `Client.make` no longer dies).
|
|
1682
|
+
- Engine subscriptions are counted where two registrations can hold one window: `ClientView.fromEngine` and `EngineHost` register through `Subscriptions.counted(engine)` — idempotent, so a tab hosting for others (`ElectedHost`) counts once for both fronts — and a relational read's own root subscription ending beside an explicit pin, or one relational read's foreign window beside another's, no longer drops the window from the engine's set-based subscription list.
|
|
1683
|
+
- The mirror protocol gains `SubscribeUrl` / `UnsubscribeUrl` (`{ mirrorId, url }`): a page with no `Client` posts them on the channel, and an `EngineHost` built with `nodes` and `urls` parses, plans and registers the URL's root and edge windows under that `mirrorId` — a thin adapter over the existing `Subscribe` and `SubscribeRelated`, refcounted and swept like any mirror's registrations. `EngineHost`'s `registry` defaults to `nodes.collections`. `ServiceWorkerHost.run` and `ElectedHost.run` forward `nodes` / `urls`; `ElectedHost`'s Client takes `urls` too.
|
|
1684
|
+
- `Query.tryPlan(nodes, read)` is `Query.plan` with its `QueryError` as a typed failure.
|
|
1685
|
+
|
|
1686
|
+
- cf48723: Web Push as a poke carrier, to converge a client with no open tab (#214). `PushPoke.sink` (on `@thomasfosterau/legation/internal`) is an `InvalidationSink` that sends a contentless marker, `PushPoke.payload`, to the push subscriptions the app's registry names for a change, through the app's own sender: the library holds no credential and stores no subscription. The coalescing window and the per-target budget are required options with no default. In `@thomasfosterau/legation-browser`, `PushPoke.listen` is the worker's `push` listener: the library's push registers the Background Sync tag by default, or pulls with `action: "pull"`, only when the replica is stamped for whoever is signed in now, and every other push is left to the app's own handler. `ServiceWorkerHost.start` takes it as `pushPoke`.
|
|
1687
|
+
- b4489c5: Write-path incremental view maintenance. A `mutate` and a push acknowledgement now carry the collections they touch, so a live query observing a different collection no longer re-materialises when an unrelated collection changes; the collections are discovered by running the mutator's local handler through a lightweight collection-tracking writer. The materialised optimistic overlay is cached and shared across observers (invalidated on every journal append/trim and pull), so a single change costs one journal replay rather than one per observer. `LiveView.put`/`del` gain an id index: an absent id is now an O(1) no-op and an existing row is located by binary search on its sort key (O(log n) for distinct sort keys, O(log n + equal-key run) on ties) instead of a linear scan. A checked-in benchmark (`bun run bench`) exercises a mutation burst over a large collection with many observers.
|
|
1688
|
+
|
|
1689
|
+
Targeted overlay-delta recomputation on pull-with-pending remains staged: a pull while the journal is non-empty still falls back to a global rebuild, preserving rebase correctness.
|
|
1690
|
+
|
|
1691
|
+
### Patch Changes
|
|
1692
|
+
|
|
1693
|
+
- 56a2e51: With `syncLoop.pushDebounce` set, `ClientEngine.runSync` now auto-pushes a
|
|
1694
|
+
mutation journaled at any point after it starts. Previously the push-debounce
|
|
1695
|
+
fibre subscribed to the push trigger only once it first ran, a few scheduler
|
|
1696
|
+
turns after `runSync` returned. A mutation journaled in that gap, after the
|
|
1697
|
+
connect leg had already read the journal, stayed pending until something else
|
|
1698
|
+
pushed: another mutation, a reconnect or an explicit `push`. A mutation made
|
|
1699
|
+
right after start-up could hit this whether `runSync` is forked (`Client.make`,
|
|
1700
|
+
the browser package's `ElectedHost`) or awaited (`ServiceWorkerHost`, the
|
|
1701
|
+
JSON:API package's `JsonApiSync`). `runSync` now subscribes before it forks any
|
|
1702
|
+
of its fibres.
|
|
1703
|
+
- d76b04d: Docs: add the "Coming from TanStack Query / SwiftData / SWR" capability guide
|
|
1704
|
+
(`docs/comparisons/coming-from-tanstack-query.md`) and link it from each package README.
|
|
1705
|
+
- 1743c2b: `ClientEngine` settles each mutation once. A push response that repeats a
|
|
1706
|
+
permanent rejection the client already holds (the response of a copy that
|
|
1707
|
+
loses a race, #355) no longer emits a second `MutationRejected` event or
|
|
1708
|
+
resolves a `mutateWithAck` handle twice. A rejection reported for a mutation
|
|
1709
|
+
the client has already acknowledged no longer overwrites its `acknowledged`
|
|
1710
|
+
state. The first committed outcome the client observed stands: a later,
|
|
1711
|
+
contradictory report is logged at debug and ignored.
|
|
1712
|
+
- 3e29e1b: `BackendContract.verifyBackendContract` gains an optional `awaitPeer` on `@thomasfosterau/legation/internal`. Rule 11 holds a parked handler while the peer group's call runs. A backend whose peer does real I/O (PostgreSQL) can now supply `awaitPeer`, which returns once the peer has finished or is blocked on a lock the parked call holds, so the overlap is reached on every run instead of by scheduler luck (#416). Without it the rule keeps its bounded turn count, so existing callers are unchanged.
|
|
1713
|
+
|
|
1714
|
+
**For third-party backends:** the rule 11 handler is registered under `BackendContract.countUp.name` (`contract/count-up`). It passes `gate` unchanged to `park` between its read and its write, and writes the counter plus one. A handler that omits `park` fails rule 11 with "finished without reaching park()". Only the contract's own handler calls `park`.
|
|
1715
|
+
|
|
1716
|
+
- 8995115: `Derive`'s combinators (#114) now consume `observeChanges` directly when a
|
|
1717
|
+
leaf is built from a full `ClientView` via `Derive.observed`, instead of
|
|
1718
|
+
diffing consecutive `observe()` pages to recover a delta `LiveView` already
|
|
1719
|
+
computed (#133 item 1, ADR-0027 §4's flagged follow-up). `Incremental.
|
|
1720
|
+
deltaFromChanges` reads an `observeChanges` batch's enter/update/exit/move
|
|
1721
|
+
decision directly; `observed`'s returned source carries this feed invisibly
|
|
1722
|
+
alongside its ordinary page stream, so `filter`/`map`/`join`/`groupBy`/
|
|
1723
|
+
`distinct`/`orderedWindow` built on top of it pick it up automatically. A
|
|
1724
|
+
source built any other way — a raw `Stream`, `view.observe(subset)` called
|
|
1725
|
+
directly, or another combinator's own output — still diffs pages, unchanged.
|
|
1726
|
+
|
|
1727
|
+
No public API changes: `Derive.observed`'s parameter type widens from
|
|
1728
|
+
`ClientView.Reader` to accept a full `ClientView.ClientView` too, and every
|
|
1729
|
+
combinator's signature is untouched.
|
|
1730
|
+
|
|
1731
|
+
- 8995115: `CollectionIndex` acceleration (#113) is no longer bypassed for every
|
|
1732
|
+
collection whenever any mutation is pending (#133 item 4): since #109 the
|
|
1733
|
+
engine already computes exactly which collections a batch of pending
|
|
1734
|
+
mutations touches for the rebase path, and `indexForCollection` now reuses
|
|
1735
|
+
that to scope the bypass — a pending mutation on collection A leaves
|
|
1736
|
+
collection B's declared/auto-discovered index usable. An unscoped result
|
|
1737
|
+
(an unknown mutator, undecodable args) still conservatively falls back to a
|
|
1738
|
+
full scan for every collection, same as before. No public API changes —
|
|
1739
|
+
`indexes`/`autoIndex` options and query results are unaffected; this only
|
|
1740
|
+
changes when the full-scan fallback kicks in.
|
|
1741
|
+
- ae18cb3: Add a one-sentence doc comment to every exported declaration that had none, so editor hovers on the published `.d.ts` say what each export is for. This covers the root `@thomasfosterau/legation` surface (`Backend`, `Server`, `ClientStore`, the `Predicate` and `Aggregate` constructors, …), its `/internal` seams, and the options and config interfaces of the other packages. The `@thomasfosterau/legation` module header now counts seven concepts (Hydration joined ADR-0022's six). Documentation only; no API or behaviour changes.
|
|
1742
|
+
- cc2f5b7: Observers subscribe to their change hub BEFORE taking the initial
|
|
1743
|
+
snapshot (#84). A hub only delivers to live subscribers, so a change
|
|
1744
|
+
published while an observer materialized its first page was silently
|
|
1745
|
+
lost — and because seq-gap recovery needs a _next_ item to notice the
|
|
1746
|
+
gap, a quiet collection never re-emitted: `Related` joins missed foreign
|
|
1747
|
+
writes, live queries slept through a mutation that landed in the
|
|
1748
|
+
subscribe window. `ClientEngine`'s subset observers and `TabMirror`'s
|
|
1749
|
+
key observers now hold the subscription first; a buffered change the
|
|
1750
|
+
snapshot already reflects steps as a stale seq or an idempotent
|
|
1751
|
+
re-apply.
|
|
1752
|
+
- a874acb: Bump the pinned Effect version from `4.0.0-beta.75` to `4.0.0-beta.83` across all
|
|
1753
|
+
packages (no engine API changes; build, typecheck, and the full test suite pass
|
|
1754
|
+
unchanged).
|
|
1755
|
+
|
|
1756
|
+
The version is now centralised in a root **Bun catalog**
|
|
1757
|
+
(`package.json` → `workspaces.catalog`): every package's `devDependencies`
|
|
1758
|
+
reference `effect`/`@effect/platform-*`/`@effect/vitest` as `catalog:`, while the
|
|
1759
|
+
published `peerDependencies` keep the concrete `4.0.0-beta.83` string. The
|
|
1760
|
+
Every example is a workspace member so they share the same `effect` version
|
|
1761
|
+
as the engine instead of pinning its own.
|
|
1762
|
+
|
|
1763
|
+
- c5d3484: README: the `effect` floor (`4.0.0-rc.118`) is now tested on every pull
|
|
1764
|
+
request, where CI runs the test suite against it, instead of in a weekly
|
|
1765
|
+
canary. The peer range (`effect >=4.0.0-rc.118`) and the tested pin (`4.0.0`)
|
|
1766
|
+
are unchanged.
|
|
1767
|
+
- e9363c0: Peer `effect ^4.0.0` (was `>=4.0.0-rc.118`), and `@effect/platform-node` /
|
|
1768
|
+
`@effect/platform-browser` `^4.0.0` likewise, per the peer-range policy: the
|
|
1769
|
+
range is raised only when a dependency requires it. The range form changes
|
|
1770
|
+
(the caret no longer admits the 4.0 release candidates or Effect 5); every
|
|
1771
|
+
package's tests pass at `effect` 4.0.0.
|
|
1772
|
+
|
|
1773
|
+
`@thomasfosterau/legation-svelte` peers `effect ^4.0.2`: its dependency
|
|
1774
|
+
`@thomasfosterau/effect-svelte` 0.6.0 peers `effect >=4.0.2`.
|
|
1775
|
+
|
|
1776
|
+
- 454e75c: ADR-0025's read path is reusable outside the protocol carrier (#163).
|
|
1777
|
+
`SyncServer.readSubsets(deps, context, request)` is `handleRead` without the
|
|
1778
|
+
identity resolution: the filterable check, `SyncMiddleware.onRead`, the
|
|
1779
|
+
deny-by-default auth filters, then `Backend.evaluate` + `getRows` per subset;
|
|
1780
|
+
`handleRead` calls it, and the derived REST handlers in `@thomasfosterau/legation-jsonapi`
|
|
1781
|
+
call it behind an HTTP request's identity. Every subset of a batch is
|
|
1782
|
+
authorized before any is evaluated, as `handleRead` always did, and the rows
|
|
1783
|
+
of each subset now come back in the evaluated order (`getRows` promises
|
|
1784
|
+
none), so a fallthrough read over a SQL backend is windowed exactly as
|
|
1785
|
+
`Subset.apply` orders it. `Query.runVia(fetch, nodes, read)` runs a read over
|
|
1786
|
+
an external row source — the plan, `Related.queryVia`'s two phases, and the
|
|
1787
|
+
edge windows — and `Query.runPlanVia(fetch, nodes, plan)` does the same over
|
|
1788
|
+
an already-compiled plan; `Client.query`'s server fallthrough compiles once
|
|
1789
|
+
and uses the latter.
|
|
1790
|
+
- 2003cd6: Built-in multi-tab leader election for the no-service-worker degraded
|
|
1791
|
+
topology (#119): `@thomasfosterau/legation-browser`'s `ElectedHost.run` wraps
|
|
1792
|
+
`navigator.locks` (held for the tab's whole lifetime, no voluntary
|
|
1793
|
+
step-down) around `ServiceWorkerHost.run`'s main-thread host composition —
|
|
1794
|
+
`ClientEngine` + durable store + transports + `BroadcastMirrorChannel`
|
|
1795
|
+
serving while elected, a `Client.connect` mirror over the same channel
|
|
1796
|
+
while not. It hands back one `Client` that keeps working unchanged across
|
|
1797
|
+
the mirror→host promotion (a switching `ClientView` swaps the underlying
|
|
1798
|
+
tier, replaying explicit subscriptions and re-hydrating live
|
|
1799
|
+
`observe`/`related` streams) and a `role: Stream<"host" | "mirror">` for
|
|
1800
|
+
devtools/debug UI. A promoted tab boots against the SAME durable store —
|
|
1801
|
+
same journal, rows, and cookie — so the CVR conversation resumes rather
|
|
1802
|
+
than restarting. Environments without `navigator.locks` get a documented
|
|
1803
|
+
fallback (becomes host immediately), not a polyfill.
|
|
1804
|
+
|
|
1805
|
+
`@thomasfosterau/legation`'s `Mirror` now fails any request still in flight when
|
|
1806
|
+
the host it was talking to is gone — either because the mirror's own scope
|
|
1807
|
+
closed, or because a second `HostReady` announces the host restarted — with
|
|
1808
|
+
a clear `TransportError` instead of leaving the caller's `mutate` /
|
|
1809
|
+
`mutateWithAck` / server-fallthrough `read` call hanging forever. A
|
|
1810
|
+
mutation already journaled by the old host before it died is never lost:
|
|
1811
|
+
it's durable in the shared store and the next host's sync loop picks it up
|
|
1812
|
+
regardless of what this caller was told.
|
|
1813
|
+
|
|
1814
|
+
- 6e9e119: Differential filter harness (issue #153, ADR-0028).
|
|
1815
|
+
|
|
1816
|
+
- `BackendContract` rule 6 (reference subset semantics) now also runs a small
|
|
1817
|
+
generated corpus of predicates, order terms and limits over the contract's
|
|
1818
|
+
`rank`/`tag` columns, so every backend inherits the check for every operator
|
|
1819
|
+
in the core. This is stricter than before: text order is code-point order,
|
|
1820
|
+
so a backend whose text columns carry a locale collation fails the contract
|
|
1821
|
+
by design — ADR-0028 requires `COLLATE "C"` (Postgres) or `BINARY` (SQLite).
|
|
1822
|
+
- `FilterCorpus` is a new `@thomasfosterau/legation/internal` export: seeded corpus
|
|
1823
|
+
generators over Effect's `Random` service (`Random.withSeed`), a minimiser
|
|
1824
|
+
that shrinks a disagreeing predicate/row pair, and a disagreement reporter.
|
|
1825
|
+
- `@thomasfosterau/legation/sql`'s tests gain the differential harness itself: `Predicate.test`
|
|
1826
|
+
and `Sql.compileWhere` on SQLite are compared against a Postgres oracle
|
|
1827
|
+
running in-process via PGlite, with the known JS divergences pinned until
|
|
1828
|
+
#155 closes them.
|
|
1829
|
+
|
|
1830
|
+
- a1b95d4: The rune hooks speak Query over the Client (ADR-0022 step 8). `useQuery`/`useAggregate`/`useAggregateGrouped` take declarative `Query.Read` thunks — `include`d edges resolve onto each row's `related` (windowed per the edge's declared order), so `useRelated` is removed; `useMutation`/`useMutationWithAck` run over the context Client. The hooks read a `ClientRuntime` (`ManagedRuntime<Client.Client>`) provided via `setClientRuntime`. `@thomasfosterau/legation` adds the `Client` service tag and exposes `Client.nodes` (the registry the Client was built from).
|
|
1831
|
+
- dfb8759: `ClientEngine` now tracks every journal entry it pushes in `mutationStates`, including entries a direct store write put there (`Hydration.hydrate` into a running engine) and entries whose boot-time journal read failed. Each one settles to `"acknowledged"` or `"rejected"` like a locally journaled mutation, so a server rejection of a hydrated entry now emits `MutationRejected`. Before, the entry was pushed but untracked, and a rejection for an untracked id was silently ignored (no event, no state change).
|
|
1832
|
+
|
|
1833
|
+
A hydrated entry is announced like a local one: it emits `MutationJournaled` once, when it is adopted, so its outcome event follows an opening event as it does for a local mutation. The payload is the same `clientId` and `mutationId` the outcome events carry. Re-reading the journal does not announce an entry twice. This adds an event to the stream for hydrated entries, so `registerOnJournal` in `@thomasfosterau/legation-browser` (which listens for `MutationJournaled` while offline) now also registers a background sync for them; it is a consequence of this change, not a change to that package.
|
|
1834
|
+
|
|
1835
|
+
An entry the engine already tracks keeps its phase, and a rejection that contradicts an outcome already observed is logged at warning level and ignored. The journal read that adopts entries now runs under the engine's overlay lock, so a pull's trim cannot land between the read and the adoption.
|
|
1836
|
+
|
|
1837
|
+
- 9c9f2b0: Fix a secondary index that could keep pre-pull rows. An index build reads a
|
|
1838
|
+
collection's confirmed rows, yields, then installs; a pull whose patch changed
|
|
1839
|
+
an indexed field of that collection could land in that window, and the stale
|
|
1840
|
+
index was installed and kept until the next rebuild, so indexed queries could
|
|
1841
|
+
return rows that no longer match (or miss rows that now do). Each collection now
|
|
1842
|
+
carries a generation that every confirmed-row write (and every field-set change)
|
|
1843
|
+
bumps; a build discards its result and retries when the generation moved during
|
|
1844
|
+
its read, and falls back to a full scan after repeated races.
|
|
1845
|
+
- ab1fb4e: A refused or failed journal append no longer burns a mutation id (#258).
|
|
1846
|
+
`journalHighWater` refusals, quota errors and aborted transactions used to leave
|
|
1847
|
+
the id counter one ahead of the journal, so the next mutation carried
|
|
1848
|
+
`lastId + 2`, every backend rejected the gap, and the journal never drained.
|
|
1849
|
+
`ClientStore` gains `appendNext(draft)`, which allocates the id and appends as
|
|
1850
|
+
one store step (the key-value store checks the high-water mark first and
|
|
1851
|
+
persists the counter last, healing it from the journal's highest id after a
|
|
1852
|
+
crash); the engine journals through it. Custom `ClientStore` implementations
|
|
1853
|
+
must provide `appendNext`; `nextMutationId` stays for low-level use but an id
|
|
1854
|
+
reserved without an append is a gap.
|
|
1855
|
+
|
|
1856
|
+
An id that has left the journal is never issued again: `acknowledge` and
|
|
1857
|
+
`quarantinePending` raise the client's counter to the highest id they remove (in
|
|
1858
|
+
the key-value store, before deleting anything), and the key-value `appendNext`
|
|
1859
|
+
is uninterruptible. Previously a lost counter write (or an interrupt between the
|
|
1860
|
+
index and counter writes) let the next append reissue an acknowledged id, which
|
|
1861
|
+
the backend skipped as an idempotent duplicate, silently losing the write. The
|
|
1862
|
+
in-memory store's journal writers are now mutually exclusive: `appendMutation`,
|
|
1863
|
+
`appendNext`, `acknowledge`, `compactPending`, `nextMutationId`,
|
|
1864
|
+
`quarantinePending`, `releaseQuarantine` and `discardQuarantine` all hold one
|
|
1865
|
+
permit, so a read-then-write operation can no longer overwrite a concurrent
|
|
1866
|
+
append or a released quarantine. `appendNext` heals its counter from the journal
|
|
1867
|
+
exactly as the key-value store does, and so does `nextMutationId` on both
|
|
1868
|
+
stores.
|
|
1869
|
+
|
|
1870
|
+
- eb9555a: The filter-grammar bridge (ADR-0028 §5, #161): `@thomasfosterau/legation-jsonapi` now
|
|
1871
|
+
translates both ways between effect-jsonapi's filter AST and the engine's
|
|
1872
|
+
`Predicate`, parses a JSON:API URL into a `Query.Read`, and encodes the whole
|
|
1873
|
+
predicate exactly. Bumps `@thomasfosterau/effect-jsonapi` to ^0.13.0, which
|
|
1874
|
+
carries the grammar (`Filter`, `Sort`, `Query.Filter`, `Query.canonical`,
|
|
1875
|
+
`Resource.filterable` / `Resource.sortable`).
|
|
1876
|
+
|
|
1877
|
+
- `JsonApiQuery.fromFilterAst(ast)` is total — every grammar node has an
|
|
1878
|
+
engine node — and `toFilterAst(predicate)` is partial: `Exists`, an empty
|
|
1879
|
+
`In` / `NotIn` list and a `null` literal have no URL form and return `None`,
|
|
1880
|
+
so callers fail closed. `foldConstants` folds the two definite cases away
|
|
1881
|
+
exactly (an empty `In` is false, an empty `NotIn` true under ADR-0028).
|
|
1882
|
+
- `JsonApiQuery.parseRead(nodes, url)` / `parseParams(nodes, collection,
|
|
1883
|
+
params)` decode `include`, `fields`, `sort`, `page` and `filter` with
|
|
1884
|
+
effect-jsonapi's `Query.schema` derived from the **node graph** (each node's
|
|
1885
|
+
filterable set — operators and literal kinds — and edges become the codec's
|
|
1886
|
+
declaration; `resourcesOf(nodes)` exposes the synthesised resources) and map
|
|
1887
|
+
them onto the read: `filter` → `where`, `sort` → `order`, `page[limit]` /
|
|
1888
|
+
`page[after]` → `first` / `after`, `include` → edge selects. Include paths
|
|
1889
|
+
are single level (ADR-0028 D1). `fields[]` is carried beside the read as a
|
|
1890
|
+
projection, never into the plan. Every rejection is a `QueryError` whose new
|
|
1891
|
+
`parameter` field names the offending query key (the 400's
|
|
1892
|
+
`source.parameter`). A `/{type}/{id}` URL reads as `filter[id]`, first 1.
|
|
1893
|
+
- `JsonApiQuery.canonical(nodes, read)` compiles the read and encodes its plan
|
|
1894
|
+
through effect-jsonapi's canonical encoder — one string per plan, the
|
|
1895
|
+
subscription identity of ADR-0029 — and `parseRead` accepts it back.
|
|
1896
|
+
- The encoder is exact: `defaultEncoder` pushes the whole predicate as the
|
|
1897
|
+
grammar's canonical `filter[...]` form (operators, `or`, `not`, `isnull`,
|
|
1898
|
+
`nin` included — nothing is dropped any more); `safeFilters` is gone. A
|
|
1899
|
+
subset whose predicate folds to false is answered without a request, and one
|
|
1900
|
+
with no URL form fails closed. `QueryEncoder.encode` now returns
|
|
1901
|
+
`Effect<Option<string>, QueryError>`; `JsonApiRequestOptions.filter` is a
|
|
1902
|
+
filter AST (`Filter.eq("status", "open")`) instead of a `Record<string,
|
|
1903
|
+
string>`; query strings are serialised through `Query.serialise` (readable
|
|
1904
|
+
`[`/`]` in keys). `buildQueryString` lives in `JsonApiQuery` and stays
|
|
1905
|
+
re-exported from `CollectionLoader`.
|
|
1906
|
+
- `NodeBridge.fromResource` / `registry` read the filterable set off the
|
|
1907
|
+
resource's own declaration (`Resource.filterable` / `Resource.sortable`): an
|
|
1908
|
+
attribute keeps its name and derives its kind from its schema; a to-one
|
|
1909
|
+
relationship's declaration lands on the edge's key field (`author` →
|
|
1910
|
+
`authorId`, kind `text`). The `filterable` / `sortable` options remain as
|
|
1911
|
+
per-field overrides (the way to give an `instant` its kind).
|
|
1912
|
+
|
|
1913
|
+
- 26ad3f3: `ClientStore.makeKeyValue` now drops, when it opens, any journal index entry
|
|
1914
|
+
whose body is missing. A crash (or a failed write) part-way through
|
|
1915
|
+
`acknowledge` removed the acknowledged bodies but left their index entries,
|
|
1916
|
+
which inflated `pendingCount` and the journal-depth gauge, could make the
|
|
1917
|
+
`journalHighWater` check refuse appends early, and made `compactPending` refuse
|
|
1918
|
+
to renumber over the gap until the next acknowledgement trimmed them. The
|
|
1919
|
+
dropped ids stay reserved, so none is reissued, and `pendingCount` still reads
|
|
1920
|
+
the index alone.
|
|
1921
|
+
- cb0a33d: The in-memory `ClientStore` now takes the atomic batch path that the key-value store takes when it has an `AtomicKeyValue`: a pull's release and a push acknowledgement's park and trim each run as one step under the store's lock, with no release record. A release the store refuses now writes nothing, so the unpulled tail keeps its entries and can sit above its bound until the next push acknowledgement or pull releases them, rather than being finished from a record.
|
|
1922
|
+
- 912d95d: `MemoryBackend` now meets Backend guarantee 1. Each mutation's writes are staged
|
|
1923
|
+
until its handler returns, and the staged writes commit with the client's
|
|
1924
|
+
`lastMutationId` bump in one state update. A pull or `evaluate` that runs while
|
|
1925
|
+
a handler is mid-flight no longer sees its writes ahead of the bump. A handler
|
|
1926
|
+
that fails with `BackendError` after a write leaves no write behind, and a
|
|
1927
|
+
`MutationRejected` still discards the writes while bumping the id. Handlers
|
|
1928
|
+
still read their own staged writes, and row versions are assigned as before.
|
|
1929
|
+
- 7290d2b: `MemoryBackend` no longer double-applies a mutation when two `applyMutations`
|
|
1930
|
+
calls for one client group overlap (a client retry while the first push is
|
|
1931
|
+
still in flight). The commit re-checks the client's `lastMutationId`, so a copy
|
|
1932
|
+
whose mutation another call already committed is dropped, reported as a replay
|
|
1933
|
+
skip, and no longer moves the watermark backwards. The bump also takes the max,
|
|
1934
|
+
so `lastMutationId` never falls. `Backend` guarantee 1 now states that calls for
|
|
1935
|
+
one client group may overlap and what must hold when they do.
|
|
1936
|
+
- 41c33e3: `MemoryBackend` now reports the committed outcome from every copy of a mutation. A copy that loses a race to another copy, or replays a mutation already committed, lists it in `rejected` when it was committed as permanently rejected, with the same reason. Before, such a copy reported nothing, so a client could settle a rejected mutation as applied, or show its optimistic effects until the next pull, depending on which push response arrived first. A committed rejection is remembered until the client's next push starts past it, so the backend holds at most that push's unacknowledged rejections per client.
|
|
1937
|
+
- a77261c: `MemoryBackend` no longer emits a stale value in a push's change hint for a row
|
|
1938
|
+
that a later mutation of the same push deleted. A put in an earlier mutation
|
|
1939
|
+
left its value in the batch's hint values after a later mutation deleted the
|
|
1940
|
+
row, so the hint carried a row that no longer exists and fine-grained fan-out
|
|
1941
|
+
routed by it. The delete now removes the row from the batch's values, as a
|
|
1942
|
+
delete within one mutation already did.
|
|
1943
|
+
- a04918b: A mirror's `mutate`, `mutateWithAck` and server-fallthrough `read` now get their own answer when another tab on the same host has a request of the same number in flight. `Mutate` and `Read` carry the mirror's id, and the host echoes it on `MutateAck`, `MutateError`, `MutateSettled` and `ReadResult`; a mirror ignores a reply addressed to another. The field is optional both ways, so a mirror and a host from before this change still work together, as they did before.
|
|
1944
|
+
- bc4c599: Fix a lost-write window in paced compaction (#249 follow-up). Once
|
|
1945
|
+
`ClientStore.compactPending` had made a compaction durable (the write-ahead
|
|
1946
|
+
intent), a failure applying it (an IndexedDB quota error, an aborted
|
|
1947
|
+
transaction) or an interrupt before the engine caught up left the store
|
|
1948
|
+
renumbered while the engine, `Paced` trackers and `mutateWithAck` handles kept
|
|
1949
|
+
the old ids, so the next paced call dropped the user's newest write. Now
|
|
1950
|
+
`compactPending` returns the committed renumbering as soon as the intent is
|
|
1951
|
+
durable (the next journal operation, or the next open, rolls the rest
|
|
1952
|
+
forward), the store commit and the engine's re-key run uninterruptibly, the
|
|
1953
|
+
memory store updates journal and counter together, and `Paced` journals and
|
|
1954
|
+
remembers an id in one uninterruptible step.
|
|
1955
|
+
|
|
1956
|
+
Also: a journal found at engine boot or hydrated from a snapshot is frozen
|
|
1957
|
+
(never renumbered), since it may already have been pushed; a durable
|
|
1958
|
+
compaction reads only the journal index and the bodies it moves instead of the
|
|
1959
|
+
whole journal; `dropped` is now a valid phase on the mirror wire (a dropped
|
|
1960
|
+
state used to make the whole `MutationStatesUpdate` undecodable); and the
|
|
1961
|
+
compaction events are emitted inside the journal lock.
|
|
1962
|
+
|
|
1963
|
+
- 20a6e18: Fix paced compaction stranding the journal behind a mutation-id gap (#249).
|
|
1964
|
+
`Paced.coalesce`/`debounce`/`throttle` drop superseded unpushed entries, which
|
|
1965
|
+
left holes in the client's mutation ids. Both reference backends reject a gap
|
|
1966
|
+
(ADR-0008's exactly-once ledger), so every push failed and the journal never
|
|
1967
|
+
drained. The client now renumbers: `ClientEngine.dropIfPending` drops the
|
|
1968
|
+
entries and renumbers the surviving unpushed ones contiguously, in order, from
|
|
1969
|
+
the last entry a push may have sent (or from the first pending id), rewinding
|
|
1970
|
+
the id counter to match. The backends are unchanged and still reject gaps.
|
|
1971
|
+
|
|
1972
|
+
- `dropIfPending` now returns `{ dropped, renumbered }` (was the dropped ids),
|
|
1973
|
+
and `Paced` applies the moves to every paced instance on the engine so none
|
|
1974
|
+
acts on an id that now names a different entry.
|
|
1975
|
+
- Safety: compaction never touches an entry a push attempt may have sent. The
|
|
1976
|
+
engine records a durable per-client "sent" watermark (`ClientStore.markSent`)
|
|
1977
|
+
before each push leaves, so a push whose response was lost keeps its ids
|
|
1978
|
+
(renumbering an applied write would get a later one skipped as a duplicate).
|
|
1979
|
+
It still refuses while a push is in flight, and runs under the journal lock so
|
|
1980
|
+
it is atomic with `mutate` and with a push claiming the pending set.
|
|
1981
|
+
- `ClientStore.dropPending` (unreleased) is replaced by `compactPending`
|
|
1982
|
+
(drop + renumber + counter rewind, as one step) and `markSent`. The
|
|
1983
|
+
KeyValue store does this through a write-ahead intent that is rolled forward
|
|
1984
|
+
on the next open, so a crash never leaves a gap, a duplicate or a stale
|
|
1985
|
+
counter. Custom `ClientStore` implementations must provide both.
|
|
1986
|
+
- `mutateWithAck` handles and `mutationStates` follow renumbered entries, a
|
|
1987
|
+
dropped entry's `acknowledged` effect is interrupted instead of leaking, and
|
|
1988
|
+
`engineEvents` gains `MutationRenumbered`. `MutationHandle.mutationId` is the
|
|
1989
|
+
id at journal time and can change while the entry is unpushed.
|
|
1990
|
+
|
|
1991
|
+
- 821b10d: The publish of a push's change hints is bounded by a timeout now that it runs
|
|
1992
|
+
inside the uninterruptible commit-and-publish region. A hung broker used to hold
|
|
1993
|
+
that region open indefinitely, so a push could never finish. Each broker call the
|
|
1994
|
+
bus makes is now bounded: `FanoutBus` (its publish and lease registration, option
|
|
1995
|
+
`timeout`), the Redis broker's `XADD` (option `timeout`), and the Durable Object
|
|
1996
|
+
`DurableFanoutBroker` (its shard calls, option `timeout`, which aborts the request).
|
|
1997
|
+
All three default to 5 seconds. A timed-out call is logged and dropped, the same as
|
|
1998
|
+
any other bus failure: a lost poke only delays the next pull, so correctness never
|
|
1999
|
+
depends on the bus.
|
|
2000
|
+
- 9e74a83: A push that takes the acknowledged-but-unpulled tail to half its bound now starts
|
|
2001
|
+
a pull ahead of the overflow, so a healthy transport never reaches the bound
|
|
2002
|
+
(#337). Before, with no pokes and no `pullInterval`, nothing pulled on its own,
|
|
2003
|
+
so a long run of pushes could fill the tail and release its oldest overlay
|
|
2004
|
+
entries early, reverting the view until a pull landed. The pull runs in the
|
|
2005
|
+
engine's scope and the push does not wait for it; at most one such pull is in
|
|
2006
|
+
flight, and a failed one does not block the next.
|
|
2007
|
+
- e7f4871: A push acknowledgement no longer reverts the view before the pull lands (#286). The push used to trim a mutation's optimistic effects from the overlay as soon as the backend acknowledged it, so an observer saw the pre-mutation value until the next pull. The applied entries now stay overlaid until a pull reports them, and the pull reveals the confirmed row in one change. A permanently rejected mutation still leaves the view at its push, since the backend applied nothing.
|
|
2008
|
+
|
|
2009
|
+
The acknowledged entries are held in memory and bounded at 1024: past that, the oldest are released with a warning. An identity reset clears them. A pull's confirmed rows and its `lastMutationIds` are read as one snapshot, re-reading (up to three passes) when a push commits mid-pull, so, on a backend whose mutations commit their `lastMutationId` with their effects, a pull never leaves out a push its rows carry and an acknowledged mutation is never counted twice. `MemoryBackend` and `SqlBackend` on D1 do not commit them together yet, so a mutation can still be counted twice there until the next pull (#341, #342). Only when a push lands on every pass does a pull report a push its rows lack, which reverts that mutation until the next pull.
|
|
2010
|
+
|
|
2011
|
+
- 916d91b: `SyncServer`'s push handler no longer loses a fan-out when it is interrupted.
|
|
2012
|
+
The backend commit and the publish of its change hints now run as one
|
|
2013
|
+
uninterruptible region, so an interrupt that lands after the commit still
|
|
2014
|
+
publishes the hints. Before, an interrupt between the two (a client dropping
|
|
2015
|
+
mid-push, say) left the change committed but never poked the other client
|
|
2016
|
+
groups, and a replayed retry carries no hints to repair it. The trade-off: a
|
|
2017
|
+
slow `applyMutations` can no longer be cancelled mid-push; an interrupt waits
|
|
2018
|
+
for the commit and its publish to finish.
|
|
2019
|
+
- 47f4e15: `SyncServer.readSubsets`, the read behind `handleRead` and the derived REST handlers, evaluates its subsets through `Backend.evaluateAll` (one `evaluateMany` call on a backend that has it, such as `SqlBackend`'s single `UNION ALL`) and fetches their rows with one `getRows` per collection, instead of an `evaluate` and a `getRows` per subset (#534). Each subset's rows still come back in its evaluated order.
|
|
2020
|
+
- f829146: Rewrite the `@thomasfosterau/legation` and `@thomasfosterau/legation-browser` READMEs against the current API. The stubs are replaced by install, usage and API sections with examples that compile against the published entry points: the root `Node`, `Query`, `Mutation`, `Client` and `Server` surface and the browser host, store, cache and Background Sync modules. The `@thomasfosterau/legation` package description now names the ADR-0022 surface instead of the v1 vocabulary. Documentation only; no API or behaviour changes.
|
|
2021
|
+
- 13658a7: The Redis Streams broker (`makeRedisBroker`) resolves each subscription's start position once it succeeds, then reads on from the last entry it saw. A hint published between a block timeout and the next read is delivered: the read continues from a fixed ID (`XREVRANGE ... COUNT 1`, or `0-0` for an empty stream), not from `$`, which would move forward on each read. The `"$"` option keeps its meaning: a subscriber reads only hints published after its start position.
|
|
2022
|
+
|
|
2023
|
+
`subscribe` makes one start-position attempt and waits at most 500 ms for it, then returns either way. So `SyncServer` construction, which subscribes to the bus, waits at most 500 ms when Redis is unreachable. While Redis answers in time, a hint published once `subscribe` returns is never missed. If the attempt fails or times out, the subscription's reader makes the attempt on its first step, and retries each failed step with a back-off of 200 ms, doubling, capped at 5 s, until Redis answers. While Redis is unreachable, a publish that races the start can be missed; delivery stays best-effort (ADR-0009).
|
|
2024
|
+
|
|
2025
|
+
Each outage logs a warning on its first failed step, a debug line for each further retry, and an info line when Redis answers again.
|
|
2026
|
+
|
|
2027
|
+
- 31cfb2c: The Redis Streams broker (`makeRedisBroker`) bounds each reader `XREAD BLOCK` at its `block` time plus 2 seconds. A read with no reply by then is logged, backed off and retried from the last ID seen, as a failed start lookup already was, so a connection that accepts a read and never answers no longer parks the subscriber silently. A normal BLOCK timeout (a null reply) is still a success, and `block: 0` is not bounded.
|
|
2028
|
+
- 4fbd0b7: Fix a race in `Related.observeWith` that could leave a live join permanently
|
|
2029
|
+
stale: a foreign-row write landing between the join's index priming and its
|
|
2030
|
+
foreign observers subscribing was swallowed by the observers'
|
|
2031
|
+
initial-emission drop, and nothing re-emitted until the next foreign write.
|
|
2032
|
+
The observers' initial current-rows emission is no longer dropped — an
|
|
2033
|
+
unchanged rebuild re-hydrates to the same join and dedups away via
|
|
2034
|
+
`changesWith`, while a racing write now correctly re-emits.
|
|
2035
|
+
- 507ccb1: Every tagged error is now a branded `Schema.TaggedError` rather than a
|
|
2036
|
+
`Data.TaggedError`: `StoreError`, `TransportError`, `QueryError` and the rest
|
|
2037
|
+
of the engine's errors, the JSON:API adapter's `SyncError` / `NetworkError` /
|
|
2038
|
+
`ParseError` / `ApiError` / `ConflictError` / `ResourceBridgeError`, and
|
|
2039
|
+
`HostNotReady`. Construction, `_tag`, `message` and `Effect.catchTag` are
|
|
2040
|
+
unchanged; each class now carries a schema and a nominal brand, so a
|
|
2041
|
+
structurally similar object no longer type-checks as one.
|
|
2042
|
+
- 22f320d: `ClientEngine` runs pulls one at a time. Before, a pull issued while another was in flight did not wait for it, so two pulls could leave with the same cookie and an older response applied last could revert rows and the cookie. Now a pull waits for the pull in flight, and an explicit `pull` resolves once a pull that started after the call has been applied, so a call is never answered from data older than itself. Pokes that arrive during a pull share one follow-up pull. Pulls still run in the caller's fibre, so the caller's context and interruption behave as before.
|
|
2043
|
+
- 8d02113: Fix the mutation-id bump on executors without transactions (`transactional: false`) failing on PostgreSQL. The upsert's `GREATEST` named the ledger's `last_mutation_id` unqualified, which PostgreSQL reads as ambiguous with `excluded`, so every push on such an executor was refused. The column is now qualified with the ledger table's name.
|
|
2044
|
+
- bf9054d: Two fixes surfaced by running `verifyBackendContract` over a real SQLite
|
|
2045
|
+
executor (the commerce example): `SqlBackend`'s mutation-ledger upsert
|
|
2046
|
+
re-bound a placeholder index, which renders an extra unmatched `?` under the
|
|
2047
|
+
sqlite dialect (the fake-executor tests never bound params positionally) —
|
|
2048
|
+
now `excluded.`-based and dialect-safe. And `verifyBackendContract` no longer
|
|
2049
|
+
imposes a value-encoding convention the engine itself doesn't require
|
|
2050
|
+
(ADR-0014 keeps values opaque): the idempotency rule checks the
|
|
2051
|
+
discriminating field instead of exact value equality, and the evaluation rows
|
|
2052
|
+
use a string discriminator instead of a boolean (SQLite has no boolean type).
|
|
2053
|
+
- 985c4d5: `SqlBackend` on an executor without transactions forgets a rejection that an earlier push recorded before its bump failed, once a retry applies the same mutation. Before, the stale rejection outlived the retry, so every later replay reported a rejection for a mutation that had applied (#425).
|
|
2054
|
+
- 97cc5f9: Build with `vp pack` (tsdown) instead of `tsc --build`. Each entry point is now one ESM bundle with one declaration file (the two `@thomasfosterau/legation` entries share a chunk, so a value reached through `.` and `./internal` is still one instance). The `exports` map, entry file names and published dependency and peer ranges are unchanged, and nothing from npm is bundled. The source also moved to the Effect lint presets' idioms (namespace imports from `effect/*` submodules, `effect/Predicate` guards, tag-exact error recovery); behaviour is unchanged.
|