@confect/react 10.0.0-next.8 → 10.0.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,321 @@
1
+ import * as Equal from "effect/Equal";
2
+ import * as Function from "effect/Function";
3
+ import * as Hash from "effect/Hash";
4
+ import * as Match from "effect/Match";
5
+ import * as Pipeable from "effect/Pipeable";
6
+ import * as Predicate from "effect/Predicate";
7
+
8
+ const TypeId = "~@confect/react/PaginatedQueryResult";
9
+ type TypeId = typeof TypeId;
10
+
11
+ /**
12
+ * A `PaginatedQueryResult` represents the result of a Confect paginated query.
13
+ * The non-`Failure` variants mirror the statuses of `usePaginatedQuery` from
14
+ * `convex/react` and all carry `results` and `isLoading`, so the common UI
15
+ * shape—render the list, show a spinner while loading—needs only an
16
+ * {@link isFailure} early-out and plain field access.
17
+ *
18
+ * Every variant carries `results`, including `Failure`: when a later page
19
+ * fails, the pages already loaded are still worth rendering alongside the
20
+ * error, so they are not discarded. `Failure` additionally carries the query's
21
+ * decoded typed error.
22
+ *
23
+ * `loadMore` appears only on `CanLoadMore`, the one state it can make progress
24
+ * from. The underlying Convex hook exposes it on every status, but calling it
25
+ * while a page is in flight, once the list is exhausted, or after a failure is
26
+ * an intentional no-op there; narrowing to `CanLoadMore`—via
27
+ * {@link isCanLoadMore} or {@link match}—makes that statically apparent instead
28
+ * of silently dropping the call.
29
+ *
30
+ * When the query declares no `error` schema (`E` is `never`), the `Failure`
31
+ * variant is excluded from the type entirely, so `isLoading` is accessible
32
+ * without any narrowing.
33
+ *
34
+ * @typeParam Item - The type of a decoded page item.
35
+ * @typeParam E - The type of the decoded typed error in the `Failure` variant.
36
+ */
37
+ export type PaginatedQueryResult<Item, E = never> = [E] extends [never]
38
+ ?
39
+ | LoadingFirstPage<Item, E>
40
+ | LoadingMore<Item, E>
41
+ | CanLoadMore<Item, E>
42
+ | Exhausted<Item, E>
43
+ :
44
+ | LoadingFirstPage<Item, E>
45
+ | LoadingMore<Item, E>
46
+ | CanLoadMore<Item, E>
47
+ | Exhausted<Item, E>
48
+ | Failure<Item, E>;
49
+
50
+ /**
51
+ * Every variant regardless of `E`—the parameter type for guards and
52
+ * {@link match}, since `PaginatedQueryResult` itself excludes `Failure` when `E`
53
+ * is `never`.
54
+ */
55
+ export type Variants<Item, E = never> =
56
+ | LoadingFirstPage<Item, E>
57
+ | LoadingMore<Item, E>
58
+ | CanLoadMore<Item, E>
59
+ | Exhausted<Item, E>
60
+ | Failure<Item, E>;
61
+
62
+ export declare namespace PaginatedQueryResult {
63
+ // eslint-disable-next-line @typescript-eslint/no-shadow
64
+ export interface Proto<out Item, out E> extends Pipeable.Pipeable {
65
+ readonly [TypeId]: TypeId;
66
+ readonly "~Item": Item;
67
+ readonly "~E": E;
68
+ }
69
+
70
+ export type Item<R> = R extends { readonly "~Item": infer Item_ }
71
+ ? Item_
72
+ : never;
73
+
74
+ // eslint-disable-next-line @typescript-eslint/no-shadow
75
+ export type Failure<R> = R extends { readonly "~E": infer E } ? E : never;
76
+ }
77
+
78
+ export interface LoadingFirstPage<
79
+ Item,
80
+ E = never,
81
+ > extends PaginatedQueryResult.Proto<Item, E> {
82
+ readonly _tag: "LoadingFirstPage";
83
+ readonly skipped: boolean;
84
+ readonly results: ReadonlyArray<Item>;
85
+ readonly isLoading: true;
86
+ }
87
+
88
+ export interface LoadingMore<
89
+ Item,
90
+ E = never,
91
+ > extends PaginatedQueryResult.Proto<Item, E> {
92
+ readonly _tag: "LoadingMore";
93
+ readonly results: ReadonlyArray<Item>;
94
+ readonly isLoading: true;
95
+ }
96
+
97
+ export interface CanLoadMore<
98
+ Item,
99
+ E = never,
100
+ > extends PaginatedQueryResult.Proto<Item, E> {
101
+ readonly _tag: "CanLoadMore";
102
+ readonly results: ReadonlyArray<Item>;
103
+ readonly isLoading: false;
104
+ readonly loadMore: (numItems: number) => void;
105
+ }
106
+
107
+ export interface Exhausted<Item, E = never> extends PaginatedQueryResult.Proto<
108
+ Item,
109
+ E
110
+ > {
111
+ readonly _tag: "Exhausted";
112
+ readonly results: ReadonlyArray<Item>;
113
+ readonly isLoading: false;
114
+ }
115
+
116
+ export interface Failure<Item, E = never> extends PaginatedQueryResult.Proto<
117
+ Item,
118
+ E
119
+ > {
120
+ readonly _tag: "Failure";
121
+ readonly error: E;
122
+ /**
123
+ * The pages loaded before the failure.
124
+ */
125
+ readonly results: ReadonlyArray<Item>;
126
+ }
127
+
128
+ export const isPaginatedQueryResult = (
129
+ u: unknown,
130
+ ): u is Variants<unknown, unknown> => Predicate.hasProperty(u, TypeId);
131
+
132
+ const PaginatedQueryResultProto = {
133
+ [TypeId]: TypeId,
134
+ pipe(this: Variants<any, any>, ...args: ReadonlyArray<unknown>) {
135
+ return Pipeable.pipeArguments(
136
+ this,
137
+ args as unknown as Parameters<typeof Pipeable.pipeArguments>[1],
138
+ );
139
+ },
140
+ /**
141
+ * `CanLoadMore`'s `loadMore` is excluded from equality and hashing, like
142
+ * function fields generally: two results that render identically are equal
143
+ * even though their callbacks differ by identity.
144
+ */
145
+ [Equal.symbol](this: Variants<any, any>, that: Variants<any, any>): boolean {
146
+ if (this._tag !== that._tag) {
147
+ return false;
148
+ }
149
+ return Match.value(this).pipe(
150
+ Match.tag(
151
+ "LoadingFirstPage",
152
+ (self) => self.skipped === (that as LoadingFirstPage<any, any>).skipped,
153
+ ),
154
+ Match.tag("LoadingMore", "CanLoadMore", "Exhausted", (self) =>
155
+ Equal.equals(self.results, that.results),
156
+ ),
157
+ Match.tag(
158
+ "Failure",
159
+ (self) =>
160
+ Equal.equals(self.error, (that as Failure<any, any>).error) &&
161
+ Equal.equals(self.results, (that as Failure<any, any>).results),
162
+ ),
163
+ Match.exhaustive,
164
+ );
165
+ },
166
+ [Hash.symbol](this: Variants<any, any>): number {
167
+ const tagHash = Hash.string(this._tag);
168
+ return Match.value(this).pipe(
169
+ Match.tag("LoadingFirstPage", (self) =>
170
+ Hash.combine(tagHash)(Hash.hash(self.skipped)),
171
+ ),
172
+ Match.tag("LoadingMore", "CanLoadMore", "Exhausted", (self) =>
173
+ Hash.combine(tagHash)(Hash.hash(self.results)),
174
+ ),
175
+ Match.tag("Failure", (self) =>
176
+ Hash.combine(tagHash)(
177
+ Hash.combine(Hash.hash(self.error))(Hash.hash(self.results)),
178
+ ),
179
+ ),
180
+ Match.exhaustive,
181
+ );
182
+ },
183
+ };
184
+
185
+ const noResults: ReadonlyArray<never> = [];
186
+
187
+ export const loadingFirstPage = <Item = never, E = never>(options: {
188
+ skipped: boolean;
189
+ }): LoadingFirstPage<Item, E> =>
190
+ Object.assign(Object.create(PaginatedQueryResultProto), {
191
+ _tag: "LoadingFirstPage" as const,
192
+ skipped: options.skipped,
193
+ results: noResults,
194
+ isLoading: true as const,
195
+ });
196
+
197
+ export const loadingMore = <Item, E = never>(options: {
198
+ results: ReadonlyArray<Item>;
199
+ }): LoadingMore<Item, E> =>
200
+ Object.assign(Object.create(PaginatedQueryResultProto), {
201
+ _tag: "LoadingMore" as const,
202
+ results: options.results,
203
+ isLoading: true as const,
204
+ });
205
+
206
+ export const canLoadMore = <Item, E = never>(options: {
207
+ results: ReadonlyArray<Item>;
208
+ loadMore: (numItems: number) => void;
209
+ }): CanLoadMore<Item, E> =>
210
+ Object.assign(Object.create(PaginatedQueryResultProto), {
211
+ _tag: "CanLoadMore" as const,
212
+ results: options.results,
213
+ isLoading: false as const,
214
+ loadMore: options.loadMore,
215
+ });
216
+
217
+ export const exhausted = <Item, E = never>(options: {
218
+ results: ReadonlyArray<Item>;
219
+ }): Exhausted<Item, E> =>
220
+ Object.assign(Object.create(PaginatedQueryResultProto), {
221
+ _tag: "Exhausted" as const,
222
+ results: options.results,
223
+ isLoading: false as const,
224
+ });
225
+
226
+ export const failure = <E, Item = never>(options: {
227
+ error: E;
228
+ results: ReadonlyArray<Item>;
229
+ }): Failure<Item, E> =>
230
+ Object.assign(Object.create(PaginatedQueryResultProto), {
231
+ _tag: "Failure" as const,
232
+ error: options.error,
233
+ results: options.results,
234
+ });
235
+
236
+ export const isLoadingFirstPage = <Item, E>(
237
+ result: Variants<Item, E>,
238
+ ): result is LoadingFirstPage<Item, E> => result._tag === "LoadingFirstPage";
239
+
240
+ export const isLoadingMore = <Item, E>(
241
+ result: Variants<Item, E>,
242
+ ): result is LoadingMore<Item, E> => result._tag === "LoadingMore";
243
+
244
+ export const isCanLoadMore = <Item, E>(
245
+ result: Variants<Item, E>,
246
+ ): result is CanLoadMore<Item, E> => result._tag === "CanLoadMore";
247
+
248
+ export const isExhausted = <Item, E>(
249
+ result: Variants<Item, E>,
250
+ ): result is Exhausted<Item, E> => result._tag === "Exhausted";
251
+
252
+ export const isFailure = <Item, E>(
253
+ result: Variants<Item, E>,
254
+ ): result is Failure<Item, E> => result._tag === "Failure";
255
+
256
+ export const isLoading = <Item, E>(
257
+ result: Variants<Item, E>,
258
+ ): result is LoadingFirstPage<Item, E> | LoadingMore<Item, E> =>
259
+ result._tag === "LoadingFirstPage" || result._tag === "LoadingMore";
260
+
261
+ type MatchOptions<Item, E, V, W, X, Y, Z> = {
262
+ readonly onLoadingFirstPage: (skipped: boolean) => V;
263
+ readonly onLoadingMore: (results: ReadonlyArray<Item>) => W;
264
+ readonly onCanLoadMore: (
265
+ results: ReadonlyArray<Item>,
266
+ loadMore: (numItems: number) => void,
267
+ ) => X;
268
+ readonly onExhausted: (results: ReadonlyArray<Item>) => Y;
269
+ } & ([E] extends [never]
270
+ ? {}
271
+ : {
272
+ readonly onFailure: (error: E, results: ReadonlyArray<Item>) => Z;
273
+ });
274
+
275
+ type MatchReturns<E, V, W, X, Y, Z> = [E] extends [never]
276
+ ? V | W | X | Y
277
+ : V | W | X | Y | Z;
278
+
279
+ /**
280
+ * Matches a {@link PaginatedQueryResult} to the appropriate handler based on its
281
+ * tag. If the provided result cannot fail (i.e. `E` is `never`), `onFailure` is
282
+ * not required.
283
+ */
284
+ export const match: {
285
+ <Item, E, V, W, X, Y, Z = never>(
286
+ options: MatchOptions<Item, E, V, W, X, Y, Z>,
287
+ ): (self: Variants<Item, E>) => MatchReturns<E, V, W, X, Y, Z>;
288
+ <Item, E, V, W, X, Y, Z = never>(
289
+ self: Variants<Item, E>,
290
+ options: MatchOptions<Item, E, V, W, X, Y, Z>,
291
+ ): MatchReturns<E, V, W, X, Y, Z>;
292
+ } = Function.dual(
293
+ 2,
294
+ <Item, E, V, W, X, Y, Z = never>(
295
+ self: Variants<Item, E>,
296
+ options: MatchOptions<Item, E, V, W, X, Y, Z>,
297
+ ): MatchReturns<E, V, W, X, Y, Z> =>
298
+ Match.value(self).pipe(
299
+ Match.tag("LoadingFirstPage", (loadingFirstPage_) =>
300
+ options.onLoadingFirstPage(loadingFirstPage_.skipped),
301
+ ),
302
+ Match.tag("LoadingMore", (loadingMore_) =>
303
+ options.onLoadingMore(loadingMore_.results),
304
+ ),
305
+ Match.tag("CanLoadMore", (canLoadMore_) =>
306
+ options.onCanLoadMore(canLoadMore_.results, canLoadMore_.loadMore),
307
+ ),
308
+ Match.tag("Exhausted", (exhausted_) =>
309
+ options.onExhausted(exhausted_.results),
310
+ ),
311
+ Match.tag("Failure", (failure_) => {
312
+ if (Predicate.hasProperty(options, "onFailure")) {
313
+ return options.onFailure(failure_.error, failure_.results);
314
+ }
315
+ throw new Error(
316
+ "`onFailure` is required when error schema is provided",
317
+ );
318
+ }),
319
+ Match.exhaustive,
320
+ ) as MatchReturns<E, V, W, X, Y, Z>,
321
+ );
@@ -1,17 +1,18 @@
1
- import { identity } from "effect/Function";
2
1
  import * as Equal from "effect/Equal";
