@thomasfosterau/legation-svelte 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.
@@ -0,0 +1,401 @@
1
+ import { createRuntimeContext, useLiveStream } from "@thomasfosterau/effect-svelte";
2
+ import * as Effect from "effect/Effect";
3
+ import * as Layer from "effect/Layer";
4
+ import * as ManagedRuntime from "effect/ManagedRuntime";
5
+ import * as Option from "effect/Option";
6
+ import { getContext, hasContext, setContext } from "svelte";
7
+ import { Aggregate, Client, Query } from "@thomasfosterau/legation";
8
+ import * as Schema from "effect/Schema";
9
+ import * as Context from "effect/Context";
10
+ import * as Fiber from "effect/Fiber";
11
+ import * as HashMap from "effect/HashMap";
12
+ import * as P from "effect/Predicate";
13
+ import * as R from "effect/Record";
14
+ import * as Stream from "effect/Stream";
15
+ import { AsyncResult } from "effect/reactivity";
16
+ // src/unavailable.ts
17
+ /**
18
+ * The failure of a hook that reaches for a Client where none was set (#147):
19
+ * `setClient(undefined)`, as on the server. Kept apart from `Runtime.ts` so the
20
+ * hooks can raise it without importing the context module's public surface.
21
+ */
22
+ /**
23
+ * A hook reached for the Client where none was set: `setClient(undefined)`,
24
+ * as on the server, where a hook renders from its `initial` seed and fails
25
+ * only when it needs the Client itself.
26
+ */
27
+ var ClientUnavailable = class extends Schema.TaggedError()("ClientUnavailable", { message: Schema.String }) {};
28
+ /** The {@link ClientUnavailable} a hook fails with when no Client was set. */
29
+ const unavailable = () => new ClientUnavailable({ message: "@thomasfosterau/legation-svelte: no Client here (setClient was given none, as on the server). A hook renders from its `initial` seed without one; this one needed the Client." });
30
+ // src/Runtime.ts
31
+ /**
32
+ * Svelte client context
33
+ *
34
+ * The rune hooks reach the {@link Client.Client} — ADR-0022's one-per-thread
35
+ * object, so where a read is answered (this thread's engine, a worker-hosted
36
+ * one, the server) is configuration the components never see.
37
+ *
38
+ * An app builds a {@link ClientHandle} with {@link createClient} (#147) and
39
+ * hands it to the component tree with {@link setClient}; the hooks read it
40
+ * back, so they must be called during component initialisation (the same
41
+ * rule Svelte's `getContext` follows). The runtime the hooks run over is an
42
+ * implementation detail of the handle. An app with its own layers (an
43
+ * in-page server, a service-worker mirror, a simulated-offline transport)
44
+ * builds the runtime itself and wraps it with {@link fromRuntime}, or passes
45
+ * it to {@link setClientRuntime}.
46
+ *
47
+ * On the server there may be no Client at all: `setClient(undefined)` renders
48
+ * every hook from its `initial` seed, and only a hook that actually reaches
49
+ * for the Client (a write, an unseeded read) fails, with
50
+ * {@link ClientUnavailable}.
51
+ */
52
+ /**
53
+ * Build the Client a Svelte app boots with (#147): `Client.make`'s options in,
54
+ * a {@link ClientHandle} out, with no Effect runtime for the app to build. The
55
+ * Client is built on first use, so creating the handle does no work; with
56
+ * `transport: { url }` it is the standard remote setup.
57
+ */
58
+ const createClient = (options) => fromRuntime(ManagedRuntime.make(Layer.effect(Client.Client)(Effect.map(Client.make(options), (made) => made.client))));
59
+ /**
60
+ * Wrap a runtime the app built itself, for the custom-layer path: an in-page
61
+ * server, a service-worker mirror, a simulated-offline transport.
62
+ */
63
+ const fromRuntime = (runtime) => ({
64
+ runtime,
65
+ dispose: () => runtime.dispose()
66
+ });
67
+ const clientContext = createRuntimeContext("@thomasfosterau/legation-svelte — call setClient(createClient(…)) higher in the component tree");
68
+ const handleKey = Symbol.for("@thomasfosterau/legation-svelte/ClientHandle");
69
+ /**
70
+ * The runtime a tree with no Client runs its hooks over. Building it is lazy,
71
+ * so it costs nothing until a hook reaches for the Client, and then it fails.
72
+ */
73
+ const unavailableRuntime = /* @__PURE__ */ ManagedRuntime.make(Layer.effect(Client.Client)(Effect.die(unavailable())));
74
+ /**
75
+ * Provide the Client to descendant components. Call once, high in the tree,
76
+ * before any rune hook. `undefined` (on the server, say) renders every hook
77
+ * from its `initial` seed; a hook that needs the Client then fails with
78
+ * {@link ClientUnavailable}.
79
+ */
80
+ const setClient = (client) => {
81
+ setContext(handleKey, Option.fromUndefinedOr(client));
82
+ clientContext.provide(client === void 0 ? unavailableRuntime : client.runtime);
83
+ };
84
+ /** The Client put in context by {@link setClient}: `None` when it was given none. */
85
+ const getClient = () => hasContext(handleKey) ? getContext(handleKey) : Option.none();
86
+ /**
87
+ * Provide a Client runtime the app built itself: the escape hatch beside
88
+ * {@link setClient}, the same as `setClient(fromRuntime(runtime))`.
89
+ */
90
+ const setClientRuntime = (runtime) => {
91
+ setClient(fromRuntime(runtime));
92
+ };
93
+ /** Read the Client runtime put in context by {@link setClient} or {@link setClientRuntime}. */
94
+ const getClientRuntime = () => clientContext.get();
95
+ // src/hooks.svelte.ts
96
+ /**
97
+ * Rune-based live queries (Svelte 5)
98
+ *
99
+ * The `use*` hooks speak **Query over the Client** (ADR-0022): reads are
100
+ * declarative `Query.Read`s — collection, `where`, `include`d edges with
101
+ * per-edge windows, `order`, cursor pagination — and where a read is
102
+ * answered (replica, worker-hosted engine, server fallthrough) is the
103
+ * Client's business, never the component's. Each hook returns an object
104
+ * whose reactive `current` is an `AsyncResult` (the same result vocabulary
105
+ * as the engine's reactivity atoms and the React binding), plus an
106
+ * awaitable `promise` for Async Svelte. Inputs are thunks called inside
107
+ * `$effect`, so a hook re-subscribes whenever the reactive state its thunk
108
+ * reads changes — a read whose cursor lives in `$state` just works — while
109
+ * the previous value stays visible as a waiting result
110
+ * (stale-while-revalidate).
111
+ *
112
+ * Joins need no separate hook: `include` resolves edges onto each row
113
+ * (`row.related`), windowed per the edge's declared order (ADR-0024), over
114
+ * owned and worker-hosted engines alike.
115
+ *
116
+ * Re-exported from the package root (`@thomasfosterau/legation-svelte`), which is the only
117
+ * read/write surface. Requires Svelte 5. Hooks must be called during
118
+ * component initialisation — they read the Client runtime from context
119
+ * ({@link setClientRuntime}) and register a `$effect`.
120
+ */
121
+ const EMPTY_ROWS = [];
122
+ /** A `promise` that never settles — `.promise` while a read thunk opts out. */
123
+ const NEVER_PROMISE = new Promise(() => {});
124
+ /**
125
+ * Core plumbing — effect-svelte's `useLiveStream` does the work (thunk
126
+ * tracked inside `$effect`, interrupt-and-resubscribe on input change with
127
+ * the previous value kept as a waiting result, emissions in `$state.raw`,
128
+ * teardown interruption never surfacing as a failure, `initial` seeding a
129
+ * synchronous `Success`). This adapter resolves the Client from the
130
+ * context runtime and keeps the SSR seam below.
131
+ *
132
+ * `makeStream` returning `undefined` is the read thunk's opt-out (#141):
133
+ * "nothing right now". No window is ever registered and no request is
134
+ * issued, because the closure that would call `client.observe`/
135
+ * `view.observeNamed` never runs — `Stream.never` stands in, so
136
+ * `useLiveStream`'s own subscription does nothing. `optedOut` then forces
137
+ * `current`/`promise` back to their pre-subscription state regardless of
138
+ * what that never-emitting subscription leaves behind in `useLiveStream`'s
139
+ * state (stale-while-revalidate would otherwise keep a prior success
140
+ * visible, tagged "waiting", forever) — so an opted-out read stays
141
+ * `Initial`, distinguishable from a `Success` over zero rows, whether the
142
+ * thunk never ran or ran once and opted back out.
143
+ */
144
+ const live = (runtime, makeStream, options) => {
145
+ let optedOut = $state(false);
146
+ const pick = () => {
147
+ const toStream = makeStream();
148
+ optedOut = toStream === void 0;
149
+ return toStream;
150
+ };
151
+ const seed = options?.initial === void 0 ? {} : { initial: options.initial };
152
+ const result = Option.isNone(getClient()) ? useLiveStream(() => pick() === void 0 ? Stream.never : Stream.die(unavailable()), {
153
+ runtime: Context.empty(),
154
+ ...seed
155
+ }) : useLiveStream(() => {
156
+ const toStream = pick();
157
+ return toStream === void 0 ? Stream.never : Stream.unwrap(Effect.map(Client.Client, (client) => toStream(client)));
158
+ }, {
159
+ runtime,
160
+ ...seed
161
+ });
162
+ return {
163
+ get current() {
164
+ return optedOut ? AsyncResult.initial() : result.current;
165
+ },
166
+ get promise() {
167
+ if (P.isUndefined(globalThis.document) && options?.initial !== void 0) return options.initial;
168
+ return optedOut ? NEVER_PROMISE : result.promise;
169
+ }
170
+ };
171
+ };
172
+ /**
173
+ * Live result of a declarative read, re-subscribing when the thunk's
174
+ * reactive inputs change. `include`d edges resolve onto each row's
175
+ * `related`, windowed per the edge's declared order (ADR-0024). Observing
176
+ * IS subscribing: the read's windows are registered for the hook's
177
+ * lifetime through the Client's ledger.
178
+ *
179
+ * The thunk may return `undefined` to opt out (#141) — "nothing right now",
180
+ * for a read keyed on a selection that starts empty. No window is
181
+ * registered and no request is issued; `current` stays `Initial` rather
182
+ * than becoming a `Success` over zero rows, so an opted-out read stays
183
+ * distinguishable from one that legitimately matched nothing. The thunk
184
+ * stays reactive either way: flipping the condition subscribes/unsubscribes
185
+ * with the same stale-while-revalidate behaviour as any other input change.
186
+ */
187
+ const useQuery = (read, options) => live(getClientRuntime(), () => {
188
+ const value = read();
189
+ if (value === void 0) return void 0;
190
+ return (client) => client.observe(value);
191
+ }, options);
192
+ /** The base read minus its window — the hook owns `first`/`after`. */
193
+ const connectionKey = (read) => {
194
+ const { first: _first, after: _after, ...base } = read;
195
+ return JSON.stringify(base);
196
+ };
197
+ /**
198
+ * Connection-style paging over a read's root window: a live result that
199
+ * *grows* rather than jumping between detached pages. `loadMore` widens the
200
+ * one window by a page (ADR-0024 — "load more widens the subset"), so each
201
+ * page's rows accumulate and the previous ones stay on screen through the
202
+ * widen (stale-while-revalidate); the engine only ever syncs the current
203
+ * window, never the whole collection.
204
+ *
205
+ * Per-parent paging falls out of instantiating the hook per parent: scope
206
+ * the read's `where` to a parent id and give each parent its own
207
+ * `useConnection` (one per `{#each parents}` row), and each parent's list
208
+ * pages independently, with its own `hasMore` and window. The read thunk
209
+ * stays reactive — when its base (collection/where/order/include) changes the
210
+ * connection re-bases to the first page automatically; the window it owns
211
+ * (`first`/`after`) is ignored on the passed read.
212
+ *
213
+ * ```svelte
214
+ * const reviews = useConnection(() => productReviews(id), { pageSize: 10 });
215
+ * // {#each await reviews.promise as row (row.value.id)} … {/each}
216
+ * // <button onclick={reviews.loadMore} disabled={!reviews.hasMore}>More</button>
217
+ * ```
218
+ *
219
+ * The read thunk may return `undefined` to opt out (#141): no window is
220
+ * registered and no request issued, `current` stays `Initial`, and paging
221
+ * state (`hasMore`/`loading`) reads as idle until the thunk starts returning
222
+ * a read again.
223
+ *
224
+ * **Engine-level retargeting (#133).** When the read has no `include`d edges
225
+ * and the view is engine-backed (`ClientView.observeWindow` present — a
226
+ * `Mirror` has none, see `docs/observing-row-changes.md`), `loadMore`/`reset`
227
+ * widen or shrink the SAME open observation's window via
228
+ * `ClientEngine.observeWindow`'s `retarget` — reusing `LiveView`'s maintained
229
+ * order instead of tearing the stream down and resubscribing wider each page.
230
+ * The stream itself is still built once per *base* change (collection/where/
231
+ * order/include), exactly as before; only `pages` no longer forces a rebuild.
232
+ * A joined read, or a mirror-backed connection, falls back to the original
233
+ * resubscribe-per-page behavior, unchanged.
234
+ */
235
+ const useConnection = (read, options) => {
236
+ const pageSize = options?.pageSize ?? 20;
237
+ const runtime = getClientRuntime();
238
+ let pages = $state(1);
239
+ let baseKey;
240
+ $effect(() => {
241
+ const value = read();
242
+ const key = value === void 0 ? void 0 : connectionKey(value);
243
+ if (key !== baseKey) {
244
+ baseKey = key;
245
+ pages = 1;
246
+ }
247
+ });
248
+ let retargetHandle;
249
+ const windowed = live(runtime, () => {
250
+ const value = read();
251
+ if (value === void 0) {
252
+ retargetHandle = void 0;
253
+ return;
254
+ }
255
+ const { first: _first, after: _after, ...base } = value;
256
+ const key = connectionKey(value);
257
+ return (client) => Stream.unwrap(Effect.gen(function* () {
258
+ const observeWindow = client.view.observeWindow;
259
+ if (!R.isEmptyReadonlyRecord(base.include ?? {}) || observeWindow === void 0) {
260
+ retargetHandle = void 0;
261
+ return client.observe({
262
+ ...base,
263
+ first: pages * pageSize
264
+ });
265
+ }
266
+ const opened = yield* observeWindow(Query.plan(client.nodes, {
267
+ ...base,
268
+ first: pageSize
269
+ }).root);
270
+ retargetHandle = {
271
+ baseKey: key,
272
+ retarget: (limit) => {
273
+ runtime.runFork(opened.retarget({ limit }));
274
+ }
275
+ };
276
+ if (pages > 1) retargetHandle.retarget(pages * pageSize);
277
+ return opened.rows.pipe(Stream.orDie, Stream.map((rows) => rows.map((row) => ({
278
+ ...row,
279
+ related: {}
280
+ }))));
281
+ }).pipe(Effect.orDie));
282
+ }, options);
283
+ const rows = () => AsyncResult.getOrElse(windowed.current, () => EMPTY_ROWS);
284
+ const widenTo = (nextPages) => {
285
+ pages = nextPages;
286
+ const handle = retargetHandle;
287
+ if (handle !== void 0 && handle.baseKey === baseKey) handle.retarget(pages * pageSize);
288
+ };
289
+ return {
290
+ get current() {
291
+ return windowed.current;
292
+ },
293
+ get promise() {
294
+ return windowed.promise;
295
+ },
296
+ get hasMore() {
297
+ return rows().length >= pages * pageSize;
298
+ },
299
+ get loading() {
300
+ return AsyncResult.isWaiting(windowed.current);
301
+ },
302
+ loadMore: () => {
303
+ if (!AsyncResult.isWaiting(windowed.current) && rows().length >= pages * pageSize) widenTo(pages + 1);
304
+ },
305
+ reset: () => widenTo(1)
306
+ };
307
+ };
308
+ /**
309
+ * Live result of a named query (server-resolved), with reactive name/args.
310
+ * The thunk may return `undefined` to opt out (#141): see {@link useQuery}.
311
+ */
312
+ const useNamedQuery = (query, options) => live(getClientRuntime(), () => {
313
+ const value = query();
314
+ if (value === void 0) return void 0;
315
+ const { name, args } = value;
316
+ return (client) => client.view.observeNamed(name, args).pipe(Stream.orDie);
317
+ }, options);
318
+ /**
319
+ * Live aggregate over a read's root window (count/sum/min/max/avg). Local
320
+ * tier only — the aggregate sees the rows this client has synced. The read
321
+ * thunk may return `undefined` to opt out (#141): see {@link useQuery}.
322
+ */
323
+ const useAggregate = (read, aggregate, options) => live(getClientRuntime(), () => {
324
+ const value = read();
325
+ if (value === void 0) return void 0;
326
+ return (client) => Aggregate.observe(client.view.observe(Query.plan(client.nodes, value).root).pipe(Stream.orDie), aggregate);
327
+ }, options);
328
+ /**
329
+ * Live grouped aggregate over a read's root window, keyed by
330
+ * `String(row.value[groupBy])`. The read thunk may return `undefined` to
331
+ * opt out (#141): see {@link useQuery}.
332
+ */
333
+ const useAggregateGrouped = (read, aggregate, groupBy, options) => live(getClientRuntime(), () => {
334
+ const value = read();
335
+ if (value === void 0) return void 0;
336
+ return (client) => Aggregate.observeGrouped(client.view.observe(Query.plan(client.nodes, value).root).pipe(Stream.orDie), aggregate, groupBy);
337
+ }, options);
338
+ /**
339
+ * A `mutate(mutation, args)` bound to the context Client, resolving on
340
+ * journal (the write is applied optimistically before the promise settles).
341
+ */
342
+ const useMutation = () => {
343
+ const runtime = getClientRuntime();
344
+ return (mutation, args) => runtime.runPromise(Effect.flatMap(Client.Client, (client) => client.mutate(mutation, args)));
345
+ };
346
+ /**
347
+ * Like {@link useMutation}, but resolving with the mutation's authoritative
348
+ * outcome once the backend settles it.
349
+ */
350
+ const useMutationWithAck = () => {
351
+ const runtime = getClientRuntime();
352
+ return (mutation, args) => runtime.runPromise(Effect.flatMap(Client.Client, (client) => client.mutateWithAck(mutation, args)));
353
+ };
354
+ /**
355
+ * Reactive mutation-state list (#116) — TanStack Query's `useMutationState`
356
+ * analogue: every journaled mutation's current lifecycle state (`pending` →
357
+ * `acknowledged` | `rejected`), oldest-journaled first. Pass a `mutator`
358
+ * thunk to scope to one mutator (reactive, like {@link useQuery}'s `read`);
359
+ * omit it (or return `undefined`) for every mutation. Works identically over
360
+ * an owned engine and a connected host (ADR-0021) — the same states either
361
+ * way.
362
+ */
363
+ const useMutationState = (mutator) => live(getClientRuntime(), () => {
364
+ const scope = mutator?.();
365
+ return (client) => client.mutationStates.pipe(Stream.map((states) => [...HashMap.values(states)].filter((state) => scope === void 0 || state.mutator === scope).toSorted((a, b) => a.mutationId - b.mutationId)));
366
+ }, { initial: [] });
367
+ /** Drain a boolean stream into `$state`, interrupting on teardown. */
368
+ const useFlag = (changes) => {
369
+ let value = $state(false);
370
+ $effect(() => {
371
+ const fiber = Effect.runFork(Stream.runForEach(changes, (next) => Effect.sync(() => value = next)));
372
+ return () => {
373
+ Effect.runFork(Fiber.interrupt(fiber));
374
+ };
375
+ });
376
+ return { get current() {
377
+ return value;
378
+ } };
379
+ };
380
+ /**
381
+ * Undo/redo over a manager's history as runes. Build one manager per client
382
+ * with `Undo.make(handle)` — a `Client` or a `ClientEngine` both work
383
+ * (issue #223) — and call this during component initialisation.
384
+ */
385
+ const useUndo = (manager) => {
386
+ const canUndo = useFlag(manager.canUndoChanges);
387
+ const canRedo = useFlag(manager.canRedoChanges);
388
+ return {
389
+ get canUndo() {
390
+ return canUndo.current;
391
+ },
392
+ get canRedo() {
393
+ return canRedo.current;
394
+ },
395
+ undo: () => Effect.runPromise(Effect.ignore(manager.undo)),
396
+ redo: () => Effect.runPromise(Effect.ignore(manager.redo))
397
+ };
398
+ };
399
+ export { ClientUnavailable, createClient, fromRuntime, getClient, getClientRuntime, setClient, setClientRuntime, useAggregate, useAggregateGrouped, useConnection, useMutation, useMutationState, useMutationWithAck, useNamedQuery, useQuery, useUndo };
400
+
401
+ //# sourceMappingURL=index.svelte.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.svelte.js","names":[],"sources":["../src/unavailable.ts","../src/Runtime.ts","../src/hooks.svelte.ts"],"sourcesContent":["/**\n * The failure of a hook that reaches for a Client where none was set (#147):\n * `setClient(undefined)`, as on the server. Kept apart from `Runtime.ts` so the\n * hooks can raise it without importing the context module's public surface.\n */\nimport * as Schema from \"effect/Schema\";\n\n/**\n * A hook reached for the Client where none was set: `setClient(undefined)`,\n * as on the server, where a hook renders from its `initial` seed and fails\n * only when it needs the Client itself.\n */\nexport class ClientUnavailable extends Schema.TaggedError<\n ClientUnavailable,\n { readonly brand: unique symbol }\n>()(\"ClientUnavailable\", { message: Schema.String }) {}\n\n/** The {@link ClientUnavailable} a hook fails with when no Client was set. */\nexport const unavailable = (): ClientUnavailable =>\n new ClientUnavailable({\n message:\n \"@thomasfosterau/legation-svelte: no Client here (setClient was given none, as on the server). A hook renders from its `initial` seed without one; this one needed the Client.\",\n });\n","/**\n * Svelte client context\n *\n * The rune hooks reach the {@link Client.Client} — ADR-0022's one-per-thread\n * object, so where a read is answered (this thread's engine, a worker-hosted\n * one, the server) is configuration the components never see.\n *\n * An app builds a {@link ClientHandle} with {@link createClient} (#147) and\n * hands it to the component tree with {@link setClient}; the hooks read it\n * back, so they must be called during component initialisation (the same\n * rule Svelte's `getContext` follows). The runtime the hooks run over is an\n * implementation detail of the handle. An app with its own layers (an\n * in-page server, a service-worker mirror, a simulated-offline transport)\n * builds the runtime itself and wraps it with {@link fromRuntime}, or passes\n * it to {@link setClientRuntime}.\n *\n * On the server there may be no Client at all: `setClient(undefined)` renders\n * every hook from its `initial` seed, and only a hook that actually reaches\n * for the Client (a write, an unseeded read) fails, with\n * {@link ClientUnavailable}.\n */\nimport { createRuntimeContext } from \"@thomasfosterau/effect-svelte\";\nimport * as Effect from \"effect/Effect\";\nimport * as Layer from \"effect/Layer\";\nimport * as ManagedRuntime from \"effect/ManagedRuntime\";\nimport * as Option from \"effect/Option\";\nimport { getContext, hasContext, setContext } from \"svelte\";\nimport { Client } from \"@thomasfosterau/legation\";\nimport { unavailable } from \"./unavailable.js\";\n\nexport { ClientUnavailable } from \"./unavailable.js\";\n\n/**\n * A runtime carrying the {@link Client.Client} — ADR-0022's one-per-thread\n * object. The rune hooks run over it: reads are declarative `Query.Read`s\n * and where they are answered (replica, host, server fallthrough) is the\n * Client's business, never the component's.\n */\nexport type ClientRuntime = ManagedRuntime.ManagedRuntime<Client.Client, never>;\n\n/**\n * The Client an app hands to its component tree (#147): what {@link setClient}\n * takes, and what {@link createClient} and {@link fromRuntime} build.\n */\nexport interface ClientHandle {\n /** The runtime the hooks run over, for Effect code that needs the Client directly. */\n readonly runtime: ClientRuntime;\n /**\n * Release the Client: stop its sync loop and close its store and transport.\n * Call it when the app tears down (a root layout's cleanup). Calling it again\n * does nothing more.\n */\n readonly dispose: () => Promise<void>;\n}\n\n/**\n * Build the Client a Svelte app boots with (#147): `Client.make`'s options in,\n * a {@link ClientHandle} out, with no Effect runtime for the app to build. The\n * Client is built on first use, so creating the handle does no work; with\n * `transport: { url }` it is the standard remote setup.\n */\nexport const createClient = (options: Client.MakeOptions): ClientHandle =>\n fromRuntime(\n ManagedRuntime.make(\n Layer.effect(Client.Client)(Effect.map(Client.make(options), (made) => made.client))\n )\n );\n\n/**\n * Wrap a runtime the app built itself, for the custom-layer path: an in-page\n * server, a service-worker mirror, a simulated-offline transport.\n */\nexport const fromRuntime = (runtime: ClientRuntime): ClientHandle => ({\n runtime,\n dispose: () => runtime.dispose(),\n});\n\nconst clientContext = createRuntimeContext<Client.Client>(\n \"@thomasfosterau/legation-svelte — call setClient(createClient(…)) higher in the component tree\"\n);\n\nconst handleKey = Symbol.for(\"@thomasfosterau/legation-svelte/ClientHandle\");\n\n/**\n * The runtime a tree with no Client runs its hooks over. Building it is lazy,\n * so it costs nothing until a hook reaches for the Client, and then it fails.\n */\nconst unavailableRuntime: ClientRuntime = /* @__PURE__ */ ManagedRuntime.make(\n Layer.effect(Client.Client)(Effect.die(unavailable()))\n);\n\n/**\n * Provide the Client to descendant components. Call once, high in the tree,\n * before any rune hook. `undefined` (on the server, say) renders every hook\n * from its `initial` seed; a hook that needs the Client then fails with\n * {@link ClientUnavailable}.\n */\nexport const setClient = (client?: ClientHandle): void => {\n setContext(handleKey, Option.fromUndefinedOr(client));\n clientContext.provide(client === undefined ? unavailableRuntime : client.runtime);\n};\n\n/** The Client put in context by {@link setClient}: `None` when it was given none. */\nexport const getClient = (): Option.Option<ClientHandle> =>\n hasContext(handleKey) ? getContext<Option.Option<ClientHandle>>(handleKey) : Option.none();\n\n/**\n * Provide a Client runtime the app built itself: the escape hatch beside\n * {@link setClient}, the same as `setClient(fromRuntime(runtime))`.\n */\nexport const setClientRuntime = (runtime: ClientRuntime): void => {\n setClient(fromRuntime(runtime));\n};\n\n/** Read the Client runtime put in context by {@link setClient} or {@link setClientRuntime}. */\nexport const getClientRuntime = (): ClientRuntime =>\n // Narrowing as in {@link getEngineRuntime}: `setClient` is the only writer.\n clientContext.get() as ClientRuntime;\n","/**\n * Rune-based live queries (Svelte 5)\n *\n * The `use*` hooks speak **Query over the Client** (ADR-0022): reads are\n * declarative `Query.Read`s — collection, `where`, `include`d edges with\n * per-edge windows, `order`, cursor pagination — and where a read is\n * answered (replica, worker-hosted engine, server fallthrough) is the\n * Client's business, never the component's. Each hook returns an object\n * whose reactive `current` is an `AsyncResult` (the same result vocabulary\n * as the engine's reactivity atoms and the React binding), plus an\n * awaitable `promise` for Async Svelte. Inputs are thunks called inside\n * `$effect`, so a hook re-subscribes whenever the reactive state its thunk\n * reads changes — a read whose cursor lives in `$state` just works — while\n * the previous value stays visible as a waiting result\n * (stale-while-revalidate).\n *\n * Joins need no separate hook: `include` resolves edges onto each row\n * (`row.related`), windowed per the edge's declared order (ADR-0024), over\n * owned and worker-hosted engines alike.\n *\n * Re-exported from the package root (`@thomasfosterau/legation-svelte`), which is the only\n * read/write surface. Requires Svelte 5. Hooks must be called during\n * component initialisation — they read the Client runtime from context\n * ({@link setClientRuntime}) and register a `$effect`.\n */\nimport { useLiveStream } from \"@thomasfosterau/effect-svelte\";\nimport * as Context from \"effect/Context\";\nimport * as Effect from \"effect/Effect\";\nimport * as Fiber from \"effect/Fiber\";\nimport * as HashMap from \"effect/HashMap\";\nimport * as Option from \"effect/Option\";\nimport * as P from \"effect/Predicate\";\nimport * as R from \"effect/Record\";\nimport * as Stream from \"effect/Stream\";\nimport { AsyncResult } from \"effect/reactivity\";\nimport { Aggregate, Client, Query } from \"@thomasfosterau/legation\";\nimport { ClientEngine, type MutationState, type Undo } from \"@thomasfosterau/legation/internal\";\nimport { getClient, getClientRuntime, type ClientRuntime } from \"./Runtime.js\";\nimport { unavailable } from \"./unavailable.js\";\n\n/** A live result: `current` re-renders whatever reads it on every emission. */\nexport interface Live<A, E = never> {\n /**\n * The current result. `Initial` until the first emission, then `Success`\n * per emission; while re-subscribing after an input change the previous\n * result is kept with `waiting: true` (stale-while-revalidate). An\n * `initial` seed starts as a non-waiting `Success` — \"we have data\", no\n * spinner over server-rendered content — until the first real emission.\n */\n readonly current: AsyncResult.AsyncResult<A, E>;\n /**\n * The awaitable view of the same result, for Async Svelte\n * (`experimental.async`): pending until the first data arrives — so\n * `await rows.promise` in a derived or template suspends the enclosing\n * `<svelte:boundary>`'s `pending` snippet — then an already-resolved\n * promise replaced on every emission (and on input change), so awaiting\n * expressions re-run with fresh rows while Svelte keeps the previous UI\n * on screen (stale-while-revalidate). A failed stream rejects it, which\n * the boundary's `failed` snippet catches.\n */\n readonly promise: Promise<A>;\n}\n\n/** Options shared by the live hooks. */\nexport interface LiveOptions<A> {\n /**\n * Seed `current` with a synchronous `Success` before the first emission —\n * during SSR (where `$effect` never runs) this is what renders, and on the\n * client it makes the first render match the server markup (no loading\n * flash). Compute it from the rows the page's load already fetched.\n */\n readonly initial?: A;\n}\n\n/** The rows a read resolves to: node values with their included edges. */\nexport type ResolvedRows = ReadonlyArray<Client.ResolvedRow>;\nconst EMPTY_ROWS: ResolvedRows = [];\n\n/** A `promise` that never settles — `.promise` while a read thunk opts out. */\nconst NEVER_PROMISE: Promise<never> = new Promise(() => {});\n\n/**\n * Core plumbing — effect-svelte's `useLiveStream` does the work (thunk\n * tracked inside `$effect`, interrupt-and-resubscribe on input change with\n * the previous value kept as a waiting result, emissions in `$state.raw`,\n * teardown interruption never surfacing as a failure, `initial` seeding a\n * synchronous `Success`). This adapter resolves the Client from the\n * context runtime and keeps the SSR seam below.\n *\n * `makeStream` returning `undefined` is the read thunk's opt-out (#141):\n * \"nothing right now\". No window is ever registered and no request is\n * issued, because the closure that would call `client.observe`/\n * `view.observeNamed` never runs — `Stream.never` stands in, so\n * `useLiveStream`'s own subscription does nothing. `optedOut` then forces\n * `current`/`promise` back to their pre-subscription state regardless of\n * what that never-emitting subscription leaves behind in `useLiveStream`'s\n * state (stale-while-revalidate would otherwise keep a prior success\n * visible, tagged \"waiting\", forever) — so an opted-out read stays\n * `Initial`, distinguishable from a `Success` over zero rows, whether the\n * thunk never ran or ran once and opted back out.\n */\nconst live = <A, E>(\n runtime: ClientRuntime,\n makeStream: () => ((client: Client.Client) => Stream.Stream<A, E>) | undefined,\n options?: LiveOptions<A>\n): Live<A, E> => {\n let optedOut = $state(false);\n // The thunk, read inside `$effect` for its reactive inputs; `undefined` opts out.\n const pick = () => {\n const toStream = makeStream();\n optedOut = toStream === undefined;\n return toStream;\n };\n const seed = options?.initial === undefined ? {} : { initial: options.initial };\n // With no Client set (#147), a read that is not opted out fails at once with\n // `ClientUnavailable`, run where nothing needs the Client; one that renders\n // only from its seed (SSR, where no `$effect` runs) never gets this far.\n const result = Option.isNone(getClient())\n ? useLiveStream(() => (pick() === undefined ? Stream.never : Stream.die(unavailable())), {\n runtime: Context.empty(),\n ...seed,\n })\n : useLiveStream(\n () => {\n const toStream = pick();\n return toStream === undefined\n ? Stream.never\n : Stream.unwrap(Effect.map(Client.Client, (client) => toStream(client)));\n },\n { runtime, ...seed }\n );\n return {\n get current() {\n return optedOut ? AsyncResult.initial() : result.current;\n },\n get promise() {\n // During SSR (no `$effect`, so no live stream) a seeded hook exposes\n // its value DIRECTLY: Svelte's server renderer defers real promises to\n // the boundary's `pending` snippet but renders awaited plain values\n // synchronously — so seeded pages fully server-render, and unseeded\n // ones legitimately show `pending`.\n if (P.isUndefined(globalThis.document) && options?.initial !== undefined) {\n return options.initial as Promise<A>;\n }\n return optedOut ? NEVER_PROMISE : result.promise;\n },\n };\n};\n\n/**\n * Live result of a declarative read, re-subscribing when the thunk's\n * reactive inputs change. `include`d edges resolve onto each row's\n * `related`, windowed per the edge's declared order (ADR-0024). Observing\n * IS subscribing: the read's windows are registered for the hook's\n * lifetime through the Client's ledger.\n *\n * The thunk may return `undefined` to opt out (#141) — \"nothing right now\",\n * for a read keyed on a selection that starts empty. No window is\n * registered and no request is issued; `current` stays `Initial` rather\n * than becoming a `Success` over zero rows, so an opted-out read stays\n * distinguishable from one that legitimately matched nothing. The thunk\n * stays reactive either way: flipping the condition subscribes/unsubscribes\n * with the same stale-while-revalidate behaviour as any other input change.\n */\nexport const useQuery = (\n read: () => Query.Read | undefined,\n options?: LiveOptions<ResolvedRows>\n): Live<ResolvedRows> =>\n live(\n getClientRuntime(),\n () => {\n const value = read();\n if (value === undefined) return undefined;\n return (client) => client.observe(value);\n },\n options\n );\n\n/** A live, growing connection over a read's root window (ADR-0024). */\nexport interface Connection extends Live<ResolvedRows> {\n /**\n * Widen the window by one page. A no-op while a widen is still resolving\n * or once {@link hasMore} is false — call it freely from a click handler.\n */\n readonly loadMore: () => void;\n /**\n * Whether another page may exist: `true` while the current window is\n * saturated (it holds as many rows as it asked for). Reactive. At a dataset\n * whose size is an exact multiple of the page size this stays `true` for one\n * more `loadMore`, which then resolves empty and flips it `false` — the\n * standard cost of limit-based paging without a separate total count.\n */\n readonly hasMore: boolean;\n /** Whether a page (the first or a widen) is currently resolving. Reactive. */\n readonly loading: boolean;\n /** Collapse back to the first page. */\n readonly reset: () => void;\n}\n\n/** Options for {@link useConnection}. */\nexport interface ConnectionOptions extends LiveOptions<ResolvedRows> {\n /** Rows per page — the window grows by this on each `loadMore`. Default 20. */\n readonly pageSize?: number;\n}\n\n/** The base read minus its window — the hook owns `first`/`after`. */\nconst connectionKey = (read: Query.Read): string => {\n const { first: _first, after: _after, ...base } = read;\n return JSON.stringify(base);\n};\n\n/**\n * Connection-style paging over a read's root window: a live result that\n * *grows* rather than jumping between detached pages. `loadMore` widens the\n * one window by a page (ADR-0024 — \"load more widens the subset\"), so each\n * page's rows accumulate and the previous ones stay on screen through the\n * widen (stale-while-revalidate); the engine only ever syncs the current\n * window, never the whole collection.\n *\n * Per-parent paging falls out of instantiating the hook per parent: scope\n * the read's `where` to a parent id and give each parent its own\n * `useConnection` (one per `{#each parents}` row), and each parent's list\n * pages independently, with its own `hasMore` and window. The read thunk\n * stays reactive — when its base (collection/where/order/include) changes the\n * connection re-bases to the first page automatically; the window it owns\n * (`first`/`after`) is ignored on the passed read.\n *\n * ```svelte\n * const reviews = useConnection(() => productReviews(id), { pageSize: 10 });\n * // {#each await reviews.promise as row (row.value.id)} … {/each}\n * // <button onclick={reviews.loadMore} disabled={!reviews.hasMore}>More</button>\n * ```\n *\n * The read thunk may return `undefined` to opt out (#141): no window is\n * registered and no request issued, `current` stays `Initial`, and paging\n * state (`hasMore`/`loading`) reads as idle until the thunk starts returning\n * a read again.\n *\n * **Engine-level retargeting (#133).** When the read has no `include`d edges\n * and the view is engine-backed (`ClientView.observeWindow` present — a\n * `Mirror` has none, see `docs/observing-row-changes.md`), `loadMore`/`reset`\n * widen or shrink the SAME open observation's window via\n * `ClientEngine.observeWindow`'s `retarget` — reusing `LiveView`'s maintained\n * order instead of tearing the stream down and resubscribing wider each page.\n * The stream itself is still built once per *base* change (collection/where/\n * order/include), exactly as before; only `pages` no longer forces a rebuild.\n * A joined read, or a mirror-backed connection, falls back to the original\n * resubscribe-per-page behavior, unchanged.\n */\nexport const useConnection = (\n read: () => Query.Read | undefined,\n options?: ConnectionOptions\n): Connection => {\n const pageSize = options?.pageSize ?? 20;\n const runtime = getClientRuntime();\n let pages = $state(1);\n // Non-reactive: tracks the base read so a reused hook re-bases to page 1\n // when its inputs change (a per-parent instance never trips this).\n // `undefined` also covers the opted-out state (#141).\n let baseKey: string | undefined;\n\n $effect(() => {\n const value = read();\n const key = value === undefined ? undefined : connectionKey(value);\n if (key !== baseKey) {\n baseKey = key;\n pages = 1;\n }\n });\n\n // The current base's retarget handle, set once per base change from inside\n // the stream thunk below. `loadMore`/`reset` widen/shrink through it\n // instead of relying on `pages` to force a resubscribe — `pages` is\n // deliberately read only inside the thunk's *async* body below, which\n // Svelte's effect tracking never sees, so changing it alone no longer\n // retriggers `useLiveStream`'s interrupt-and-resubscribe. `undefined` when\n // the current base has no engine-level retarget available (falls back to\n // the pre-#133 behavior).\n let retargetHandle:\n | { readonly baseKey: string; readonly retarget: (limit: number) => void }\n | undefined;\n\n const windowed = live(\n runtime,\n () => {\n const value = read();\n if (value === undefined) {\n retargetHandle = undefined;\n return undefined;\n }\n const { first: _first, after: _after, ...base } = value;\n const key = connectionKey(value);\n return (client) =>\n Stream.unwrap(\n Effect.gen(function* () {\n const observeWindow = client.view.observeWindow;\n const hasEdges = !R.isEmptyReadonlyRecord(base.include ?? {});\n if (hasEdges || observeWindow === undefined) {\n retargetHandle = undefined;\n return client.observe({ ...base, first: pages * pageSize });\n }\n const compiled = Query.plan(client.nodes, { ...base, first: pageSize });\n const opened = yield* observeWindow(compiled.root);\n retargetHandle = {\n baseKey: key,\n retarget: (limit) => {\n runtime.runFork(opened.retarget({ limit }));\n },\n };\n // Defensive: `pages` is always freshly reset to 1 by the base-key\n // effect above before this thunk re-runs, but this keeps the\n // observation correct even if that ordering ever changes.\n if (pages > 1) retargetHandle.retarget(pages * pageSize);\n return opened.rows.pipe(\n Stream.orDie,\n Stream.map((rows) => rows.map((row) => ({ ...row, related: {} })))\n );\n }).pipe(Effect.orDie)\n );\n },\n options\n );\n\n const rows = (): ResolvedRows => AsyncResult.getOrElse(windowed.current, () => EMPTY_ROWS);\n\n const widenTo = (nextPages: number) => {\n pages = nextPages;\n const handle = retargetHandle;\n if (handle !== undefined && handle.baseKey === baseKey) handle.retarget(pages * pageSize);\n };\n\n return {\n get current() {\n return windowed.current;\n },\n get promise() {\n return windowed.promise;\n },\n get hasMore() {\n return rows().length >= pages * pageSize;\n },\n get loading() {\n return AsyncResult.isWaiting(windowed.current);\n },\n loadMore: () => {\n if (!AsyncResult.isWaiting(windowed.current) && rows().length >= pages * pageSize) {\n widenTo(pages + 1);\n }\n },\n reset: () => widenTo(1),\n };\n};\n\n/**\n * Live result of a named query (server-resolved), with reactive name/args.\n * The thunk may return `undefined` to opt out (#141): see {@link useQuery}.\n */\nexport const useNamedQuery = (\n query: () => { readonly name: string; readonly args: unknown } | undefined,\n options?: LiveOptions<ReadonlyArray<ClientEngine.LocalRow>>\n): Live<ReadonlyArray<ClientEngine.LocalRow>> =>\n live(\n getClientRuntime(),\n () => {\n const value = query();\n if (value === undefined) return undefined;\n const { name, args } = value;\n return (client) => client.view.observeNamed(name, args).pipe(Stream.orDie);\n },\n options\n );\n\n/**\n * Live aggregate over a read's root window (count/sum/min/max/avg). Local\n * tier only — the aggregate sees the rows this client has synced. The read\n * thunk may return `undefined` to opt out (#141): see {@link useQuery}.\n */\nexport const useAggregate = (\n read: () => Query.Read | undefined,\n aggregate: Aggregate.Aggregate,\n options?: LiveOptions<Aggregate.AggregateValue>\n): Live<Aggregate.AggregateValue> =>\n live(\n getClientRuntime(),\n () => {\n const value = read();\n if (value === undefined) return undefined;\n return (client) =>\n Aggregate.observe(\n client.view.observe(Query.plan(client.nodes, value).root).pipe(Stream.orDie),\n aggregate\n );\n },\n options\n );\n\n/**\n * Live grouped aggregate over a read's root window, keyed by\n * `String(row.value[groupBy])`. The read thunk may return `undefined` to\n * opt out (#141): see {@link useQuery}.\n */\nexport const useAggregateGrouped = (\n read: () => Query.Read | undefined,\n aggregate: Aggregate.Aggregate,\n groupBy: string,\n options?: LiveOptions<Record<string, Aggregate.AggregateValue>>\n): Live<Record<string, Aggregate.AggregateValue>> =>\n live(\n getClientRuntime(),\n () => {\n const value = read();\n if (value === undefined) return undefined;\n return (client) =>\n Aggregate.observeGrouped(\n client.view.observe(Query.plan(client.nodes, value).root).pipe(Stream.orDie),\n aggregate,\n groupBy\n );\n },\n options\n );\n\n/**\n * A `mutate(mutation, args)` bound to the context Client, resolving on\n * journal (the write is applied optimistically before the promise settles).\n */\nexport const useMutation = (): ((mutation: string, args: unknown) => Promise<void>) => {\n const runtime = getClientRuntime();\n return (mutation, args) =>\n runtime.runPromise(Effect.flatMap(Client.Client, (client) => client.mutate(mutation, args)));\n};\n\n/**\n * Like {@link useMutation}, but resolving with the mutation's authoritative\n * outcome once the backend settles it.\n */\nexport const useMutationWithAck = (): ((\n mutation: string,\n args: unknown\n) => Promise<ClientEngine.MutationOutcome>) => {\n const runtime = getClientRuntime();\n return (mutation, args) =>\n runtime.runPromise(\n Effect.flatMap(Client.Client, (client) => client.mutateWithAck(mutation, args))\n );\n};\n\n/**\n * Reactive mutation-state list (#116) — TanStack Query's `useMutationState`\n * analogue: every journaled mutation's current lifecycle state (`pending` →\n * `acknowledged` | `rejected`), oldest-journaled first. Pass a `mutator`\n * thunk to scope to one mutator (reactive, like {@link useQuery}'s `read`);\n * omit it (or return `undefined`) for every mutation. Works identically over\n * an owned engine and a connected host (ADR-0021) — the same states either\n * way.\n */\nexport const useMutationState = (\n mutator?: () => string | undefined\n): Live<ReadonlyArray<MutationState.MutationState>> =>\n live(\n getClientRuntime(),\n () => {\n const scope = mutator?.();\n return (client) =>\n client.mutationStates.pipe(\n Stream.map((states) =>\n [...HashMap.values(states)]\n .filter((state) => scope === undefined || state.mutator === scope)\n .toSorted((a, b) => a.mutationId - b.mutationId)\n )\n );\n },\n { initial: [] }\n );\n\n/** Live undo/redo availability plus the two commands. */\nexport interface UndoControls {\n /** Whether an undo is currently possible. Reactive. */\n readonly canUndo: boolean;\n /** Whether a redo is currently possible. Reactive. */\n readonly canRedo: boolean;\n /** Run the manager's `undo` (a no-op when nothing is undoable). */\n readonly undo: () => Promise<void>;\n /** Run the manager's `redo` (a no-op when nothing is redoable). */\n readonly redo: () => Promise<void>;\n}\n\n/** Drain a boolean stream into `$state`, interrupting on teardown. */\nconst useFlag = (changes: Stream.Stream<boolean>): { readonly current: boolean } => {\n let value = $state(false);\n $effect(() => {\n // The manager's streams close over the engine (no `R`), so they run on the\n // default runtime — `useUndo` needs no Client in context.\n const fiber = Effect.runFork(\n Stream.runForEach(changes, (next) => Effect.sync(() => (value = next)))\n );\n return () => {\n Effect.runFork(Fiber.interrupt(fiber));\n };\n });\n return {\n get current() {\n return value;\n },\n };\n};\n\n/**\n * Undo/redo over a manager's history as runes. Build one manager per client\n * with `Undo.make(handle)` — a `Client` or a `ClientEngine` both work\n * (issue #223) — and call this during component initialisation.\n */\nexport const useUndo = <E>(manager: Undo.UndoManager<E>): UndoControls => {\n const canUndo = useFlag(manager.canUndoChanges);\n const canRedo = useFlag(manager.canRedoChanges);\n return {\n get canUndo() {\n return canUndo.current;\n },\n get canRedo() {\n return canRedo.current;\n },\n undo: () => Effect.runPromise(Effect.ignore(manager.undo)),\n redo: () => Effect.runPromise(Effect.ignore(manager.redo)),\n };\n};\n"],"mappings":"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"}
@@ -0,0 +1,171 @@
1
+ import { n as ClientRuntime } from "../Runtime-DAQq0HCa.js";
2
+ import * as Effect from "effect/Effect";
3
+ import * as Layer from "effect/Layer";
4
+ import { Client, ClientStore, Hydration, Mutation, MutatorError, Server, StoreError, Transport, TransportError, VersionNotSupportedError } from "@thomasfosterau/legation";
5
+ import { Cookies } from "@sveltejs/kit";
6
+ /** Options for {@link clientGroupId}. */
7
+ export interface ClientGroupOptions {
8
+ /** Cookie name. Default `"legation-client-group"`. */
9
+ readonly name?: string;
10
+ /** Generate a fresh id on first contact. Default `crypto.randomUUID`. */
11
+ readonly generate?: () => string;
12
+ /** Cookie path. Default `"/"`. */
13
+ readonly path?: string;
14
+ /** Cookie lifetime in seconds. Default one year. */
15
+ readonly maxAge?: number;
16
+ }
17
+ /** The client-group cookie's default name. */
18
+ export declare const CLIENT_GROUP_COOKIE = "legation-client-group";
19
+ /**
20
+ * Read the browser's client group id from its cookie, minting one when
21
+ * absent. Call it in a server `load` (or `handle`) and render the page's
22
+ * snapshot under the returned id; the client boots its engine with the same
23
+ * id (it can read the cookie itself, or take the value from the load data).
24
+ *
25
+ * The cookie is deliberately not `httpOnly` — the client engine needs the
26
+ * same group id. It carries no authority: identity is resolved server-side
27
+ * through the `IdentityResolver` seam, never from this value.
28
+ */
29
+ export declare const clientGroupId: (cookies: Cookies, options?: ClientGroupOptions) => string;
30
+ /** Options for {@link loadSnapshot}. */
31
+ export interface LoadSnapshotOptions extends Omit<Hydration.SnapshotOptions, "clientGroupId"> {
32
+ /** Client-group cookie options (name, generation, lifetime). */
33
+ readonly cookie?: ClientGroupOptions;
34
+ }
35
+ /** {@link LoadSnapshotOptions} with the carrier supplied — no `ServerHandler` needed. */
36
+ export interface RemoteLoadSnapshotOptions extends LoadSnapshotOptions {
37
+ readonly transport: Transport.ClientTransport;
38
+ }
39
+ /** What {@link loadSnapshot} returns — put both in the page data. */
40
+ export interface LoadedSnapshot {
41
+ readonly snapshot: Hydration.Snapshot;
42
+ readonly clientGroupId: string;
43
+ }
44
+ type LoadSnapshotError = StoreError | TransportError | MutatorError | VersionNotSupportedError;
45
+ /**
46
+ * Resolve the request's client group id from its cookie and produce the SSR
47
+ * snapshot for the given subsets under it.
48
+ *
49
+ * The group id is the point of the cookie: the render happens under the
50
+ * *browser's* group, so the snapshot's pull cookie continues that group's CVR
51
+ * conversation. That holds over a remote `transport` too — the id travels on
52
+ * the pull request and the server's fresh cookie travels back.
53
+ */
54
+ export declare function loadSnapshot(cookies: Cookies, options: RemoteLoadSnapshotOptions): Effect.Effect<LoadedSnapshot, LoadSnapshotError>;
55
+ /** The in-process form: renders against the app's sync server, the `ServerHandler` in context. */
56
+ export declare function loadSnapshot(cookies: Cookies, options: LoadSnapshotOptions): Effect.Effect<LoadedSnapshot, LoadSnapshotError, Transport.ServerHandler>;
57
+ /** Options for {@link clientRuntime}. */
58
+ export interface ClientRuntimeOptions extends Omit<Client.MakeOptions, "store" | "transport"> {
59
+ /** Fully-provided client store layer (e.g. `IndexedDbClientStore.layerWindow()`). */
60
+ readonly store: Layer.Layer<ClientStore.ClientStore>;
61
+ /** Fully-provided transport layer (RPC client, or an in-memory pair in tests). */
62
+ readonly transport: Layer.Layer<Transport.ClientTransport>;
63
+ /** The page's SSR snapshot, hydrated into an empty store before the Client boots. */
64
+ readonly snapshot?: Hydration.Snapshot;
65
+ /** Reads to pin for the runtime's lifetime (the app shell's working set). */
66
+ readonly subscribe?: ReadonlyArray<Parameters<Client.Client["subscribe"]>[0]>;
67
+ }
68
+ /**
69
+ * Build the tab's Client runtime: store (snapshot-hydrated when empty) →
70
+ * transport → `Client.make`, which starts the sync loop itself. Hand the
71
+ * result to `setClientRuntime`, and dispose it when the app tears down.
72
+ */
73
+ export declare const clientRuntime: (options: ClientRuntimeOptions) => ClientRuntime;
74
+ /** The hidden field carrying the rendered form's form client id. */
75
+ export declare const FORM_CLIENT_FIELD = "legation-form-client";
76
+ /** The hidden field carrying the form client's mutation id. */
77
+ export declare const FORM_MUTATION_FIELD = "legation-form-mutation";
78
+ /** The key of the action result's data under which the receipt travels. */
79
+ export declare const RECEIPT_KEY = "legationReceipt";
80
+ /** A form client: the id minted for one rendered form, and its mutation id (`1`). */
81
+ export interface FormClient {
82
+ readonly clientId: string;
83
+ readonly mutationId: number;
84
+ }
85
+ /**
86
+ * Mint a form client for one rendered form: a fresh id with mutation id `1`
87
+ * (ADR-0030 #215 §2). The page's server `load` mints one per form and renders
88
+ * it in the {@link FORM_CLIENT_FIELD} and {@link FORM_MUTATION_FIELD} hidden
89
+ * fields, so a resubmission of the same rendered form is the same mutation.
90
+ */
91
+ export declare const mintFormClient: (generate?: () => string) => FormClient;
92
+ /** The form client a posted form names, or `undefined` when its fields are missing or malformed. */
93
+ export declare const formClientOf: (data: FormData) => FormClient | undefined;
94
+ /** Where a form action's Mutation runs: the client group and the form client. */
95
+ export interface FormMutationTarget extends FormClient {
96
+ readonly clientGroupId: string;
97
+ }
98
+ /** Options for {@link formMutationTarget}. */
99
+ export interface FormMutationTargetOptions {
100
+ /** The client-group cookie's name. Default `"legation-client-group"`, as `clientGroupId` uses. */
101
+ readonly cookieName?: string | undefined;
102
+ }
103
+ /**
104
+ * Read where a form action's Mutation runs: the client group from its cookie
105
+ * (never minted here) and the form client from the posted fields. `undefined`
106
+ * means the action cannot name the group or the form: it stays an out-of-band
107
+ * writer.
108
+ */
109
+ export declare const formMutationTarget: (cookies: Cookies, data: FormData, options?: FormMutationTargetOptions) => FormMutationTarget | undefined;
110
+ /**
111
+ * Run the form layer's decoded args as the target's Mutation on the server
112
+ * (`Server.mutate`) and answer the receipt. Return it from the action as
113
+ * `{ [RECEIPT_KEY]: receipt }`, so the enhanced submit settles on it; a
114
+ * rejected receipt's `reason` is the form layer's to route to a message.
115
+ * Args that do not fit the Mutation's schema are a defect, not a field error.
116
+ */
117
+ export declare const runFormMutation: <Args, ArgsEncoded>(server: Server.Server, target: FormMutationTarget, mutation: Mutation.Mutator<string, Args, ArgsEncoded>, args: Args) => Effect.Effect<Server.Receipt, TransportError | VersionNotSupportedError>;
118
+ /**
119
+ * The part of SvelteKit's `ActionResult` (from `$app/forms`) read here: its
120
+ * type, and the data a `success` or `failure` result carries.
121
+ */
122
+ export interface FormActionResult {
123
+ readonly type: string;
124
+ readonly data?: Readonly<Record<string, unknown>> | undefined;
125
+ }
126
+ /**
127
+ * The submit function {@link enhanceFormMutation} returns, typed by the parts
128
+ * of SvelteKit's `SubmitFunction` it uses, so it can be passed to `enhance`.
129
+ */
130
+ export type FormSubmitFunction = (input: {
131
+ readonly formData: FormData;
132
+ }) => (opts: {
133
+ readonly result: FormActionResult;
134
+ readonly update: (options?: {
135
+ refreshAll?: boolean;
136
+ }) => Promise<void>;
137
+ }) => Promise<void>;
138
+ /** The receipt an action result carries under {@link RECEIPT_KEY}, or `undefined`. */
139
+ export declare const receiptOf: (result: FormActionResult) => Server.Receipt | undefined;
140
+ /** Options for {@link enhanceFormMutation}. */
141
+ export interface EnhanceFormMutationOptions<Args, ArgsEncoded> {
142
+ /** The Mutation the form's action runs. */
143
+ readonly mutation: Mutation.Mutator<string, Args, ArgsEncoded>;
144
+ /** The Mutation's args, read from the submitted form data (the form layer's decode). */
145
+ readonly args: (data: FormData) => Args;
146
+ /**
147
+ * Re-run the page's `load` functions after the action, SvelteKit's default.
148
+ * Default `false`: the overlay already shows the write, and the pull that
149
+ * covers it brings the confirmed rows (ADR-0030 #215 §4).
150
+ */
151
+ readonly refreshAll?: boolean | undefined;
152
+ /** Mint the per-submit form client id. Default `crypto.randomUUID`. */
153
+ readonly generate?: (() => string) | undefined;
154
+ /** Called with the receipt once the submission settles, e.g. to show a rejection's reason. */
155
+ readonly onReceipt?: ((receipt: Server.Receipt) => void) | undefined;
156
+ }
157
+ /**
158
+ * The enhanced submit for a form-action Mutation: pass it to SvelteKit's
159
+ * `use:enhance`. Each submit mints a fresh form client and writes it into the
160
+ * posted fields, overlays the Mutation's local effect under it without
161
+ * journaling it (the action delivers it), and settles on the receipt the
162
+ * action returns under {@link RECEIPT_KEY}: acknowledged moves the effect to
163
+ * the unpulled tail, where the pull that covers the form client releases it;
164
+ * rejected drops it. A result without a receipt (an error, a redirect, a
165
+ * failed network) abandons the effect, and the next pull shows the server's
166
+ * state. While offline the submit fails as the network fails (ADR-0030 open
167
+ * point 6).
168
+ */
169
+ export declare const enhanceFormMutation: <Args, ArgsEncoded>(runtime: ClientRuntime, options: EnhanceFormMutationOptions<Args, ArgsEncoded>) => FormSubmitFunction;
170
+ export {};
171
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","names":[],"sources":["../../src/kit/clientGroup.ts","../../src/kit/load.ts","../../src/kit/boot.ts","../../src/kit/formMutation.ts"],"mappings":";;;;;;iBAciB;;WAEN;;WAEA;;WAEA;;WAEA;;;qBAIE;;;;;;;;;;;qBAaA,gBAAiB,SAAS,SAAS,UAAU;;iBCVzC,4BAA4B,KAAK,UAAU;;WAEjD,SAAS;;;iBAIH,kCAAkC;WACxC,WAAW,UAAU;;;iBAIf;WACN,UAAU,UAAU;WACpB;;KAGN,oBAAoB,aAAa,iBAAiB,eAAe;;;;;;;;;;wBAWtD,aACd,SAAS,SACT,SAAS,4BACR,OAAO,OAAO,gBAAgB;;wBAEjB,aACd,SAAS,SACT,SAAS,sBACR,OAAO,OAAO,gBAAgB,mBAAmB,UAAU;;iBC3C7C,6BAA6B,KAAK,OAAO;;WAE/C,OAAO,MAAM,MAAM,YAAY;;WAE/B,WAAW,MAAM,MAAM,UAAU;;WAEjC,WAAW,UAAU;;WAErB,YAAY,cAAc,WAAW,OAAO;;;;;;;qBAwB1C,gBAAiB,SAAS,yBAAuB;;qBCbjD;;qBAEA;;qBAEA;;iBAGI;WACN;WACA;;;;;;;;qBASE,iBAAkB,4BAAqD;;qBAMvE,eAAgB,MAAM,aAAW;;iBAS7B,2BAA2B;WACjC;;;iBAIM;;WAEN;;;;;;;;qBASE,qBACX,SAAS,SACT,MAAM,UACN,UAAU,8BACT;;;;;;;;qBAeU,kBAAmB,MAAM,aACpC,QAAQ,OAAO,QACf,QAAQ,oBACR,UAAU,SAAS,gBAAgB,MAAM,cACzC,MAAM,SACL,OAAO,OAAO,OAAO,SAAS,iBAAiB;;;;;iBAajC;WACN;WACA,OAAO,SAAS;;;;;;YAOf,sBAAsB;WACvB,UAAU;OACd;WACI,QAAQ;WACR,SAAS;IAAY;QAA2B;MACrD;;qBAGO,YAAa,QAAQ,qBAAmB,OAAO;;iBAsB3C,2BAA2B,MAAM;;WAEvC,UAAU,SAAS,gBAAgB,MAAM;;WAEzC,OAAO,MAAM,aAAa;;;;;;WAM1B;;WAEA;;WAEA,cAAc,SAAS,OAAO;;;;;;;;;;;;;;qBAe5B,sBACV,MAAM,aACL,SAAS,eACT,SAAS,2BAA2B,MAAM,iBACzC"}