foldkit 0.121.0 → 0.122.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,415 @@
1
+ import { Array, Function, Match as M, Option, Predicate, Result, Schema as S, } from 'effect';
2
+ import { ts } from '../schema/index.js';
3
+ /** Constructs the `Idle` state. A parameter-free callable Schema, so it can
4
+ * also serve as a Union member. */
5
+ export const Idle = ts('Idle');
6
+ /** Constructs the `Loading` state. A parameter-free callable Schema, so it
7
+ * can also serve as a Union member. */
8
+ export const Loading = ts('Loading');
9
+ /** Constructs a `Refreshing` state holding the previous good data. Plain
10
+ * value builder, generic in `A`; use the `Schema` factory's `Refreshing`
11
+ * for a Schema-bound constructor. */
12
+ export const Refreshing = (payload) => ({
13
+ _tag: 'Refreshing',
14
+ data: payload.data,
15
+ });
16
+ /** Constructs a `Failure` state carrying the error only. Plain value
17
+ * builder, generic in `E`; use the `Schema` factory's `Failure` for a
18
+ * Schema-bound constructor. */
19
+ export const Failure = (payload) => ({
20
+ _tag: 'Failure',
21
+ error: payload.error,
22
+ });
23
+ /** Constructs a `Stale` state carrying both the refresh error and the
24
+ * last good data. Plain value builder, generic in `A` and `E`; use the
25
+ * `Schema` factory's `Stale` for a Schema-bound constructor. */
26
+ export const Stale = (payload) => ({
27
+ _tag: 'Stale',
28
+ error: payload.error,
29
+ data: payload.data,
30
+ });
31
+ /** Constructs a `Success` state holding the data. Plain value builder,
32
+ * generic in `A`; use the `Schema` factory's `Success` for a Schema-bound
33
+ * constructor. */
34
+ export const Success = (payload) => ({
35
+ _tag: 'Success',
36
+ data: payload.data,
37
+ });
38
+ /** Bare-value alias for `Success({ data })`, mirroring `Result.succeed`. */
39
+ export const succeed = (data) => Success({ data });
40
+ /** Bare-value alias for `Failure({ error })`, mirroring `Result.fail`. */
41
+ export const fail = (error) => Failure({ error });
42
+ /** Builds the six-state `AsyncData` Schema for the given data and error
43
+ * Schemas (value-first). Put `schema` in your Model; use the returned
44
+ * constructors when you want ones typed to this instance's `A` and `E`.
45
+ *
46
+ * @example
47
+ * ```ts
48
+ * import { AsyncData } from 'foldkit'
49
+ * import { Schema as S } from 'effect'
50
+ *
51
+ * const Note = S.Struct({ id: S.String, body: S.String })
52
+ * const Notes = AsyncData.Schema(S.Array(Note), S.String)
53
+ *
54
+ * // Model field: typeof Notes.schema.Type
55
+ * const initial = AsyncData.Idle()
56
+ * const loaded = Notes.Success({ data: [] })
57
+ * ```
58
+ */
59
+ export const Schema = (dataSchema, errorSchema) => {
60
+ const RefreshingSchema = ts('Refreshing', { data: dataSchema });
61
+ const FailureSchema = ts('Failure', { error: errorSchema });
62
+ const StaleSchema = ts('Stale', { error: errorSchema, data: dataSchema });
63
+ const SuccessSchema = ts('Success', { data: dataSchema });
64
+ const schema = S.Union([
65
+ Idle,
66
+ Loading,
67
+ RefreshingSchema,
68
+ FailureSchema,
69
+ StaleSchema,
70
+ SuccessSchema,
71
+ ]);
72
+ return {
73
+ schema,
74
+ Idle,
75
+ Loading,
76
+ Refreshing: RefreshingSchema,
77
+ Failure: FailureSchema,
78
+ Stale: StaleSchema,
79
+ Success: SuccessSchema,
80
+ };
81
+ };
82
+ // OPERATION
83
+ /** Handles all six states exhaustively, passing each handler its unwrapped
84
+ * payload. `onStale` alone receives the whole `{ error, data }` payload
85
+ * object because `Stale` carries two fields. Use `matchData` when the view
86
+ * does not care which of the data-bearing states it is rendering.
87
+ *
88
+ * @example
89
+ * ```ts
90
+ * import { AsyncData } from 'foldkit'
91
+ *
92
+ * AsyncData.match(notes, {
93
+ * onIdle: () => 'Not loaded',
94
+ * onLoading: () => 'Loading',
95
+ * onRefreshing: notes => `Refreshing ${notes.length} notes`,
96
+ * onFailure: error => `Failed: ${error}`,
97
+ * onStale: ({ error, data }) => `${data.length} notes (stale: ${error})`,
98
+ * onSuccess: notes => `${notes.length} notes`,
99
+ * })
100
+ * ```
101
+ */
102
+ // NOTE: match, matchData, matchDataSplit, getData, and getError use
103
+ // refinement chains instead of Match because tagsExhaustive returns
104
+ // Unify<B>, which does not reduce when the handlers return a caller's
105
+ // naked generic in effect 4.0.0-beta.88. Combinators whose handlers
106
+ // return concrete AsyncData shapes use Match as usual.
107
+ export const match = Function.dual(2, (self, handlers) => {
108
+ if (isIdle(self)) {
109
+ return handlers.onIdle();
110
+ }
111
+ if (isLoading(self)) {
112
+ return handlers.onLoading();
113
+ }
114
+ if (isRefreshing(self)) {
115
+ return handlers.onRefreshing(self.data);
116
+ }
117
+ if (isFailure(self)) {
118
+ return handlers.onFailure(self.error);
119
+ }
120
+ if (isStale(self)) {
121
+ return handlers.onStale({ error: self.error, data: self.data });
122
+ }
123
+ return handlers.onSuccess(self.data);
124
+ });
125
+ /** Collapses the six states to the three channels a view usually renders:
126
+ * `onData` spans the data-bearing states (`Success`, `Refreshing`,
127
+ * `Stale`), `onFailure` receives the `Failure` error, and `onEmpty` covers
128
+ * `Idle` and `Loading` together. A `Stale` renders through `onData` so its
129
+ * data stays on screen. Use `matchDataSplit` when `Idle` and `Loading`
130
+ * render differently, and `match` when the stale error or the `Refreshing`
131
+ * signal matters. */
132
+ export const matchData = Function.dual(2, (self, handlers) => {
133
+ const maybeData = getData(self);
134
+ if (Option.isSome(maybeData)) {
135
+ return handlers.onData(maybeData.value);
136
+ }
137
+ if (isFailure(self)) {
138
+ return handlers.onFailure(self.error);
139
+ }
140
+ return handlers.onEmpty();
141
+ });
142
+ /** Like `matchData`, but the two cold states are split: `onIdle` handles
143
+ * `Idle` and `onLoading` handles `Loading`, for views that render nothing
144
+ * requested yet differently from a request in flight. `onData` and
145
+ * `onFailure` behave exactly as in `matchData`. */
146
+ export const matchDataSplit = Function.dual(2, (self, handlers) => {
147
+ const maybeData = getData(self);
148
+ if (Option.isSome(maybeData)) {
149
+ return handlers.onData(maybeData.value);
150
+ }
151
+ if (isFailure(self)) {
152
+ return handlers.onFailure(self.error);
153
+ }
154
+ if (isLoading(self)) {
155
+ return handlers.onLoading();
156
+ }
157
+ return handlers.onIdle();
158
+ });
159
+ /** Maps the data of all three data-bearing states, preserving each tag so
160
+ * the `Refreshing` and `Stale` signals survive a pure transform. `Stale`
161
+ * maps only its `data`, keeping its `error`. The no-data states pass
162
+ * through unchanged. */
163
+ export const map = Function.dual(2, (self, f) => M.value(self).pipe(M.withReturnType(), M.tagsExhaustive({
164
+ Idle: idle => idle,
165
+ Loading: loading => loading,
166
+ Refreshing: ({ data }) => Refreshing({ data: f(data) }),
167
+ Failure: failure => failure,
168
+ Stale: ({ error, data }) => Stale({ error, data: f(data) }),
169
+ Success: ({ data }) => Success({ data: f(data) }),
170
+ })));
171
+ /** Maps the error of the two error-bearing states: `Failure` and `Stale`
172
+ * transform (`Stale` keeps its `data`), everything else passes through.
173
+ * Use it to unify heterogeneous error types before a combine. */
174
+ export const mapError = Function.dual(2, (self, f) => M.value(self).pipe(M.withReturnType(), M.tagsExhaustive({
175
+ Idle: idle => idle,
176
+ Loading: loading => loading,
177
+ Refreshing: refreshing => refreshing,
178
+ Failure: ({ error }) => Failure({ error: f(error) }),
179
+ Stale: ({ error, data }) => Stale({ error: f(error), data }),
180
+ Success: success => success,
181
+ })));
182
+ /** Maps both channels with channel-named handlers: `onData` spans `Success`,
183
+ * `Refreshing`, and `Stale`; `onError` spans `Failure` and `Stale`. For
184
+ * `Stale`, both handlers apply. Tags are preserved. */
185
+ export const mapBoth = Function.dual(2, (self, handlers) => M.value(self).pipe(M.withReturnType(), M.tagsExhaustive({
186
+ Idle: idle => idle,
187
+ Loading: loading => loading,
188
+ Refreshing: ({ data }) => Refreshing({ data: handlers.onData(data) }),
189
+ Failure: ({ error }) => Failure({ error: handlers.onError(error) }),
190
+ Stale: ({ error, data }) => Stale({
191
+ error: handlers.onError(error),
192
+ data: handlers.onData(data),
193
+ }),
194
+ Success: ({ data }) => Success({ data: handlers.onData(data) }),
195
+ })));
196
+ /** Treats every data-bearing state (`Success`, `Refreshing`, `Stale`)
197
+ * exactly like `Success(data)`: returns `f(data)` unchanged, dropping the
198
+ * tag and any `Stale` error, so a caller can settle in-flight or stale data
199
+ * into `Success`. The no-data states pass through. Widens the error channel
200
+ * to `E | E2`, matching `Result.flatMap`. */
201
+ export const flatMap = Function.dual(2, (self, f) => M.value(self).pipe(M.withReturnType(), M.tagsExhaustive({
202
+ Idle: idle => idle,
203
+ Loading: loading => loading,
204
+ Refreshing: ({ data }) => f(data),
205
+ Failure: failure => failure,
206
+ Stale: ({ data }) => f(data),
207
+ Success: ({ data }) => f(data),
208
+ })));
209
+ /** Returns the data as an `Option`, spanning all three data-bearing states:
210
+ * `Some` for `Success`, `Refreshing`, and `Stale`, `None` otherwise.
211
+ * Payload-named because it deliberately spans three tags. */
212
+ export const getData = (self) => {
213
+ if (isSuccess(self) || isRefreshing(self) || isStale(self)) {
214
+ return Option.some(self.data);
215
+ }
216
+ return Option.none();
217
+ };
218
+ /** Returns the error as an `Option`, spanning both error-bearing states:
219
+ * `Some` for `Failure` and `Stale`, `None` otherwise. Symmetric with
220
+ * `getData`. */
221
+ export const getError = (self) => {
222
+ if (isFailure(self) || isStale(self)) {
223
+ return Option.some(self.error);
224
+ }
225
+ return Option.none();
226
+ };
227
+ /** Returns the data of any data-bearing state, or `onEmpty()` for the three
228
+ * no-data states. The fallback is a nullary thunk, mirroring
229
+ * `Option.getOrElse`, because the no-data states collapse to empty with no
230
+ * single payload. */
231
+ export const getOrElse = Function.dual(2, (self, onEmpty) => Option.getOrElse(getData(self), onEmpty));
232
+ /** Returns `true` only for the `Idle` state. Refinement. */
233
+ export const isIdle = (self) => self._tag === 'Idle';
234
+ /** Returns `true` only for the empty `Loading` state, not `Refreshing`.
235
+ * Refinement. */
236
+ export const isLoading = (self) => self._tag === 'Loading';
237
+ /** Returns `true` only for the `Refreshing` state. Refinement. */
238
+ export const isRefreshing = (self) => self._tag === 'Refreshing';
239
+ /** Returns `true` only for the `Failure` state, not `Stale`. Refinement. */
240
+ export const isFailure = (self) => self._tag === 'Failure';
241
+ /** Returns `true` only for the `Stale` state: a failed refresh holding the
242
+ * last good data. Refinement. */
243
+ export const isStale = (self) => self._tag === 'Stale';
244
+ /** Returns `true` only for the `Success` state, not `Refreshing` or
245
+ * `Stale`. Refinement. */
246
+ export const isSuccess = (self) => self._tag === 'Success';
247
+ /** Returns `true` when the state holds data: `Success`, `Refreshing`, or
248
+ * `Stale`. */
249
+ export const hasData = (self) => Option.isSome(getData(self));
250
+ /** Returns `true` when the state carries an error: `Failure` or `Stale`.
251
+ * The error-channel twin of `hasData`. */
252
+ export const hasError = (self) => Option.isSome(getError(self));
253
+ /** Returns `true` when a request is in flight: `Loading` or `Refreshing`
254
+ * only. `Stale` is not pending because its fetch already failed. Use for a
255
+ * spinner regardless of held data. */
256
+ export const isPending = (self) => isLoading(self) || isRefreshing(self);
257
+ const stateTags = [
258
+ 'Idle',
259
+ 'Loading',
260
+ 'Refreshing',
261
+ 'Failure',
262
+ 'Stale',
263
+ 'Success',
264
+ ];
265
+ /** Type guard on `unknown`: checks that the value has a `_tag` belonging to
266
+ * the six `AsyncData` states. */
267
+ export const isAsyncData = (input) => Predicate.hasProperty(input, '_tag') &&
268
+ Predicate.isString(input._tag) &&
269
+ Array.contains(stateTags, input._tag);
270
+ /** Returns `self` when it holds data (`Success`, `Refreshing`, or `Stale`),
271
+ * otherwise `that()`. The recovery and cache-fallback combinator: recover
272
+ * an `Idle`, `Loading`, or `Failure` into a secondary source without
273
+ * `match`. */
274
+ export const orElse = Function.dual(2, (self, that) => (hasData(self) ? self : that()));
275
+ /** The revalidate-on-entry transition: revalidates loaded data and loads
276
+ * cold data. The data-bearing loaded states (`Success`, `Stale`) move to
277
+ * `Refreshing`; the cold no-data states (`Idle`, `Failure`) start a fresh
278
+ * `Loading`; the already-pending states (`Loading`, `Refreshing`) yield
279
+ * `None` so the request in flight is not restarted. `None` means no
280
+ * transition, and no load Command, is needed. */
281
+ export const revalidateOrLoad = (self) => M.value(self).pipe(M.withReturnType(), M.tagsExhaustive({
282
+ Idle: () => Option.some(Loading()),
283
+ Loading: () => Option.none(),
284
+ Refreshing: () => Option.none(),
285
+ Failure: () => Option.some(Loading()),
286
+ Stale: ({ data }) => Option.some(Refreshing({ data })),
287
+ Success: ({ data }) => Option.some(Refreshing({ data })),
288
+ }));
289
+ /** The loaded-only revalidation transition: `Success` and `Stale` move to
290
+ * `Refreshing`, every other state yields `None`. Unlike
291
+ * `revalidateOrLoad` there is no cold-start `Loading`, so only caches
292
+ * that actually hold data revalidate; this is the generic refresher path
293
+ * after a mutation. */
294
+ export const revalidate = (self) => M.value(self).pipe(M.tag('Success', 'Stale', ({ data }) => Refreshing({ data })), M.option);
295
+ /** Combines two values under the two-tier lattice
296
+ * `Failure > Loading > Idle > Stale > Refreshing > Success`. If either
297
+ * input is a no-data state, the highest-ranked such state wins with no
298
+ * combination (`self`'s error wins when both are `Failure`). Otherwise both
299
+ * hold data: combine with `f` and tag the result with the highest-ranked
300
+ * data state present (`self`'s error wins when both are `Stale`).
301
+ *
302
+ * Because the combined value needs both inputs' data, a single no-data
303
+ * input collapses the whole combine to that state:
304
+ * `zipWith(Idle(), Success({ data }), f)` yields `Idle`. */
305
+ export const zipWith = Function.dual(3, (self, that, f) => {
306
+ if (isFailure(self)) {
307
+ return self;
308
+ }
309
+ if (isFailure(that)) {
310
+ return that;
311
+ }
312
+ if (isLoading(self) || isLoading(that)) {
313
+ return Loading();
314
+ }
315
+ if (isIdle(self) || isIdle(that)) {
316
+ return Idle();
317
+ }
318
+ const data = f(self.data, that.data);
319
+ if (isStale(self)) {
320
+ return Stale({ error: self.error, data });
321
+ }
322
+ if (isStale(that)) {
323
+ return Stale({ error: that.error, data });
324
+ }
325
+ if (isRefreshing(self) || isRefreshing(that)) {
326
+ return Refreshing({ data });
327
+ }
328
+ return Success({ data });
329
+ });
330
+ const combineLattice = (states, combineData) => {
331
+ const maybeFailure = Array.findFirst(states, isFailure);
332
+ if (Option.isSome(maybeFailure)) {
333
+ return maybeFailure.value;
334
+ }
335
+ if (Array.some(states, isLoading)) {
336
+ return Loading();
337
+ }
338
+ if (Array.some(states, isIdle)) {
339
+ return Idle();
340
+ }
341
+ const data = combineData(Array.getSomes(Array.map(states, getData)));
342
+ const maybeStale = Array.findFirst(states, isStale);
343
+ if (Option.isSome(maybeStale)) {
344
+ return Stale({ error: maybeStale.value.error, data });
345
+ }
346
+ if (Array.some(states, isRefreshing)) {
347
+ return Refreshing({ data });
348
+ }
349
+ return Success({ data });
350
+ };
351
+ const allIterable = (inputs) => combineLattice(Array.fromIterable(inputs), Function.identity);
352
+ const allRecord = (inputs) => {
353
+ const entries = Object.entries(inputs);
354
+ const keys = Array.map(entries, ([key]) => key);
355
+ const states = Array.map(entries, ([, state]) => state);
356
+ return combineLattice(states, datas => Object.fromEntries(Array.zip(keys, datas)));
357
+ };
358
+ /** Combines an iterable or record of values under the `zipWith` lattice.
359
+ * An iterable collapses to an in-order array of data (empty input yields
360
+ * `Success({ data: [] })`); a record builds a struct (empty input yields
361
+ * `Success({ data: {} })`). The highest-ranked no-data state blocks, the
362
+ * leftmost `Failure` error wins, and any `Stale` in an all-data set makes
363
+ * the result `Stale` with the leftmost `Stale` error. All inputs must share
364
+ * one error type; unify with `mapError` first.
365
+ *
366
+ * The record form is the multi-resource screen:
367
+ * `all({ user, orders, prefs })` combines into one value whose data is the
368
+ * struct of all datas. */
369
+ export const all = (inputs) => {
370
+ if (Symbol.iterator in inputs) {
371
+ return allIterable(inputs);
372
+ }
373
+ return allRecord(inputs);
374
+ };
375
+ /** Folds a settled `Result` into the previous state, keeping the last good
376
+ * data: a `Result` success becomes `Success`, and a `Result` failure
377
+ * becomes `Stale({ error, data })` when `self` holds data, else a bare
378
+ * `Failure`. The primary way to fold a fetch back into the Model, and the
379
+ * reason a failed refresh keeps data on screen: without `settle`, every
380
+ * fetch needs a success arm and a failure arm that hand-assemble
381
+ * `Success`, `Stale`, and `Failure` from the previous state. When a
382
+ * failure should deliberately drop the previous data, match on the
383
+ * `Result` directly and build the `Failure` explicitly.
384
+ *
385
+ * @example
386
+ * ```ts
387
+ * import { Effect, pipe } from 'effect'
388
+ * import { AsyncData, Command } from 'foldkit'
389
+ *
390
+ * // The Command settles the fetch into a Result instead of throwing:
391
+ * const LoadNotes = Command.define(
392
+ * 'LoadNotes',
393
+ * SettledLoadNotes,
394
+ * )(
395
+ * pipe(
396
+ * fetchNotes,
397
+ * Effect.result,
398
+ * Effect.map(result => SettledLoadNotes({ result })),
399
+ * ),
400
+ * )
401
+ *
402
+ * // One update arm folds it in, whatever the previous state was:
403
+ * SettledLoadNotes: ({ result }) => [
404
+ * evo(model, { notes: AsyncData.settle(result) }),
405
+ * [],
406
+ * ]
407
+ * ```
408
+ */
409
+ export const settle = Function.dual(2, (self, result) => Result.match(result, {
410
+ onSuccess: data => Success({ data }),
411
+ onFailure: error => Option.match(getData(self), {
412
+ onNone: () => Failure({ error }),
413
+ onSome: data => Stale({ error, data }),
414
+ }),
415
+ }));
@@ -0,0 +1,2 @@
1
+ export * from './asyncData.js';
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/asyncData/index.ts"],"names":[],"mappings":"AAAA,cAAc,gBAAgB,CAAA"}
@@ -0,0 +1 @@
1
+ export * from './asyncData.js';
@@ -0,0 +1,3 @@
1
+ export { Idle, Loading, Refreshing, Failure, Stale, Success, succeed, fail, Schema, match, matchData, matchDataSplit, map, mapError, mapBoth, flatMap, getData, getError, getOrElse, isIdle, isLoading, isRefreshing, isFailure, isStale, isSuccess, hasData, hasError, isPending, isAsyncData, orElse, revalidate, revalidateOrLoad, zipWith, all, settle, } from './index.js';
2
+ export type { AsyncData, AsyncDataEncoded, AsyncDataSchema } from './index.js';
3
+ //# sourceMappingURL=public.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"public.d.ts","sourceRoot":"","sources":["../../src/asyncData/public.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,IAAI,EACJ,OAAO,EACP,UAAU,EACV,OAAO,EACP,KAAK,EACL,OAAO,EACP,OAAO,EACP,IAAI,EACJ,MAAM,EACN,KAAK,EACL,SAAS,EACT,cAAc,EACd,GAAG,EACH,QAAQ,EACR,OAAO,EACP,OAAO,EACP,OAAO,EACP,QAAQ,EACR,SAAS,EACT,MAAM,EACN,SAAS,EACT,YAAY,EACZ,SAAS,EACT,OAAO,EACP,SAAS,EACT,OAAO,EACP,QAAQ,EACR,SAAS,EACT,WAAW,EACX,MAAM,EACN,UAAU,EACV,gBAAgB,EAChB,OAAO,EACP,GAAG,EACH,MAAM,GACP,MAAM,YAAY,CAAA;AAEnB,YAAY,EAAE,SAAS,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA"}
@@ -0,0 +1 @@
1
+ export { Idle, Loading, Refreshing, Failure, Stale, Success, succeed, fail, Schema, match, matchData, matchDataSplit, map, mapError, mapBoth, flatMap, getData, getError, getOrElse, isIdle, isLoading, isRefreshing, isFailure, isStale, isSuccess, hasData, hasError, isPending, isAsyncData, orElse, revalidate, revalidateOrLoad, zipWith, all, settle, } from './index.js';
@@ -1070,6 +1070,9 @@ declare const buildHtmlFactory: <Message>() => {
1070
1070
  } | {
1071
1071
  readonly _tag: "Filter";
1072
1072
  readonly value: string;
1073
+ } | {
1074
+ readonly _tag: "Loading";
1075
+ readonly value: string;
1073
1076
  } | {
1074
1077
  readonly _tag: "Start";
1075
1078
  readonly value: number;
@@ -1513,9 +1516,6 @@ declare const buildHtmlFactory: <Message>() => {
1513
1516
  } | {
1514
1517
  readonly _tag: "Sizes";
1515
1518
  readonly value: string;
1516
- } | {
1517
- readonly _tag: "Loading";
1518
- readonly value: string;
1519
1519
  } | {
1520
1520
  readonly _tag: "Decoding";
1521
1521
  readonly value: string;