3
2
  import * as Function from "effect/Function";
4
3
  import * as Hash from "effect/Hash";
4
+ import * as Match from "effect/Match";
5
5
  import * as Pipeable from "effect/Pipeable";
6
6
  import * as Predicate from "effect/Predicate";
7
7
 
8
- const TypeId = "@confect/react/QueryResult";
8
+ const TypeId = "~@confect/react/QueryResult";
9
9
  type TypeId = typeof TypeId;
10
10
 
11
11
  /**
12
12
  * A `QueryResult` represents the result of a Confect query.
13
13
  *
14
- * @typeParam A - The type of the decoded `returns` value in the `Success` variant.
14
+ * @typeParam A - The type of the decoded `returns` value in the `Success`
15
+ * variant.
15
16
  * @typeParam E - The type of the decoded typed error in the `Failure` variant.
16
17
  */
17
18
  export type QueryResult<A, E = never> =
@@ -20,18 +21,17 @@ export type QueryResult<A, E = never> =
20
21
  | Failure<A, E>;
21
22
 
22
23
  export declare namespace QueryResult {
23
- export interface Proto<A, E> extends Pipeable.Pipeable {
24
- readonly [TypeId]: {
25
- readonly E: (_: never) => E;
26
- readonly A: (_: never) => A;
27
- };
24
+ export interface Proto<out A, out E> extends Pipeable.Pipeable {
25
+ readonly [TypeId]: TypeId;
26
+ readonly "~A": A;
27
+ readonly "~E": E;
28
28
  }
29
29
 
30
- // oxlint-disable-next-line no-shadow
31
- export type Success<R> = R extends QueryResult<infer A, infer _E> ? A : never;
30
+ // eslint-disable-next-line @typescript-eslint/no-shadow
31
+ export type Success<R> = R extends { readonly "~A": infer A } ? A : never;
32
32
 
33
- // oxlint-disable-next-line no-shadow
34
- export type Failure<R> = R extends QueryResult<infer _A, infer E> ? E : never;
33
+ // eslint-disable-next-line @typescript-eslint/no-shadow
34
+ export type Failure<R> = R extends { readonly "~E": infer E } ? E : never;
35
35
  }
36
36
 
37
37
  export interface Loading<A, E = never> extends QueryResult.Proto<A, E> {
@@ -53,10 +53,7 @@ export const isQueryResult = (u: unknown): u is QueryResult<unknown, unknown> =>
53
53
  Predicate.hasProperty(u, TypeId);
54
54
 
55
55
  const QueryResultProto = {
56
- [TypeId]: {
57
- E: identity,
58
- A: identity,
59
- },
56
+ [TypeId]: TypeId,
60
57
  pipe(this: QueryResult<any, any>, ...args: ReadonlyArray<unknown>) {
61
58
  return Pipeable.pipeArguments(
62
59
  this,
@@ -70,25 +67,34 @@ const QueryResultProto = {
70
67
  if (this._tag !== that._tag) {
71
68
  return false;
72
69
  }
73
- switch (this._tag) {
74
- case "Loading":
75
- return this.skipped === (that as Loading<any, any>).skipped;
76
- case "Success":
77
- return Equal.equals(this.value, (that as Success<any, any>).value);
78
- case "Failure":
79
- return Equal.equals(this.error, (that as Failure<any, any>).error);
80
- }
70
+ return Match.value(this).pipe(
71
+ Match.tag(
72
+ "Loading",
73
+ (self) => self.skipped === (that as Loading<any, any>).skipped,
74
+ ),
75
+ Match.tag("Success", (self) =>
76
+ Equal.equals(self.value, (that as Success<any, any>).value),
77
+ ),
78
+ Match.tag("Failure", (self) =>
79
+ Equal.equals(self.error, (that as Failure<any, any>).error),
80
+ ),
81
+ Match.exhaustive,
82
+ );
81
83
  },
82
84
  [Hash.symbol](this: QueryResult<any, any>): number {
83
85
  const tagHash = Hash.string(this._tag);
84
- switch (this._tag) {
85
- case "Loading":
86
- return Hash.combine(tagHash)(Hash.hash(this.skipped));
87
- case "Success":
88
- return Hash.combine(tagHash)(Hash.hash(this.value));
89
- case "Failure":
90
- return Hash.combine(tagHash)(Hash.hash(this.error));
91
- }
86
+ return Match.value(this).pipe(
87
+ Match.tag("Loading", (self) =>
88
+ Hash.combine(tagHash)(Hash.hash(self.skipped)),
89
+ ),
90
+ Match.tag("Success", (self) =>
91
+ Hash.combine(tagHash)(Hash.hash(self.value)),
92
+ ),
93
+ Match.tag("Failure", (self) =>
94
+ Hash.combine(tagHash)(Hash.hash(self.error)),
95
+ ),
96
+ Match.exhaustive,
97
+ );
92
98
  },
93
99
  };
94
100
 
@@ -135,12 +141,11 @@ type MatchReturns<E, X, Y, Z> = [E] extends [never] ? X | Y : X | Y | Z;
135
141
  * not required.
136
142
  *
137
143
  * @example
138
- * ```tsx
139
- * const result = QueryResult.match(queryResult, {
140
- * onLoading: (skipped) => skipped ? null : <p>Loading…</p>,
141
- * onSuccess: (value) => <p>{value.text}</p>,
142
- * onFailure: (error) => <p>Error: {error.message}</p>,
143
- * });
144
+ * const result = QueryResult.match(queryResult, {
145
+ * onLoading: (skipped) => (skipped ? null : <p>Loading…</p>),
146
+ * onSuccess: (value) => <p>{value.text}</p>,
147
+ * onFailure: (error) => <p>Error: {error.message}</p>,
148
+ * });
144
149
  */
145
150
  export const match: {
146
151
  <A, E, X, Y, Z = never>(
@@ -155,20 +160,18 @@ export const match: {
155
160
  <A, E, X, Y, Z = never>(
156
161
  self: QueryResult<A, E>,
157
162
  options: MatchOptions<A, E, X, Y, Z>,
158
- ): MatchReturns<E, X, Y, Z> => {
159
- switch (self._tag) {
160
- case "Loading":
161
- return options.onLoading(self.skipped);
162
- case "Success":
163
- return options.onSuccess(self.value);
164
- case "Failure": {
163
+ ): MatchReturns<E, X, Y, Z> =>
164
+ Match.value(self).pipe(
165
+ Match.tag("Loading", (loading) => options.onLoading(loading.skipped)),
166
+ Match.tag("Success", (success) => options.onSuccess(success.value)),
167
+ Match.tag("Failure", (failure) => {
165
168
  if (Predicate.hasProperty(options, "onFailure")) {
166
- return options.onFailure(self.error) as MatchReturns<E, X, Y, Z>;
169
+ return options.onFailure(failure.error);
167
170
  }
168
171
  throw new Error(
169
172
  "`onFailure` is required when error schema is provided",
170
173
  );
171
- }
172
- }
173
- },
174
+ }),
175
+ Match.exhaustive,
176
+ ) as MatchReturns<E, X, Y, Z>,
174
177
  );