@thomasfosterau/legation-svelte 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +644 -0
- package/LICENSE +21 -0
- package/README.md +390 -2
- package/dist/Runtime-DAQq0HCa.d.ts +66 -0
- package/dist/Runtime-DAQq0HCa.d.ts.map +1 -0
- package/dist/index.svelte.d.ts +177 -0
- package/dist/index.svelte.d.ts.map +1 -0
- package/dist/index.svelte.js +401 -0
- package/dist/index.svelte.js.map +1 -0
- package/dist/kit/index.d.ts +171 -0
- package/dist/kit/index.d.ts.map +1 -0
- package/dist/kit/index.js +180 -0
- package/dist/kit/index.js.map +1 -0
- package/package.json +68 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Thomas Foster
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,391 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @thomasfosterau/legation-svelte
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Idiomatic Svelte bindings for the
|
|
4
|
+
[`@thomasfosterau/legation`](../effect) engine.
|
|
5
|
+
|
|
6
|
+
Coming from TanStack Query / SwiftData / SWR? [Coming from TanStack Query](../../docs/comparisons/coming-from-tanstack-query.md) maps those libraries' concepts onto this model.
|
|
7
|
+
|
|
8
|
+
The package is deliberately thin. Everything the engine can do on its own —
|
|
9
|
+
reads, mutations, hydration — is `@thomasfosterau/legation`; what lives here is the
|
|
10
|
+
Svelte shape of it, plus the two integrations a framework can do that the core
|
|
11
|
+
cannot: **reactive re-subscription** (a read re-runs when the `$state` its
|
|
12
|
+
thunk touches changes) and **SSR hydration** (a server-rendered page's rows
|
|
13
|
+
seeding the first client render).
|
|
14
|
+
|
|
15
|
+
Two entry points:
|
|
16
|
+
|
|
17
|
+
| Import | What it is |
|
|
18
|
+
| ------------------------------------- | --------------------------------------------------------------------------------------- |
|
|
19
|
+
| `@thomasfosterau/legation-svelte` | `createClient` / `setClient` plus every hook — reads, aggregates, mutations, undo |
|
|
20
|
+
| `@thomasfosterau/legation-svelte/kit` | the SvelteKit surface: client-group cookie, SSR snapshot `load`, snapshot-hydrated boot |
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
npm install @thomasfosterau/legation-svelte @thomasfosterau/legation effect svelte
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`@sveltejs/kit` is an **optional** peer dependency (`^2.0.0 || ^3.0.0`) — only
|
|
29
|
+
`@thomasfosterau/legation-svelte/kit` needs it, and only as a `Cookies` type, so a non-Kit
|
|
30
|
+
Svelte app pulls nothing.
|
|
31
|
+
|
|
32
|
+
## Effect version
|
|
33
|
+
|
|
34
|
+
Peers `effect ^4.0.2`, the floor its dependency `@thomasfosterau/effect-svelte` 0.6.0 requires (it drops to `^4.0.0` once an effect-svelte release peers that). Tested against `4.1.2` (the version CI pins), `4.0.2` (the floor) and `4.0.0` (CI's floor job runs every package's tests there on every PR); install one copy of `effect` in your app (ADR-0003).
|
|
35
|
+
|
|
36
|
+
## Booting the Client
|
|
37
|
+
|
|
38
|
+
`createClient` takes `Client.make`'s options and returns a handle; `setClient`
|
|
39
|
+
puts it in context for every hook below it. The app builds no Effect runtime
|
|
40
|
+
and imports nothing from `effect` to boot:
|
|
41
|
+
|
|
42
|
+
```svelte
|
|
43
|
+
<!-- +layout.svelte -->
|
|
44
|
+
<script lang="ts">
|
|
45
|
+
import { onDestroy } from "svelte";
|
|
46
|
+
import { createClient, setClient } from "@thomasfosterau/legation-svelte";
|
|
47
|
+
import { mutations, nodes } from "$lib/domain";
|
|
48
|
+
|
|
49
|
+
// No Client during SSR: the hooks render from their seeds there.
|
|
50
|
+
const client =
|
|
51
|
+
typeof document !== "undefined"
|
|
52
|
+
? createClient({ nodes, mutations, transport: { url: location.origin } })
|
|
53
|
+
: undefined;
|
|
54
|
+
setClient(client);
|
|
55
|
+
onDestroy(() => void client?.dispose());
|
|
56
|
+
</script>
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
- **`client.dispose()`** releases the Client: its sync loop stops and its store
|
|
60
|
+
and transport close. Call it when the app tears down; a second call does
|
|
61
|
+
nothing more.
|
|
62
|
+
- **On the server, pass no Client** (`setClient(undefined)`). Every hook then
|
|
63
|
+
renders from its `initial` seed, and only a hook that actually reaches for
|
|
64
|
+
the Client (a write, a read with no seed) fails, with `ClientUnavailable`.
|
|
65
|
+
No stand-in runtime is needed.
|
|
66
|
+
- **Custom layers** (an in-page server, a service-worker mirror, a
|
|
67
|
+
simulated-offline transport, the Kit's snapshot-hydrated `clientRuntime`):
|
|
68
|
+
build the `ManagedRuntime` yourself and pass `setClient(fromRuntime(runtime))`,
|
|
69
|
+
or `setClientRuntime(runtime)`, which is the same thing. `getClient()` returns
|
|
70
|
+
the handle in context (`Option`), and `client.runtime` is the runtime the
|
|
71
|
+
hooks run over, for Effect code that needs the Client directly.
|
|
72
|
+
|
|
73
|
+
## The hooks
|
|
74
|
+
|
|
75
|
+
The package root is the whole read/write surface — the hooks are the API, so
|
|
76
|
+
they are not behind a subpath. Each hook takes its
|
|
77
|
+
inputs as a **thunk** called inside `$effect`, so a read re-subscribes whenever
|
|
78
|
+
the reactive state the thunk touches changes — a paginated read whose cursor
|
|
79
|
+
lives in `$state` just works, and so does a detail read keyed on a route param.
|
|
80
|
+
Results are exposed as a reactive `current`
|
|
81
|
+
[`AsyncResult`](https://effect.website) (the engine's atom vocabulary): while
|
|
82
|
+
re-subscribing the previous value stays visible with `waiting: true`
|
|
83
|
+
(stale-while-revalidate).
|
|
84
|
+
|
|
85
|
+
- **`useQuery(() => read)`** / **`useNamedQuery(() => ({ name, args }))`** —
|
|
86
|
+
a declarative `Query.Read`; `include`d edges resolve onto each row's
|
|
87
|
+
`related`, so a join needs no separate hook. The thunk may return
|
|
88
|
+
`undefined` to opt out of reading entirely — no window is registered and
|
|
89
|
+
no request is issued, and `current` stays at its initial state rather than
|
|
90
|
+
becoming a successful empty result. Every hook below that takes a read
|
|
91
|
+
thunk (`useConnection`, `useAggregate`, `useAggregateGrouped` included)
|
|
92
|
+
supports the same opt-out.
|
|
93
|
+
- **`useConnection(() => read, { pageSize })`** — connection-style paging that
|
|
94
|
+
_grows_ one window rather than jumping between detached pages.
|
|
95
|
+
- **`useAggregate(() => read, agg)`** / **`useAggregateGrouped(() => read, agg, groupBy)`**
|
|
96
|
+
- **`useMutation()`** / **`useMutationWithAck()`** — a promise-returning
|
|
97
|
+
`mutate(name, args)`, optimistic on resolve; the `WithAck` form settles with
|
|
98
|
+
the backend's authoritative outcome.
|
|
99
|
+
- **`useMutationState(() => mutator?)`** — every journaled mutation's lifecycle.
|
|
100
|
+
- **`useUndo(manager)`** — `canUndo` / `canRedo` / `undo` / `redo` over an
|
|
101
|
+
`Undo.UndoManager`.
|
|
102
|
+
- **`setClient(client)`** / **`getClient()`** — the `Client` in Svelte context
|
|
103
|
+
(see [Booting the Client](#booting-the-client)). Call `setClient` once, high
|
|
104
|
+
in the tree, before any hook. `setClientRuntime(runtime)` /
|
|
105
|
+
`getClientRuntime()` are the same over a runtime the app built itself.
|
|
106
|
+
|
|
107
|
+
Every live hook accepts `{ initial }` to seed a synchronous `Success` before
|
|
108
|
+
the first emission — during SSR (where `$effect` never runs) the seed is what
|
|
109
|
+
renders, and on the client it makes the first render match the server markup
|
|
110
|
+
(no loading flash).
|
|
111
|
+
|
|
112
|
+
```svelte
|
|
113
|
+
<script lang="ts">
|
|
114
|
+
import { AsyncResult } from "effect/reactivity";
|
|
115
|
+
import { useQuery } from "@thomasfosterau/legation-svelte";
|
|
116
|
+
|
|
117
|
+
let cursor = $state<string | undefined>(undefined);
|
|
118
|
+
const page = useQuery(() => ({
|
|
119
|
+
collection: "products",
|
|
120
|
+
order: ["id"],
|
|
121
|
+
first: 20,
|
|
122
|
+
...(cursor === undefined ? {} : { after: cursor }),
|
|
123
|
+
}));
|
|
124
|
+
const rows = $derived(AsyncResult.getOrElse(page.current, () => []));
|
|
125
|
+
</script>
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
A read keyed on a selection that starts empty — the common master/detail
|
|
129
|
+
case — opts out by returning `undefined` instead of reaching for a sentinel
|
|
130
|
+
argument that matches nothing:
|
|
131
|
+
|
|
132
|
+
```svelte
|
|
133
|
+
<script lang="ts">
|
|
134
|
+
let selectedId = $state<string | undefined>(undefined);
|
|
135
|
+
const detail = useQuery(() =>
|
|
136
|
+
selectedId === undefined ? undefined : { collection: "products", where: { id: selectedId } }
|
|
137
|
+
);
|
|
138
|
+
</script>
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
While `selectedId` is `undefined`, `detail.current` is `Initial` — no window
|
|
142
|
+
is registered and no request is issued — and selecting a row subscribes just
|
|
143
|
+
as any other reactive input change would.
|
|
144
|
+
|
|
145
|
+
### Async Svelte
|
|
146
|
+
|
|
147
|
+
Every live hook also exposes an awaitable **`promise`** for Svelte's
|
|
148
|
+
`experimental.async` mode: it stays pending until the first data arrives —
|
|
149
|
+
so `await` inside a `<svelte:boundary>` renders the boundary's `pending`
|
|
150
|
+
snippet — and is replaced with an already-resolved promise on every
|
|
151
|
+
emission, so awaiting expressions re-run with fresh rows while Svelte keeps
|
|
152
|
+
the previous UI on screen (no pending flash after first load). A failed
|
|
153
|
+
stream rejects it into the boundary's `failed` snippet.
|
|
154
|
+
|
|
155
|
+
```svelte
|
|
156
|
+
<svelte:boundary>
|
|
157
|
+
<ul>
|
|
158
|
+
{#each await page.promise as row (row.id)}
|
|
159
|
+
<li>{row.value.name}</li>
|
|
160
|
+
{/each}
|
|
161
|
+
</ul>
|
|
162
|
+
{#snippet pending()}<p>Loading…</p>{/snippet}
|
|
163
|
+
</svelte:boundary>
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
The root entry ships as an uncompiled `.svelte.js` module (compiled by your
|
|
167
|
+
bundler's Svelte plugin, the standard Svelte-library convention — the package
|
|
168
|
+
carries a `svelte` export condition so the plugin picks it up). It requires
|
|
169
|
+
Svelte 5.40+: the hooks are runes, and the runtime context rides Svelte's
|
|
170
|
+
`createContext`. `@thomasfosterau/legation-svelte/kit` is rune-free and server-side, so
|
|
171
|
+
importing it pulls none of this in.
|
|
172
|
+
|
|
173
|
+
## SvelteKit
|
|
174
|
+
|
|
175
|
+
`@thomasfosterau/legation-svelte/kit` adds what SvelteKit shapes: the client-group cookie, an
|
|
176
|
+
SSR snapshot for a server `load`, and the snapshot-hydrated Client boot. The
|
|
177
|
+
hydration itself is core (`Hydration` in `@thomasfosterau/legation`) and the wiring is
|
|
178
|
+
`Client.make` — this subpath is the framework's share of it, nothing more.
|
|
179
|
+
It works with SvelteKit 2 and 3. The samples below import app modules through
|
|
180
|
+
Kit 2's `$lib` alias. Kit 3 removes `$lib` in favour of a Node subpath import
|
|
181
|
+
declared in the app's `package.json` (`"imports": { "#lib/*": "./src/lib/*" }`),
|
|
182
|
+
so there write `#lib/domain.js` instead; or keep `$lib` with the (deprecated)
|
|
183
|
+
`alias: { $lib: "src/lib" }` option in the SvelteKit config.
|
|
184
|
+
|
|
185
|
+
The SSR model (see `Hydration` in `@thomasfosterau/legation`): each server render runs
|
|
186
|
+
a **request-scoped engine** against the app's sync server, under the
|
|
187
|
+
**browser's client group id**, and ships the dehydrated snapshot (rows +
|
|
188
|
+
CVR cookie) in the page data. The client hydrates the snapshot into its
|
|
189
|
+
durable store **only when the store is empty** — a repeat visit's rows,
|
|
190
|
+
cookie, and journalled offline mutations are all fresher — and the first
|
|
191
|
+
client pull continues the CVR conversation where the server left off: no
|
|
192
|
+
refetch, no reset.
|
|
193
|
+
|
|
194
|
+
### Server
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
// +page.server.ts — with @thomasfosterau/effect-sveltekit's serverLoad (or any
|
|
198
|
+
// Effect-based load), the app's sync server provided as ServerHandler.
|
|
199
|
+
import { Effect } from "effect";
|
|
200
|
+
import { loadSnapshot } from "@thomasfosterau/legation-svelte/kit";
|
|
201
|
+
import { allProducts, nodes } from "$lib/domain";
|
|
202
|
+
|
|
203
|
+
export const load = serverLoad((event) =>
|
|
204
|
+
Effect.gen(function* () {
|
|
205
|
+
const { snapshot, clientGroupId } = yield* loadSnapshot(event.cookies, {
|
|
206
|
+
nodes,
|
|
207
|
+
reads: [allProducts],
|
|
208
|
+
});
|
|
209
|
+
return { snapshot, clientGroupId };
|
|
210
|
+
})
|
|
211
|
+
);
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
- **`clientGroupId(cookies, options?)`** — read-or-mint the browser's client
|
|
215
|
+
group id (a plain, non-`httpOnly` cookie; it carries no authority — identity
|
|
216
|
+
is resolved server-side via `IdentityResolver`).
|
|
217
|
+
- **`loadSnapshot(cookies, { nodes, reads })`** — resolve the group id and
|
|
218
|
+
produce the page's snapshot over a request-scoped engine (a thin wrapper of
|
|
219
|
+
core's `Hydration.snapshot`).
|
|
220
|
+
|
|
221
|
+
### Client
|
|
222
|
+
|
|
223
|
+
```ts
|
|
224
|
+
// $lib/engine.ts — boot once, hand the runtime to the hooks.
|
|
225
|
+
import { clientRuntime } from "@thomasfosterau/legation-svelte/kit";
|
|
226
|
+
import { IndexedDbClientStore } from "@thomasfosterau/legation-browser";
|
|
227
|
+
import { Identity } from "@thomasfosterau/legation";
|
|
228
|
+
import { mutations, nodes, shellReads } from "$lib/domain";
|
|
229
|
+
|
|
230
|
+
export const boot = (data: {
|
|
231
|
+
snapshot: Snapshot;
|
|
232
|
+
clientGroupId: string;
|
|
233
|
+
identity: Identity.Fingerprint;
|
|
234
|
+
}) =>
|
|
235
|
+
clientRuntime({
|
|
236
|
+
nodes,
|
|
237
|
+
mutations,
|
|
238
|
+
clientGroupId: data.clientGroupId,
|
|
239
|
+
store: IndexedDbClientStore.layerWindow({ identity: data.identity }),
|
|
240
|
+
transport: rpcTransportLayer,
|
|
241
|
+
snapshot: data.snapshot,
|
|
242
|
+
subscribe: shellReads,
|
|
243
|
+
});
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
> **Pass an `identity`.** A durable replica belongs to exactly one resolved
|
|
247
|
+
> identity, and an identity change is a reset, not a rebase (CONTEXT.md,
|
|
248
|
+
> "Identity boundary"). Without it, a multi-tenant app on a shared device
|
|
249
|
+
> boots the next person onto the last person's private rows — the replica is
|
|
250
|
+
> the freshest thing on the machine, and nothing else in the engine has a
|
|
251
|
+
> reason to distrust it. The value is an **opaque fingerprint** the app
|
|
252
|
+
> computes — a hash of the session subject, never PII and never a credential —
|
|
253
|
+
> and `Identity.anonymous` while nobody is signed in, so the first sign-in is
|
|
254
|
+
> recognised as the one benign upgrade and keeps its warm replica. Call
|
|
255
|
+
> `client.reset(fingerprint)` when the session changes mid-page.
|
|
256
|
+
>
|
|
257
|
+
> For a device with two regular users, `IndexedDbClientStore.layerNamespaced`
|
|
258
|
+
> gives each principal its own replica instead, so switching back costs no
|
|
259
|
+
> re-pull.
|
|
260
|
+
|
|
261
|
+
```svelte
|
|
262
|
+
<!-- +layout.svelte -->
|
|
263
|
+
<script lang="ts">
|
|
264
|
+
import { fromRuntime, setClient } from "@thomasfosterau/legation-svelte";
|
|
265
|
+
const { data, children } = $props();
|
|
266
|
+
setClient(fromRuntime(boot(data)));
|
|
267
|
+
</script>
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
Components render SSR markup from the snapshot and hydrate flash-free by
|
|
271
|
+
seeding the rune hooks:
|
|
272
|
+
|
|
273
|
+
```svelte
|
|
274
|
+
<script lang="ts">
|
|
275
|
+
import { Hydration } from "@thomasfosterau/legation";
|
|
276
|
+
import { useQuery } from "@thomasfosterau/legation-svelte";
|
|
277
|
+
import { allProducts, nodes } from "$lib/domain";
|
|
278
|
+
const { data } = $props();
|
|
279
|
+
const products = useQuery(() => allProducts, {
|
|
280
|
+
initial: Hydration.rowsFor(data.snapshot, nodes, allProducts),
|
|
281
|
+
});
|
|
282
|
+
</script>
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
### Form actions run Mutations
|
|
286
|
+
|
|
287
|
+
A form action that can name the client group runs a named Mutation as one of
|
|
288
|
+
that group's mutations (ADR-0030, amendment #215): the same definition the JS
|
|
289
|
+
path journals, so a write is authored once. The mechanism is core
|
|
290
|
+
(`server.mutate` in `@thomasfosterau/legation`); `/kit` reads the
|
|
291
|
+
group cookie and the form's hidden fields, and gives the enhanced submit.
|
|
292
|
+
|
|
293
|
+
Each rendered form carries a **form client**: a fresh id with mutation id
|
|
294
|
+
`1`, minted by the page's `load` and rendered in hidden fields. A resubmission
|
|
295
|
+
of the same rendered form is the same mutation, so the ledger applies it once
|
|
296
|
+
and the action answers the original ruling.
|
|
297
|
+
|
|
298
|
+
```ts
|
|
299
|
+
// +page.server.ts
|
|
300
|
+
import { Effect } from "effect";
|
|
301
|
+
import {
|
|
302
|
+
FORM_CLIENT_FIELD,
|
|
303
|
+
FORM_MUTATION_FIELD,
|
|
304
|
+
RECEIPT_KEY,
|
|
305
|
+
formMutationTarget,
|
|
306
|
+
mintFormClient,
|
|
307
|
+
runFormMutation,
|
|
308
|
+
} from "@thomasfosterau/legation-svelte/kit";
|
|
309
|
+
import { addToCart } from "$lib/domain";
|
|
310
|
+
import { server } from "$lib/server/sync";
|
|
311
|
+
|
|
312
|
+
export const load = () => ({ form: mintFormClient() });
|
|
313
|
+
|
|
314
|
+
export const actions = {
|
|
315
|
+
add: async ({ cookies, request }) => {
|
|
316
|
+
const data = await request.formData();
|
|
317
|
+
// The form layer (e.g. effect-forms) validates the fields first.
|
|
318
|
+
const args = { productId: String(data.get("productId")) };
|
|
319
|
+
const target = formMutationTarget(cookies, data);
|
|
320
|
+
if (target === undefined) {
|
|
321
|
+
// No client-group cookie: an out-of-band writer, as before.
|
|
322
|
+
return writeThroughApiAndBroadcast(args);
|
|
323
|
+
}
|
|
324
|
+
const receipt = await Effect.runPromise(runFormMutation(server, target, addToCart, args));
|
|
325
|
+
return { [RECEIPT_KEY]: receipt };
|
|
326
|
+
},
|
|
327
|
+
};
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
```svelte
|
|
331
|
+
<!-- +page.svelte -->
|
|
332
|
+
<script lang="ts">
|
|
333
|
+
import { enhance } from "$app/forms";
|
|
334
|
+
import {
|
|
335
|
+
FORM_CLIENT_FIELD,
|
|
336
|
+
FORM_MUTATION_FIELD,
|
|
337
|
+
enhanceFormMutation,
|
|
338
|
+
} from "@thomasfosterau/legation-svelte/kit";
|
|
339
|
+
import { getClientRuntime } from "@thomasfosterau/legation-svelte";
|
|
340
|
+
import { addToCart } from "$lib/domain";
|
|
341
|
+
const { data } = $props();
|
|
342
|
+
const submit = enhanceFormMutation(getClientRuntime(), {
|
|
343
|
+
mutation: addToCart,
|
|
344
|
+
args: (form) => ({ productId: String(form.get("productId")) }),
|
|
345
|
+
});
|
|
346
|
+
</script>
|
|
347
|
+
|
|
348
|
+
<form method="POST" action="?/add" use:enhance={submit}>
|
|
349
|
+
<input type="hidden" name={FORM_CLIENT_FIELD} value={data.form.clientId} />
|
|
350
|
+
<input type="hidden" name={FORM_MUTATION_FIELD} value={data.form.mutationId} />
|
|
351
|
+
<button name="productId" value="p1">Add</button>
|
|
352
|
+
</form>
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
- **No JS:** the form posts its rendered form client; the action runs the
|
|
356
|
+
Mutation and the page re-renders from the server.
|
|
357
|
+
- **Enhanced:** each submit mints a fresh form client into the posted fields
|
|
358
|
+
and applies the Mutation's local handler to the optimistic overlay under it,
|
|
359
|
+
**without journaling it** (the action delivers it; a push would apply it
|
|
360
|
+
twice). On the receipt, `acknowledged` moves the effect to the unpulled
|
|
361
|
+
tail and `rejected` drops it (`onReceipt` gets the reason). The effect is
|
|
362
|
+
released only by the pull whose `lastMutationIds` covers the form client,
|
|
363
|
+
never on the action's response, exactly as for a pushed mutation. The
|
|
364
|
+
post-action `load` re-run is off by default (`refreshAll: false`); the
|
|
365
|
+
overlay already shows the write. Offline, the submit fails as the network
|
|
366
|
+
does (ADR-0030 open point 6).
|
|
367
|
+
- Over a connected host (`Client.connect`, a mirror of a service-worker or
|
|
368
|
+
elected-host engine) the host's engine holds the overlay for every tab. A
|
|
369
|
+
host from a release that predates this answers the submit with a
|
|
370
|
+
`TransportError` rather than leaving it waiting.
|
|
371
|
+
|
|
372
|
+
**One path per write.** A form-action Mutation and a façade write (#169,
|
|
373
|
+
`@thomasfosterau/legation-jsonapi`) are two interception points for the same
|
|
374
|
+
intent. Both name the mutation by id, neither fabricates a status the server
|
|
375
|
+
did not give, and both release the optimistic effect only when a pull
|
|
376
|
+
observes it. A façade write journals a plain `fetch` on the client and
|
|
377
|
+
answers `202 pending`; a form-action Mutation runs on the server and answers
|
|
378
|
+
the ruling. Write a submission through one of them, never both: the two
|
|
379
|
+
deliveries carry different client ids, so the backend would apply the write
|
|
380
|
+
twice.
|
|
381
|
+
|
|
382
|
+
### No-JS baseline for other writers
|
|
383
|
+
|
|
384
|
+
An action that cannot name the client group (no cookie, another service, a
|
|
385
|
+
cron) stays an out-of-band writer (ADR-0019): commit the write through the
|
|
386
|
+
app's API, then `server.broadcast({ collection })` for the touched
|
|
387
|
+
collections so live subscribers pull. The next full-page render re-snapshots.
|
|
388
|
+
|
|
389
|
+
## License
|
|
390
|
+
|
|
391
|
+
MIT
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import * as ManagedRuntime from "effect/ManagedRuntime";
|
|
2
|
+
import * as Option from "effect/Option";
|
|
3
|
+
import { Client } from "@thomasfosterau/legation";
|
|
4
|
+
import * as Schema from "effect/Schema";
|
|
5
|
+
declare const ClientUnavailable_base: Schema.Class<ClientUnavailable, Schema.TaggedStruct<"ClientUnavailable", {
|
|
6
|
+
readonly message: Schema.String;
|
|
7
|
+
}>, import("effect/Cause").YieldableError & {
|
|
8
|
+
readonly brand: unique symbol;
|
|
9
|
+
}>;
|
|
10
|
+
/**
|
|
11
|
+
* A hook reached for the Client where none was set: `setClient(undefined)`,
|
|
12
|
+
* as on the server, where a hook renders from its `initial` seed and fails
|
|
13
|
+
* only when it needs the Client itself.
|
|
14
|
+
*/
|
|
15
|
+
declare class ClientUnavailable extends ClientUnavailable_base {}
|
|
16
|
+
/**
|
|
17
|
+
* A runtime carrying the {@link Client.Client} — ADR-0022's one-per-thread
|
|
18
|
+
* object. The rune hooks run over it: reads are declarative `Query.Read`s
|
|
19
|
+
* and where they are answered (replica, host, server fallthrough) is the
|
|
20
|
+
* Client's business, never the component's.
|
|
21
|
+
*/
|
|
22
|
+
type ClientRuntime = ManagedRuntime.ManagedRuntime<Client.Client, never>;
|
|
23
|
+
/**
|
|
24
|
+
* The Client an app hands to its component tree (#147): what {@link setClient}
|
|
25
|
+
* takes, and what {@link createClient} and {@link fromRuntime} build.
|
|
26
|
+
*/
|
|
27
|
+
interface ClientHandle {
|
|
28
|
+
/** The runtime the hooks run over, for Effect code that needs the Client directly. */
|
|
29
|
+
readonly runtime: ClientRuntime;
|
|
30
|
+
/**
|
|
31
|
+
* Release the Client: stop its sync loop and close its store and transport.
|
|
32
|
+
* Call it when the app tears down (a root layout's cleanup). Calling it again
|
|
33
|
+
* does nothing more.
|
|
34
|
+
*/
|
|
35
|
+
readonly dispose: () => Promise<void>;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Build the Client a Svelte app boots with (#147): `Client.make`'s options in,
|
|
39
|
+
* a {@link ClientHandle} out, with no Effect runtime for the app to build. The
|
|
40
|
+
* Client is built on first use, so creating the handle does no work; with
|
|
41
|
+
* `transport: { url }` it is the standard remote setup.
|
|
42
|
+
*/
|
|
43
|
+
declare const createClient: (options: Client.MakeOptions) => ClientHandle;
|
|
44
|
+
/**
|
|
45
|
+
* Wrap a runtime the app built itself, for the custom-layer path: an in-page
|
|
46
|
+
* server, a service-worker mirror, a simulated-offline transport.
|
|
47
|
+
*/
|
|
48
|
+
declare const fromRuntime: (runtime: ClientRuntime) => ClientHandle;
|
|
49
|
+
/**
|
|
50
|
+
* Provide the Client to descendant components. Call once, high in the tree,
|
|
51
|
+
* before any rune hook. `undefined` (on the server, say) renders every hook
|
|
52
|
+
* from its `initial` seed; a hook that needs the Client then fails with
|
|
53
|
+
* {@link ClientUnavailable}.
|
|
54
|
+
*/
|
|
55
|
+
declare const setClient: (client?: ClientHandle) => void;
|
|
56
|
+
/** The Client put in context by {@link setClient}: `None` when it was given none. */
|
|
57
|
+
declare const getClient: () => Option.Option<ClientHandle>;
|
|
58
|
+
/**
|
|
59
|
+
* Provide a Client runtime the app built itself: the escape hatch beside
|
|
60
|
+
* {@link setClient}, the same as `setClient(fromRuntime(runtime))`.
|
|
61
|
+
*/
|
|
62
|
+
declare const setClientRuntime: (runtime: ClientRuntime) => void;
|
|
63
|
+
/** Read the Client runtime put in context by {@link setClient} or {@link setClientRuntime}. */
|
|
64
|
+
declare const getClientRuntime: () => ClientRuntime;
|
|
65
|
+
export { getClient as a, setClientRuntime as c, fromRuntime as i, ClientUnavailable as l, ClientRuntime as n, getClientRuntime as o, createClient as r, setClient as s, ClientHandle as t };
|
|
66
|
+
//# sourceMappingURL=Runtime-DAQq0HCa.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Runtime-DAQq0HCa.d.ts","names":[],"sources":["../src/unavailable.ts","../src/Runtime.ts"],"mappings":";;;;;;;WAcoB;;;;;;;cAFP,0BAA0B;;;;;;;KC0B3B,gBAAgB,eAAe,eAAe,OAAO;;;;;UAMhD;;WAEN,SAAS;;;;;;WAMT,eAAe;;;;;;;;cASb,eAAgB,SAAS,OAAO,gBAAc;;;;;cAW9C,cAAe,SAAS,kBAAgB;;;;;;;cAyBxC,YAAa,SAAS;;cAMtB,iBAAgB,OAAO,OAAO;;;;;cAO9B,mBAAoB,SAAS;;cAK7B,wBAAuB"}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { a as getClient, c as setClientRuntime, i as fromRuntime, l as ClientUnavailable, n as ClientRuntime, o as getClientRuntime, r as createClient, s as setClient, t as ClientHandle } from "./Runtime-DAQq0HCa.js";
|
|
2
|
+
import { Aggregate, Client, Query } from "@thomasfosterau/legation";
|
|
3
|
+
import { AsyncResult } from "effect/reactivity";
|
|
4
|
+
import { ClientEngine, MutationState, Undo } from "@thomasfosterau/legation/internal";
|
|
5
|
+
/** A live result: `current` re-renders whatever reads it on every emission. */
|
|
6
|
+
export interface Live<A, E = never> {
|
|
7
|
+
/**
|
|
8
|
+
* The current result. `Initial` until the first emission, then `Success`
|
|
9
|
+
* per emission; while re-subscribing after an input change the previous
|
|
10
|
+
* result is kept with `waiting: true` (stale-while-revalidate). An
|
|
11
|
+
* `initial` seed starts as a non-waiting `Success` — "we have data", no
|
|
12
|
+
* spinner over server-rendered content — until the first real emission.
|
|
13
|
+
*/
|
|
14
|
+
readonly current: AsyncResult.AsyncResult<A, E>;
|
|
15
|
+
/**
|
|
16
|
+
* The awaitable view of the same result, for Async Svelte
|
|
17
|
+
* (`experimental.async`): pending until the first data arrives — so
|
|
18
|
+
* `await rows.promise` in a derived or template suspends the enclosing
|
|
19
|
+
* `<svelte:boundary>`'s `pending` snippet — then an already-resolved
|
|
20
|
+
* promise replaced on every emission (and on input change), so awaiting
|
|
21
|
+
* expressions re-run with fresh rows while Svelte keeps the previous UI
|
|
22
|
+
* on screen (stale-while-revalidate). A failed stream rejects it, which
|
|
23
|
+
* the boundary's `failed` snippet catches.
|
|
24
|
+
*/
|
|
25
|
+
readonly promise: Promise<A>;
|
|
26
|
+
}
|
|
27
|
+
/** Options shared by the live hooks. */
|
|
28
|
+
export interface LiveOptions<A> {
|
|
29
|
+
/**
|
|
30
|
+
* Seed `current` with a synchronous `Success` before the first emission —
|
|
31
|
+
* during SSR (where `$effect` never runs) this is what renders, and on the
|
|
32
|
+
* client it makes the first render match the server markup (no loading
|
|
33
|
+
* flash). Compute it from the rows the page's load already fetched.
|
|
34
|
+
*/
|
|
35
|
+
readonly initial?: A;
|
|
36
|
+
}
|
|
37
|
+
/** The rows a read resolves to: node values with their included edges. */
|
|
38
|
+
export type ResolvedRows = ReadonlyArray<Client.ResolvedRow>;
|
|
39
|
+
/**
|
|
40
|
+
* Live result of a declarative read, re-subscribing when the thunk's
|
|
41
|
+
* reactive inputs change. `include`d edges resolve onto each row's
|
|
42
|
+
* `related`, windowed per the edge's declared order (ADR-0024). Observing
|
|
43
|
+
* IS subscribing: the read's windows are registered for the hook's
|
|
44
|
+
* lifetime through the Client's ledger.
|
|
45
|
+
*
|
|
46
|
+
* The thunk may return `undefined` to opt out (#141) — "nothing right now",
|
|
47
|
+
* for a read keyed on a selection that starts empty. No window is
|
|
48
|
+
* registered and no request is issued; `current` stays `Initial` rather
|
|
49
|
+
* than becoming a `Success` over zero rows, so an opted-out read stays
|
|
50
|
+
* distinguishable from one that legitimately matched nothing. The thunk
|
|
51
|
+
* stays reactive either way: flipping the condition subscribes/unsubscribes
|
|
52
|
+
* with the same stale-while-revalidate behaviour as any other input change.
|
|
53
|
+
*/
|
|
54
|
+
export declare const useQuery: (read: () => Query.Read | undefined, options?: LiveOptions<ResolvedRows>) => Live<ResolvedRows>;
|
|
55
|
+
/** A live, growing connection over a read's root window (ADR-0024). */
|
|
56
|
+
export interface Connection extends Live<ResolvedRows> {
|
|
57
|
+
/**
|
|
58
|
+
* Widen the window by one page. A no-op while a widen is still resolving
|
|
59
|
+
* or once {@link hasMore} is false — call it freely from a click handler.
|
|
60
|
+
*/
|
|
61
|
+
readonly loadMore: () => void;
|
|
62
|
+
/**
|
|
63
|
+
* Whether another page may exist: `true` while the current window is
|
|
64
|
+
* saturated (it holds as many rows as it asked for). Reactive. At a dataset
|
|
65
|
+
* whose size is an exact multiple of the page size this stays `true` for one
|
|
66
|
+
* more `loadMore`, which then resolves empty and flips it `false` — the
|
|
67
|
+
* standard cost of limit-based paging without a separate total count.
|
|
68
|
+
*/
|
|
69
|
+
readonly hasMore: boolean;
|
|
70
|
+
/** Whether a page (the first or a widen) is currently resolving. Reactive. */
|
|
71
|
+
readonly loading: boolean;
|
|
72
|
+
/** Collapse back to the first page. */
|
|
73
|
+
readonly reset: () => void;
|
|
74
|
+
}
|
|
75
|
+
/** Options for {@link useConnection}. */
|
|
76
|
+
export interface ConnectionOptions extends LiveOptions<ResolvedRows> {
|
|
77
|
+
/** Rows per page — the window grows by this on each `loadMore`. Default 20. */
|
|
78
|
+
readonly pageSize?: number;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Connection-style paging over a read's root window: a live result that
|
|
82
|
+
* *grows* rather than jumping between detached pages. `loadMore` widens the
|
|
83
|
+
* one window by a page (ADR-0024 — "load more widens the subset"), so each
|
|
84
|
+
* page's rows accumulate and the previous ones stay on screen through the
|
|
85
|
+
* widen (stale-while-revalidate); the engine only ever syncs the current
|
|
86
|
+
* window, never the whole collection.
|
|
87
|
+
*
|
|
88
|
+
* Per-parent paging falls out of instantiating the hook per parent: scope
|
|
89
|
+
* the read's `where` to a parent id and give each parent its own
|
|
90
|
+
* `useConnection` (one per `{#each parents}` row), and each parent's list
|
|
91
|
+
* pages independently, with its own `hasMore` and window. The read thunk
|
|
92
|
+
* stays reactive — when its base (collection/where/order/include) changes the
|
|
93
|
+
* connection re-bases to the first page automatically; the window it owns
|
|
94
|
+
* (`first`/`after`) is ignored on the passed read.
|
|
95
|
+
*
|
|
96
|
+
* ```svelte
|
|
97
|
+
* const reviews = useConnection(() => productReviews(id), { pageSize: 10 });
|
|
98
|
+
* // {#each await reviews.promise as row (row.value.id)} … {/each}
|
|
99
|
+
* // <button onclick={reviews.loadMore} disabled={!reviews.hasMore}>More</button>
|
|
100
|
+
* ```
|
|
101
|
+
*
|
|
102
|
+
* The read thunk may return `undefined` to opt out (#141): no window is
|
|
103
|
+
* registered and no request issued, `current` stays `Initial`, and paging
|
|
104
|
+
* state (`hasMore`/`loading`) reads as idle until the thunk starts returning
|
|
105
|
+
* a read again.
|
|
106
|
+
*
|
|
107
|
+
* **Engine-level retargeting (#133).** When the read has no `include`d edges
|
|
108
|
+
* and the view is engine-backed (`ClientView.observeWindow` present — a
|
|
109
|
+
* `Mirror` has none, see `docs/observing-row-changes.md`), `loadMore`/`reset`
|
|
110
|
+
* widen or shrink the SAME open observation's window via
|
|
111
|
+
* `ClientEngine.observeWindow`'s `retarget` — reusing `LiveView`'s maintained
|
|
112
|
+
* order instead of tearing the stream down and resubscribing wider each page.
|
|
113
|
+
* The stream itself is still built once per *base* change (collection/where/
|
|
114
|
+
* order/include), exactly as before; only `pages` no longer forces a rebuild.
|
|
115
|
+
* A joined read, or a mirror-backed connection, falls back to the original
|
|
116
|
+
* resubscribe-per-page behavior, unchanged.
|
|
117
|
+
*/
|
|
118
|
+
export declare const useConnection: (read: () => Query.Read | undefined, options?: ConnectionOptions) => Connection;
|
|
119
|
+
/**
|
|
120
|
+
* Live result of a named query (server-resolved), with reactive name/args.
|
|
121
|
+
* The thunk may return `undefined` to opt out (#141): see {@link useQuery}.
|
|
122
|
+
*/
|
|
123
|
+
export declare const useNamedQuery: (query: () => {
|
|
124
|
+
readonly name: string;
|
|
125
|
+
readonly args: unknown;
|
|
126
|
+
} | undefined, options?: LiveOptions<ReadonlyArray<ClientEngine.LocalRow>>) => Live<ReadonlyArray<ClientEngine.LocalRow>>;
|
|
127
|
+
/**
|
|
128
|
+
* Live aggregate over a read's root window (count/sum/min/max/avg). Local
|
|
129
|
+
* tier only — the aggregate sees the rows this client has synced. The read
|
|
130
|
+
* thunk may return `undefined` to opt out (#141): see {@link useQuery}.
|
|
131
|
+
*/
|
|
132
|
+
export declare const useAggregate: (read: () => Query.Read | undefined, aggregate: Aggregate.Aggregate, options?: LiveOptions<Aggregate.AggregateValue>) => Live<Aggregate.AggregateValue>;
|
|
133
|
+
/**
|
|
134
|
+
* Live grouped aggregate over a read's root window, keyed by
|
|
135
|
+
* `String(row.value[groupBy])`. The read thunk may return `undefined` to
|
|
136
|
+
* opt out (#141): see {@link useQuery}.
|
|
137
|
+
*/
|
|
138
|
+
export declare const useAggregateGrouped: (read: () => Query.Read | undefined, aggregate: Aggregate.Aggregate, groupBy: string, options?: LiveOptions<Record<string, Aggregate.AggregateValue>>) => Live<Record<string, Aggregate.AggregateValue>>;
|
|
139
|
+
/**
|
|
140
|
+
* A `mutate(mutation, args)` bound to the context Client, resolving on
|
|
141
|
+
* journal (the write is applied optimistically before the promise settles).
|
|
142
|
+
*/
|
|
143
|
+
export declare const useMutation: () => ((mutation: string, args: unknown) => Promise<void>);
|
|
144
|
+
/**
|
|
145
|
+
* Like {@link useMutation}, but resolving with the mutation's authoritative
|
|
146
|
+
* outcome once the backend settles it.
|
|
147
|
+
*/
|
|
148
|
+
export declare const useMutationWithAck: () => ((mutation: string, args: unknown) => Promise<ClientEngine.MutationOutcome>);
|
|
149
|
+
/**
|
|
150
|
+
* Reactive mutation-state list (#116) — TanStack Query's `useMutationState`
|
|
151
|
+
* analogue: every journaled mutation's current lifecycle state (`pending` →
|
|
152
|
+
* `acknowledged` | `rejected`), oldest-journaled first. Pass a `mutator`
|
|
153
|
+
* thunk to scope to one mutator (reactive, like {@link useQuery}'s `read`);
|
|
154
|
+
* omit it (or return `undefined`) for every mutation. Works identically over
|
|
155
|
+
* an owned engine and a connected host (ADR-0021) — the same states either
|
|
156
|
+
* way.
|
|
157
|
+
*/
|
|
158
|
+
export declare const useMutationState: (mutator?: () => string | undefined) => Live<ReadonlyArray<MutationState.MutationState>>;
|
|
159
|
+
/** Live undo/redo availability plus the two commands. */
|
|
160
|
+
export interface UndoControls {
|
|
161
|
+
/** Whether an undo is currently possible. Reactive. */
|
|
162
|
+
readonly canUndo: boolean;
|
|
163
|
+
/** Whether a redo is currently possible. Reactive. */
|
|
164
|
+
readonly canRedo: boolean;
|
|
165
|
+
/** Run the manager's `undo` (a no-op when nothing is undoable). */
|
|
166
|
+
readonly undo: () => Promise<void>;
|
|
167
|
+
/** Run the manager's `redo` (a no-op when nothing is redoable). */
|
|
168
|
+
readonly redo: () => Promise<void>;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Undo/redo over a manager's history as runes. Build one manager per client
|
|
172
|
+
* with `Undo.make(handle)` — a `Client` or a `ClientEngine` both work
|
|
173
|
+
* (issue #223) — and call this during component initialisation.
|
|
174
|
+
*/
|
|
175
|
+
export declare const useUndo: <E>(manager: Undo.UndoManager<E>) => UndoControls;
|
|
176
|
+
export { ClientHandle, ClientRuntime, ClientUnavailable, createClient, fromRuntime, getClient, getClientRuntime, setClient, setClientRuntime };
|
|
177
|
+
//# sourceMappingURL=index.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.svelte.d.ts","names":[],"sources":["../src/hooks.svelte.ts"],"mappings":";;;;;iBAyCiB,KAAK,GAAG;;;;;;;;WAQd,SAAS,YAAY,YAAY,GAAG;;;;;;;;;;;WAWpC,SAAS,QAAQ;;;iBAIX,YAAY;;;;;;;WAOlB,UAAU;;;YAIT,eAAe,cAAc,OAAO;;;;;;;;;;;;;;;;qBAyFnC,WACX,YAAY,MAAM,kBAClB,UAAU,YAAY,kBACrB,KAAK;;iBAYS,mBAAmB,KAAK;;;;;WAK9B;;;;;;;;WAQA;;WAEA;;WAEA;;;iBAIM,0BAA0B,YAAY;;WAE5C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;qBA+CE,gBACX,YAAY,MAAM,kBAClB,UAAU,sBACT;;;;;qBAyGU,gBACX;WAAwB;WAAuB;eAC/C,UAAU,YAAY,cAAc,aAAa,eAChD,KAAK,cAAc,aAAa;;;;;;qBAiBtB,eACX,YAAY,MAAM,kBAClB,WAAW,UAAU,WACrB,UAAU,YAAY,UAAU,oBAC/B,KAAK,UAAU;;;;;;qBAoBL,sBACX,YAAY,MAAM,kBAClB,WAAW,UAAU,WACrB,iBACA,UAAU,YAAY,eAAe,UAAU,qBAC9C,KAAK,eAAe,UAAU;;;;;qBAoBpB,qBAAoB,kBAAkB,kBAAkB;;;;;qBAUxD,4BACX,kBACA,kBACG,QAAQ,aAAa;;;;;;;;;;qBAiBb,mBACX,uCACC,KAAK,cAAc,cAAc;;iBAkBnB;;WAEN;;WAEA;;WAEA,YAAY;;WAEZ,YAAY;;;;;;;qBA4BV,UAAW,GAAG,SAAS,KAAK,YAAY,OAAK"}
|