@confect/foldkit 10.0.0-next.19

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,338 @@
1
+ import * as PaginationError from "@confect/core/PaginationError";
2
+ import type * as Ref from "@confect/core/Ref";
3
+ import * as Effect from "effect/Effect";
4
+ import type * as Equivalence from "effect/Equivalence";
5
+ import * as Match from "effect/Match";
6
+ import * as Option from "effect/Option";
7
+ import * as Result from "effect/Result";
8
+ import * as Schema from "effect/Schema";
9
+ import * as Stream from "effect/Stream";
10
+ import type * as FoldkitSubscription from "foldkit/subscription";
11
+ import * as PaginatedQuery from "./PaginatedQuery";
12
+ import * as Client from "./Client";
13
+
14
+ /**
15
+ * Everything a reactive query subscription against `Query` can fail with: the
16
+ * ref's typed error (if it declares an `error` schema), a transport-level
17
+ * `WebSocketClientError`, or a `SchemaError` from encoding args or decoding
18
+ * returns.
19
+ */
20
+ export type Error<Query extends Ref.AnyConfectPublicQuery> =
21
+ | Ref.Error<Query>
22
+ | Client.WebSocketClientError
23
+ | Schema.SchemaError;
24
+
25
+ const paginatedError = <Query extends Ref.AnyConfectPublicPaginatedQuery>(
26
+ error: Error<Query>,
27
+ ): PaginatedQuery.Error<Query> =>
28
+ Match.value(error).pipe(
29
+ Match.when(Schema.isSchemaError, (schemaError) => schemaError),
30
+ Match.when(Match.instanceOf(Client.WebSocketClientError), (clientError) =>
31
+ PaginationError.fromConvexQueryError(clientError.cause).pipe(
32
+ Option.getOrElse(() => clientError),
33
+ ),
34
+ ),
35
+ Match.when(Match.any, (functionError) =>
36
+ PaginationError.fromConvexErrorData(functionError).pipe(
37
+ Option.getOrElse(() =>
38
+ PaginatedQuery.FunctionError({ error: functionError }),
39
+ ),
40
+ ),
41
+ ),
42
+ Match.exhaustive,
43
+ ) as PaginatedQuery.Error<Query>;
44
+
45
+ /**
46
+ * The dependency record of a `reactiveQuery` entry. `None` means the
47
+ * subscription is closed; a change from one `Some` to another tears the
48
+ * server subscription down and reopens it with the new args.
49
+ */
50
+ export interface Dependencies<Query extends Ref.AnyConfectPublicQuery> {
51
+ readonly args: Option.Option<Ref.Args<Query>>;
52
+ }
53
+
54
+ /**
55
+ * Maps a reactive query's emissions into the app's Messages. Every failure —
56
+ * the ref's typed error, a transport error, or a codec error — arrives via
57
+ * `onError`, so the resulting stream's error channel is `never`, as Foldkit
58
+ * requires.
59
+ */
60
+ export interface Handlers<
61
+ Query extends Ref.AnyConfectPublicQuery,
62
+ SuccessMessage,
63
+ ErrorMessage,
64
+ > {
65
+ readonly onSuccess: (returns: Ref.Returns<Query>) => SuccessMessage;
66
+ readonly onError: (error: Error<Query>) => ErrorMessage;
67
+ }
68
+
69
+ /**
70
+ * The `args` extractor is required whenever the query declares args; queries
71
+ * without args may omit it, in which case the subscription is always open.
72
+ */
73
+ type ArgsConfig<Query extends Ref.AnyConfectPublicQuery, Model> = {
74
+ readonly args?: (model: Model) => Option.Option<Ref.Args<Query>>;
75
+ } & (keyof Ref.Args<Query> extends never
76
+ ? unknown
77
+ : {
78
+ readonly args: (model: Model) => Option.Option<Ref.Args<Query>>;
79
+ });
80
+
81
+ /**
82
+ * A `dependenciesToStream` body for a hand-written subscription entry: runs
83
+ * the ref as a reactive query against the `Client` resource and maps
84
+ * every emission and every failure into a Message. Query failures are values,
85
+ * so the subscription remains live and can later emit a successful result.
86
+ *
87
+ * Prefer `reactiveQuery`, which builds the whole entry; reach for this when
88
+ * you need control over the entry's dependencies (custom gating, extra
89
+ * dependencies).
90
+ */
91
+ export const reactiveQueryStream =
92
+ <Query extends Ref.AnyConfectPublicQuery, SuccessMessage, ErrorMessage>(
93
+ ref: Query,
94
+ handlers: Handlers<Query, SuccessMessage, ErrorMessage>,
95
+ ) =>
96
+ (
97
+ ...args: Ref.OptionalArgs<Query>
98
+ ): Stream.Stream<SuccessMessage | ErrorMessage, never, Client.Client> =>
99
+ Client.Client.pipe(
100
+ Effect.map((client) => client.reactiveQueryResult(ref, ...args)),
101
+ Stream.unwrap,
102
+ Stream.map(
103
+ Result.match({
104
+ onSuccess: handlers.onSuccess,
105
+ onFailure: handlers.onError,
106
+ }),
107
+ ),
108
+ );
109
+
110
+ /**
111
+ * A complete Foldkit subscription entry for a Confect reactive query. The
112
+ * leading thunk fixes the `Model` type (TypeScript cannot partially infer
113
+ * type arguments), so the `args` extractor's parameter is already typed.
114
+ * Pass the result as an entry value to `Subscription.make`:
115
+ *
116
+ * ```ts
117
+ * import * as Confect from "@confect/foldkit"
118
+ * import * as Subscription from "foldkit/subscription"
119
+ *
120
+ * const subscriptions = Subscription.make<
121
+ * Model,
122
+ * Message,
123
+ * Confect.Client.Client
124
+ * >()(() => ({
125
+ * note: Confect.Subscription.reactiveQuery<Model>()(refs.public.notes.get, {
126
+ * args: (model) => Option.map(model.noteId, (noteId) => ({ noteId })),
127
+ * onSuccess: (note) => SucceededGetNote({ note }),
128
+ * onError: (error) => FailedGetNote({ message: String(error) }),
129
+ * }),
130
+ * }))
131
+ * ```
132
+ *
133
+ * The entry's dependencies are the query args wrapped in `Option`: `None`
134
+ * closes the subscription, a change from one `Some` to another unsubscribes
135
+ * and resubscribes with the new args, and structurally equal args leave the
136
+ * subscription running (equivalence is derived from the ref's args schema).
137
+ * Queries without args may omit `args`, leaving the subscription always open.
138
+ */
139
+ export const reactiveQuery =
140
+ <Model>() =>
141
+ <Query extends Ref.AnyConfectPublicQuery, SuccessMessage, ErrorMessage>(
142
+ ref: Query,
143
+ config: Handlers<Query, SuccessMessage, ErrorMessage> &
144
+ ArgsConfig<Query, Model>,
145
+ ): FoldkitSubscription.EntryWithoutKeepAlive<
146
+ Model,
147
+ SuccessMessage | ErrorMessage,
148
+ Dependencies<Query>,
149
+ Client.Client
150
+ > => {
151
+ const modelToArgs = config.args;
152
+
153
+ return {
154
+ dependenciesSchema: Schema.Struct({
155
+ args: Schema.Option(ref.args),
156
+ }),
157
+ modelToDependencies: (model) => ({
158
+ args:
159
+ modelToArgs === undefined
160
+ ? Option.some({} as Ref.Args<Query>)
161
+ : modelToArgs(model),
162
+ }),
163
+ dependenciesToStream: ({ args }) =>
164
+ Option.match(args, {
165
+ onNone: () => Stream.empty,
166
+ onSome: (someArgs) => reactiveQueryStream(ref, config)(someArgs),
167
+ }),
168
+ };
169
+ };
170
+
171
+ /**
172
+ * A complete Foldkit subscription entry that keeps exactly one live reactive
173
+ * page subscription in sync with a `PaginatedQuery` machine in the Model.
174
+ * The machine state is the single source of truth: navigation and
175
+ * split-pinning change the derived args, while `Idle` closes the subscription.
176
+ * Pagination ids are allocated here and installed by the first settlement.
177
+ *
178
+ * The leading thunk fixes the `Model` type (TypeScript cannot partially
179
+ * infer type arguments), so the `state` extractor's parameter is already
180
+ * typed:
181
+ *
182
+ * ```ts
183
+ * import * as Confect from "@confect/foldkit"
184
+ * import * as Subscription from "foldkit/subscription"
185
+ *
186
+ * const subscriptions = Subscription.make<
187
+ * Model,
188
+ * Message,
189
+ * Confect.Client.Client
190
+ * >()(() => ({
191
+ * notesPage: Confect.Subscription.paginatedQuery<Model>()(Notes, {
192
+ * state: (model) => model.notes,
193
+ * onSettled: (settlement) => SettledGetNotesPage({ settlement }),
194
+ * }),
195
+ * }))
196
+ * ```
197
+ *
198
+ * Pass the settlement to `PaginatedQuery.settle` in `update`. The request
199
+ * identity is included in both success and failure Messages, so superseded
200
+ * outcomes cannot settle a newer page or pagination session. Query failures
201
+ * do not close the Convex subscription; a later result can recover naturally.
202
+ */
203
+ type DependenciesSchema<Dependencies_> = Schema.Schema<Dependencies_> & {
204
+ readonly fields: Schema.Struct.Fields;
205
+ };
206
+
207
+ type EntryWithKeepAlive<Model, Message, Dependencies_, Services> = {
208
+ readonly dependenciesSchema: DependenciesSchema<Dependencies_>;
209
+ readonly modelToDependencies: (model: Model) => Dependencies_;
210
+ readonly keepAliveEquivalence: Equivalence.Equivalence<Dependencies_>;
211
+ readonly dependenciesToStream: (
212
+ dependencies: Dependencies_,
213
+ readDependencies: () => Dependencies_,
214
+ ) => Stream.Stream<Message, never, Services>;
215
+ };
216
+
217
+ export const paginatedQuery =
218
+ <Model>() =>
219
+ <
220
+ PaginatedQueryRef extends Ref.AnyConfectPublicPaginatedQuery,
221
+ SettledMessage,
222
+ >(
223
+ machine: PaginatedQuery.PaginatedQuery<PaginatedQueryRef>,
224
+ config: {
225
+ readonly state: (
226
+ model: Model,
227
+ ) => PaginatedQuery.State<
228
+ PaginatedQuery.Item<PaginatedQueryRef>,
229
+ PaginatedQuery.UserArgs<PaginatedQueryRef>,
230
+ PaginatedQuery.Error<PaginatedQueryRef>
231
+ >;
232
+ readonly onSettled: (
233
+ settlement: PaginatedQuery.Settlement<
234
+ PaginatedQuery.Item<PaginatedQueryRef>,
235
+ PaginatedQuery.UserArgs<PaginatedQueryRef>,
236
+ PaginatedQuery.Error<PaginatedQueryRef>
237
+ >,
238
+ ) => SettledMessage;
239
+ },
240
+ ): EntryWithKeepAlive<
241
+ Model,
242
+ SettledMessage,
243
+ {
244
+ readonly request: Option.Option<
245
+ PaginatedQuery.SubscriptionRequest<
246
+ PaginatedQuery.UserArgs<PaginatedQueryRef>
247
+ >
248
+ >;
249
+ },
250
+ Client.Client
251
+ > => {
252
+ const ref = machine.ref;
253
+ const composedArgsSchema = ref.args;
254
+ const requestEquivalence = Schema.toEquivalence(
255
+ machine.subscriptionRequestSchema,
256
+ );
257
+ const optionalRequestEquivalence = Option.makeEquivalence<
258
+ PaginatedQuery.SubscriptionRequest<
259
+ PaginatedQuery.UserArgs<PaginatedQueryRef>
260
+ >
261
+ >((left, right) =>
262
+ requestEquivalence(
263
+ { ...left, paginationId: Option.none() },
264
+ { ...right, paginationId: Option.none() },
265
+ ),
266
+ );
267
+
268
+ return {
269
+ dependenciesSchema: Schema.Struct({
270
+ request: Schema.Option(machine.subscriptionRequestSchema),
271
+ }),
272
+ modelToDependencies: (model) => ({
273
+ request: Match.value(config.state(model)).pipe(
274
+ Match.tag("Idle", () => Option.none()),
275
+ Match.tag("Active", (state) =>
276
+ Option.some(PaginatedQuery.getSubscriptionRequest(state)),
277
+ ),
278
+ Match.exhaustive,
279
+ ),
280
+ }),
281
+ keepAliveEquivalence: (left, right) =>
282
+ optionalRequestEquivalence(left.request, right.request),
283
+ dependenciesToStream: ({
284
+ request,
285
+ }): Stream.Stream<SettledMessage, never, Client.Client> =>
286
+ Option.match(request, {
287
+ onNone: () => Stream.empty,
288
+ onSome: (subscriptionRequest) =>
289
+ Stream.unwrap(
290
+ Effect.gen(function* () {
291
+ const client = yield* Client.Client;
292
+ const paginationId = yield* client.resolvePaginationId(
293
+ subscriptionRequest.paginationId,
294
+ );
295
+ const allocatedRequest = PaginatedQuery.allocateRequest(
296
+ subscriptionRequest,
297
+ paginationId,
298
+ );
299
+ const { descriptor, options } = allocatedRequest;
300
+ const composedArgs = composedArgsSchema.make({
301
+ ...allocatedRequest.args,
302
+ paginationOpts: {
303
+ numItems: options.initialNumItems,
304
+ cursor: descriptor.cursor,
305
+ id: paginationId,
306
+ ...Match.value(options.maximumRowsRead).pipe(
307
+ Match.when(undefined, () => ({})),
308
+ Match.when(Match.defined, (maximumRowsRead) => ({
309
+ maximumRowsRead,
310
+ })),
311
+ Match.exhaustive,
312
+ ),
313
+ ...Match.value(options.maximumBytesRead).pipe(
314
+ Match.when(undefined, () => ({})),
315
+ Match.when(Match.defined, (maximumBytesRead) => ({
316
+ maximumBytesRead,
317
+ })),
318
+ Match.exhaustive,
319
+ ),
320
+ ...Option.match(descriptor.endCursor, {
321
+ onNone: () => ({}),
322
+ onSome: (endCursor) => ({ endCursor }),
323
+ }),
324
+ },
325
+ });
326
+ return client.reactiveQueryResult(ref, composedArgs).pipe(
327
+ Stream.map((result) =>
328
+ config.onSettled({
329
+ request: allocatedRequest,
330
+ result: Result.mapError(result, paginatedError),
331
+ }),
332
+ ),
333
+ );
334
+ }),
335
+ ),
336
+ }),
337
+ };
338
+ };
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export * as Client from "./Client";
2
+ export * as Command from "./Command";
3
+ export * as PaginatedQuery from "./PaginatedQuery";
4
+ export * as Subscription from "./Subscription";