@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.
- package/CHANGELOG.md +61 -0
- package/LICENSE +7 -0
- package/README.md +11 -0
- package/dist/Client.d.ts +69 -0
- package/dist/Client.d.ts.map +1 -0
- package/dist/Client.js +44 -0
- package/dist/Client.js.map +1 -0
- package/dist/Command.d.ts +158 -0
- package/dist/Command.d.ts.map +1 -0
- package/dist/Command.js +111 -0
- package/dist/Command.js.map +1 -0
- package/dist/PaginatedQuery.d.ts +227 -0
- package/dist/PaginatedQuery.d.ts.map +1 -0
- package/dist/PaginatedQuery.js +351 -0
- package/dist/PaginatedQuery.js.map +1 -0
- package/dist/Subscription.d.ts +132 -0
- package/dist/Subscription.d.ts.map +1 -0
- package/dist/Subscription.js +119 -0
- package/dist/Subscription.js.map +1 -0
- package/dist/WebSocketClient.d.ts +16 -0
- package/dist/WebSocketClient.d.ts.map +1 -0
- package/dist/_virtual/_rolldown/runtime.js +13 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/package.json +71 -0
- package/src/Client.ts +53 -0
- package/src/Command.ts +452 -0
- package/src/PaginatedQuery.ts +1001 -0
- package/src/Subscription.ts +338 -0
- package/src/index.ts +4 -0
|
@@ -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