@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/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
- # Temporary Holding Version
1
+ # @thomasfosterau/legation-svelte
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 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"}