@typed/async-data 1.0.0-beta.4 → 1.0.0-beta.6

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/src/index.ts CHANGED
@@ -7,52 +7,358 @@ import * as Result from "effect/Result";
7
7
  import * as Schema from "effect/Schema";
8
8
  import type { Unify } from "effect/Unify";
9
9
 
10
+ /**
11
+ * Reports completed work and, when known, the total amount of work.
12
+ *
13
+ * @remarks
14
+ * ## Why
15
+ * Progress belongs to the value so loading and refreshing states can carry the same transportable measurement.
16
+ *
17
+ * ## Ownership and lifetime
18
+ * This plain object acquires no resources. Its `readonly` fields are a TypeScript constraint; the runtime value is not frozen.
19
+ *
20
+ * @example
21
+ * ```ts
22
+ * import type { Progress } from "@typed/async-data"
23
+ * const progress: Progress = { loaded: 4, total: 10 }
24
+ * ```
25
+ *
26
+ * @category Models
27
+ * @since 1.0.0
28
+ */
10
29
  export interface Progress {
30
+ /**
31
+ * The finite amount of work completed so far.
32
+ * @remarks
33
+ * ## Why
34
+ * A required counter lets consumers render useful progress even when the total is unknown.
35
+ * ## Ownership and lifetime
36
+ * Inherits the resource-free lifetime of its enclosing `Progress` value.
37
+ * @category Models
38
+ * @since 1.0.0
39
+ */
11
40
  readonly loaded: number;
41
+ /**
42
+ * The finite total amount of work, when the producer knows it.
43
+ * @remarks
44
+ * ## Why
45
+ * Optionality distinguishes indeterminate work from a known total without inventing a sentinel value.
46
+ * ## Ownership and lifetime
47
+ * Inherits the resource-free lifetime of its enclosing `Progress` value.
48
+ * @category Models
49
+ * @since 1.0.0
50
+ */
12
51
  readonly total?: number | undefined;
13
52
  }
14
53
 
54
+ /**
55
+ * Represents an asynchronous value before work or a result exists.
56
+ * @remarks
57
+ * ## Why
58
+ * A distinct initial state prevents absence from being confused with loading, failure, or a successful `undefined` value.
59
+ * ## Ownership and lifetime
60
+ * This shape acquires no resources. `readonly` is compile-time only; values matching the interface need not be frozen.
61
+ * @example
62
+ * ```ts
63
+ * import { NoData } from "@typed/async-data"
64
+ * const initial = NoData
65
+ * ```
66
+ * @category Models
67
+ * @since 1.0.0
68
+ */
15
69
  export interface NoData {
70
+ /**
71
+ * The discriminant for the initial state.
72
+ * @remarks
73
+ * ## Why
74
+ * A literal tag enables exhaustive matching without runtime class identity.
75
+ * ## Ownership and lifetime
76
+ * Inherits the resource-free lifetime of its enclosing state.
77
+ * @category Models
78
+ * @since 1.0.0
79
+ */
16
80
  readonly _tag: "NoData";
17
81
  }
18
82
 
83
+ /**
84
+ * Represents active work that has not produced a prior value or failure.
85
+ * @remarks
86
+ * ## Why
87
+ * Loading is separate from refreshing so consumers can decide whether stale content remains available.
88
+ * ## Ownership and lifetime
89
+ * This plain object acquires no resources; the operation it describes is owned elsewhere and the object is not frozen.
90
+ * @example
91
+ * ```ts
92
+ * import { loading } from "@typed/async-data"
93
+ * const state = loading({ loaded: 0 })
94
+ * ```
95
+ * @category Models
96
+ * @since 1.0.0
97
+ */
19
98
  export interface Loading {
99
+ /**
100
+ * The discriminant for loading without a prior result.
101
+ * @remarks
102
+ * ## Why
103
+ * The literal tag makes loading branches explicit and exhaustively checkable.
104
+ * ## Ownership and lifetime
105
+ * Inherits the resource-free lifetime of its enclosing state.
106
+ * @category Models
107
+ * @since 1.0.0
108
+ */
20
109
  readonly _tag: "Loading";
110
+ /**
111
+ * Optional progress reported by the active producer.
112
+ * @remarks
113
+ * ## Why
114
+ * Keeping progress optional supports both determinate and uninstrumented operations.
115
+ * ## Ownership and lifetime
116
+ * Inherits the resource-free lifetime of its enclosing state.
117
+ * @category Models
118
+ * @since 1.0.0
119
+ */
21
120
  readonly progress?: Progress | undefined;
22
121
  }
23
122
 
123
+ /**
124
+ * Represents a successfully produced value, optionally while it refreshes.
125
+ * @remarks
126
+ * ## Why
127
+ * Success keeps the last usable value available while progress can describe newer work.
128
+ * ## Ownership and lifetime
129
+ * This plain wrapper acquires no resources; ownership of `value` remains with the caller and `readonly` does not freeze either object.
130
+ * @example
131
+ * ```ts
132
+ * import { success } from "@typed/async-data"
133
+ * const state = success({ id: 1 })
134
+ * ```
135
+ * @category Models
136
+ * @since 1.0.0
137
+ */
24
138
  export interface Success<A> {
139
+ /**
140
+ * The discriminant for a successful value.
141
+ * @remarks
142
+ * ## Why
143
+ * The literal tag enables exhaustive success handling without inspecting the payload.
144
+ * ## Ownership and lifetime
145
+ * Inherits the resource-free lifetime of its enclosing state.
146
+ * @category Models
147
+ * @since 1.0.0
148
+ */
25
149
  readonly _tag: "Success";
150
+ /**
151
+ * The most recently successful value.
152
+ * @remarks
153
+ * ## Why
154
+ * The payload remains directly accessible even when `progress` marks a refresh.
155
+ * ## Ownership and lifetime
156
+ * Inherits the enclosing state's lifetime; the caller retains ownership of the referenced value.
157
+ * @category Models
158
+ * @since 1.0.0
159
+ */
26
160
  readonly value: A;
161
+ /**
162
+ * Optional progress for a refresh of the successful value.
163
+ * @remarks
164
+ * ## Why
165
+ * Its presence distinguishes refreshing success from a settled success.
166
+ * ## Ownership and lifetime
167
+ * Inherits the resource-free lifetime of its enclosing state.
168
+ * @category Models
169
+ * @since 1.0.0
170
+ */
27
171
  readonly progress?: Progress | undefined;
28
172
  }
29
173
 
174
+ /**
175
+ * Represents an Effect failure, optionally while a retry refreshes it.
176
+ * @remarks
177
+ * ## Why
178
+ * Retaining the complete Effect `Cause` preserves typed errors, defects, and interruption instead of flattening failure detail.
179
+ * ## Ownership and lifetime
180
+ * This plain wrapper acquires no resources; its `Cause` is persistent, but the wrapper itself is not frozen.
181
+ * @example
182
+ * ```ts
183
+ * import { failure } from "@typed/async-data"
184
+ * import { Cause } from "effect"
185
+ * const state = failure(Cause.fail("offline"))
186
+ * ```
187
+ * See [Effect Cause](https://effect.website/docs/error-management/cause/).
188
+ * @category Models
189
+ * @since 1.0.0
190
+ */
30
191
  export interface Failure<E> {
192
+ /**
193
+ * The discriminant for a failed computation.
194
+ * @remarks
195
+ * ## Why
196
+ * The literal tag separates failure from absence and loading.
197
+ * ## Ownership and lifetime
198
+ * Inherits the resource-free lifetime of its enclosing state.
199
+ * @category Models
200
+ * @since 1.0.0
201
+ */
31
202
  readonly _tag: "Failure";
203
+ /**
204
+ * The complete Effect cause of failure.
205
+ * @remarks
206
+ * ## Why
207
+ * Cause preservation keeps defects and interruption observable alongside typed failures.
208
+ * ## Ownership and lifetime
209
+ * Inherits the enclosing state's lifetime; Effect Cause is persistent.
210
+ * @category Models
211
+ * @since 1.0.0
212
+ */
32
213
  readonly cause: Cause.Cause<E>;
214
+ /**
215
+ * Optional progress for a retry of the failed operation.
216
+ * @remarks
217
+ * ## Why
218
+ * Its presence distinguishes a refreshing failure from a settled failure.
219
+ * ## Ownership and lifetime
220
+ * Inherits the resource-free lifetime of its enclosing state.
221
+ * @category Models
222
+ * @since 1.0.0
223
+ */
33
224
  readonly progress?: Progress | undefined;
34
225
  }
35
226
 
227
+ /**
228
+ * Represents an optimistic value together with the exact state it replaced.
229
+ * @remarks
230
+ * ## Why
231
+ * Preserving history makes rollback and nested optimistic transformations explicit rather than hiding them in renderer state.
232
+ * ## Ownership and lifetime
233
+ * This plain wrapper acquires no resources and retains its previous state; `readonly` is compile-time only.
234
+ * @example
235
+ * ```ts
236
+ * import { optimistic, success } from "@typed/async-data"
237
+ * const state = optimistic(success(1), 2)
238
+ * ```
239
+ * @category Models
240
+ * @since 1.0.0
241
+ */
36
242
  export interface Optimistic<A, E> {
243
+ /**
244
+ * The discriminant for optimistic state.
245
+ * @remarks
246
+ * ## Why
247
+ * The literal tag enables explicit optimistic handling and cycle-safe traversal.
248
+ * ## Ownership and lifetime
249
+ * Inherits the resource-free lifetime of its enclosing state.
250
+ * @category Models
251
+ * @since 1.0.0
252
+ */
37
253
  readonly _tag: "Optimistic";
254
+ /**
255
+ * The value currently presented optimistically.
256
+ * @remarks
257
+ * ## Why
258
+ * Separating the provisional value from history makes pending intent directly observable.
259
+ * ## Ownership and lifetime
260
+ * Inherits the enclosing state's lifetime; the caller retains ownership of the referenced value.
261
+ * @category Models
262
+ * @since 1.0.0
263
+ */
38
264
  readonly value: A;
265
+ /**
266
+ * The state to restore or inspect beneath this optimistic layer.
267
+ * @remarks
268
+ * ## Why
269
+ * Explicit history supports deterministic rollback and transformation of nested optimistic states.
270
+ * ## Ownership and lifetime
271
+ * Inherits the enclosing optimistic wrapper's resource-free lifetime.
272
+ * @category Models
273
+ * @since 1.0.0
274
+ */
39
275
  readonly previous: AsyncData<A, E>;
40
276
  }
41
277
 
278
+ /**
279
+ * The complete renderer-independent state machine for asynchronous data.
280
+ * @remarks
281
+ * ## Why
282
+ * A closed union makes absence, work, value, failure, and optimistic history compositional and exhaustively matchable.
283
+ * ## Ownership and lifetime
284
+ * AsyncData values acquire no resources; they describe operation state whose execution is owned elsewhere.
285
+ * @example
286
+ * ```ts
287
+ * import type { AsyncData } from "@typed/async-data"
288
+ * import { success } from "@typed/async-data"
289
+ * const state: AsyncData<number, string> = success(1)
290
+ * ```
291
+ * @category Models
292
+ * @since 1.0.0
293
+ */
42
294
  export type AsyncData<A, E> = NoData | Loading | Success<A> | Failure<E> | Optimistic<A, E>;
43
295
 
44
- export type Refreshing<A, E> = (Success<A> | Failure<E>) & { readonly progress: Progress };
296
+ /**
297
+ * A success or failure that retains progress for an active refresh.
298
+ * @remarks
299
+ * ## Why
300
+ * This refinement lets consumers distinguish refreshes without losing the previous result.
301
+ * ## Ownership and lifetime
302
+ * This structural view acquires no resources and has the lifetime of the underlying state value.
303
+ * @example
304
+ * ```ts
305
+ * import type { Refreshing } from "@typed/async-data"
306
+ * const state: Refreshing<number, never> = { _tag: "Success", value: 1, progress: { loaded: 0 } }
307
+ * ```
308
+ * @category Models
309
+ * @since 1.0.0
310
+ */
311
+ export type Refreshing<A, E> = (Success<A> | Failure<E>) & {
312
+ /** Progress whose presence marks the retained result as actively refreshing. @since 1.0.0 */
313
+ readonly progress: Progress;
314
+ };
315
+
316
+ type EncodedFailure = {
317
+ readonly _tag: "Failure";
318
+ readonly cause: Schema.Json;
319
+ readonly progress?: Progress | undefined;
320
+ };
45
321
 
322
+ type EncodedOptimistic<A, E> = {
323
+ readonly _tag: "Optimistic";
324
+ readonly value: A;
325
+ readonly previous: EncodedAsyncData<A, E>;
326
+ };
327
+
328
+ type EncodedAsyncData<A, E> =
329
+ | NoData
330
+ | Loading
331
+ | Success<A>
332
+ | EncodedFailure
333
+ | EncodedOptimistic<A, E>;
334
+
335
+ /**
336
+ * Builds an Effect Schema codec for recursive AsyncData values.
337
+ * @remarks
338
+ * ## Why
339
+ * A codec gives transport boundaries the same state model as application code while encoding `Cause` as JSON and preserving schema service requirements.
340
+ * ## Ownership and lifetime
341
+ * Codec construction is pure and acquires no resources; decoding and encoding use the services declared by the supplied schemas.
342
+ * @example
343
+ * ```ts
344
+ * import { AsyncData } from "@typed/async-data"
345
+ * import { Schema } from "effect"
346
+ * const codec = AsyncData(Schema.String, Schema.String)
347
+ * ```
348
+ * See [Effect Schema](https://effect.website/docs/schema/introduction/).
349
+ * @category Schemas
350
+ * @since 1.0.0
351
+ */
46
352
  export const AsyncData = <const A extends Schema.Top, E extends Schema.Top>(
47
353
  A: A,
48
354
  E: E,
49
355
  ): Schema.Codec<
50
356
  AsyncData<A["Type"], E["Type"]>,
51
- AsyncData<A["Encoded"], E["Encoded"]>,
357
+ EncodedAsyncData<A["Encoded"], E["Encoded"]>,
52
358
  A["DecodingServices"] | E["DecodingServices"],
53
359
  A["EncodingServices"] | E["EncodingServices"]
54
360
  > => {
55
- const Progress = Schema.Struct({ loaded: Schema.Number, total: Schema.optional(Schema.Number) });
361
+ const Progress = Schema.Struct({ loaded: Schema.Finite, total: Schema.optional(Schema.Finite) });
56
362
  const NoData = Schema.Struct({ _tag: Schema.tag("NoData") });
57
363
  const Loading = Schema.Struct({
58
364
  _tag: Schema.tag("Loading"),
@@ -63,96 +369,431 @@ export const AsyncData = <const A extends Schema.Top, E extends Schema.Top>(
63
369
  value: A,
64
370
  progress: Schema.optional(Progress),
65
371
  });
372
+ const CauseSchema = Schema.Cause(E, Schema.Defect());
66
373
  const Failure = Schema.Struct({
67
374
  _tag: Schema.tag("Failure"),
68
- cause: E,
375
+ cause: Schema.toCodecJson(CauseSchema),
69
376
  progress: Schema.optional(Progress),
70
377
  });
71
378
  const Optimistic = Schema.Struct({
72
379
  _tag: Schema.tag("Optimistic"),
73
380
  value: A,
74
- previous: Schema.suspend(() => AsyncData),
381
+ previous: Schema.suspend(() => AsyncDataSchema),
75
382
  });
76
- const AsyncData = Schema.Union([NoData, Loading, Success, Failure, Optimistic]) as Schema.Codec<
383
+ const AsyncDataSchema = Schema.Union([
384
+ NoData,
385
+ Loading,
386
+ Success,
387
+ Failure,
388
+ Optimistic,
389
+ ]) as Schema.Codec<
77
390
  AsyncData<A["Type"], E["Type"]>,
78
- AsyncData<A["Encoded"], E["Encoded"]>,
391
+ EncodedAsyncData<A["Encoded"], E["Encoded"]>,
79
392
  A["DecodingServices"] | E["DecodingServices"],
80
393
  A["EncodingServices"] | E["EncodingServices"]
81
394
  >;
82
- return AsyncData;
395
+ return AsyncDataSchema;
83
396
  };
84
397
 
398
+ /**
399
+ * Tests whether an AsyncData value is `NoData`.
400
+ * @remarks
401
+ * ## Why
402
+ * A named refinement narrows the union without duplicating tag checks.
403
+ * ## Ownership and lifetime
404
+ * This pure predicate acquires no resources and does not retain its argument.
405
+ * @example
406
+ * ```ts
407
+ * import { isNoData, NoData } from "@typed/async-data"
408
+ * isNoData(NoData)
409
+ * ```
410
+ * @category Refinements
411
+ * @since 1.0.0
412
+ */
85
413
  export const isNoData = <A, E>(asyncData: AsyncData<A, E>): asyncData is NoData =>
86
414
  asyncData._tag === "NoData";
415
+ /**
416
+ * Tests whether an AsyncData value is loading without a prior result.
417
+ * @remarks
418
+ * ## Why
419
+ * This refinement deliberately excludes refreshing success and failure states.
420
+ * ## Ownership and lifetime
421
+ * This pure predicate acquires no resources and does not retain its argument.
422
+ * @example
423
+ * ```ts
424
+ * import { isLoading, loading } from "@typed/async-data"
425
+ * isLoading(loading())
426
+ * ```
427
+ * @category Refinements
428
+ * @since 1.0.0
429
+ */
87
430
  export const isLoading = <A, E>(asyncData: AsyncData<A, E>): asyncData is Loading =>
88
431
  asyncData._tag === "Loading";
432
+ /**
433
+ * Tests whether an AsyncData value contains a successful value.
434
+ * @remarks
435
+ * ## Why
436
+ * This refinement separates settled or refreshing success from optimistic state.
437
+ * ## Ownership and lifetime
438
+ * This pure predicate acquires no resources and does not retain its argument.
439
+ * @example
440
+ * ```ts
441
+ * import { isSuccess, success } from "@typed/async-data"
442
+ * isSuccess(success(1))
443
+ * ```
444
+ * @category Refinements
445
+ * @since 1.0.0
446
+ */
89
447
  export const isSuccess = <A, E>(asyncData: AsyncData<A, E>): asyncData is Success<A> =>
90
448
  asyncData._tag === "Success";
449
+ /**
450
+ * Tests whether an AsyncData value contains an Effect Cause.
451
+ * @remarks
452
+ * ## Why
453
+ * The refinement exposes complete failure information without treating optimistic history as a current failure.
454
+ * ## Ownership and lifetime
455
+ * This pure predicate acquires no resources and does not retain its argument.
456
+ * @example
457
+ * ```ts
458
+ * import { failure, isFailure } from "@typed/async-data"
459
+ * import { Cause } from "effect"
460
+ * isFailure(failure(Cause.fail("offline")))
461
+ * ```
462
+ * @category Refinements
463
+ * @since 1.0.0
464
+ */
91
465
  export const isFailure = <A, E>(asyncData: AsyncData<A, E>): asyncData is Failure<E> =>
92
466
  asyncData._tag === "Failure";
467
+ /**
468
+ * Tests whether an AsyncData value is an optimistic history node.
469
+ * @remarks
470
+ * ## Why
471
+ * A dedicated refinement makes history traversal explicit and type safe.
472
+ * ## Ownership and lifetime
473
+ * This pure predicate acquires no resources and does not retain its argument.
474
+ * @example
475
+ * ```ts
476
+ * import { isOptimistic, optimistic, success } from "@typed/async-data"
477
+ * isOptimistic(optimistic(success(1), 2))
478
+ * ```
479
+ * @category Refinements
480
+ * @since 1.0.0
481
+ */
93
482
  export const isOptimistic = <A, E>(asyncData: AsyncData<A, E>): asyncData is Optimistic<A, E> =>
94
483
  asyncData._tag === "Optimistic";
95
484
 
96
- const TAGS = new Set(["NoData", "Loading", "Success", "Failure", "Optimistic"]);
485
+ const hasValidProgress = (u: object): boolean => {
486
+ if (!hasProperty(u, "progress") || u.progress === undefined) {
487
+ return true;
488
+ }
489
+ const progress = u.progress;
490
+ return (
491
+ isObject(progress) &&
492
+ hasProperty(progress, "loaded") &&
493
+ typeof progress.loaded === "number" &&
494
+ Number.isFinite(progress.loaded) &&
495
+ (!hasProperty(progress, "total") ||
496
+ progress.total === undefined ||
497
+ (typeof progress.total === "number" && Number.isFinite(progress.total)))
498
+ );
499
+ };
97
500
 
98
- export const isAsyncData = <A, E>(u: unknown): u is AsyncData<A, E> =>
99
- isObject(u) && hasProperty(u, "_tag") && isString(u._tag) && TAGS.has(u._tag);
501
+ /**
502
+ * Validates the runtime structure of an unknown AsyncData value.
503
+ * @remarks
504
+ * ## Why
505
+ * Boundary validation rejects malformed progress, failure causes, and cyclic optimistic histories before application logic depends on them.
506
+ * ## Ownership and lifetime
507
+ * Validation is iterative, acquires no resources, and retains no visited objects after returning.
508
+ * @example
509
+ * ```ts
510
+ * import { isAsyncData } from "@typed/async-data"
511
+ * isAsyncData({ _tag: "Loading", progress: { loaded: 1 } })
512
+ * ```
513
+ * @category Refinements
514
+ * @since 1.0.0
515
+ */
516
+ export const isAsyncData = <A, E>(u: unknown): u is AsyncData<A, E> => {
517
+ const visited = new WeakSet<object>();
518
+ let current = u;
519
+ while (isObject(current) && hasProperty(current, "_tag") && isString(current._tag)) {
520
+ switch (current._tag) {
521
+ case "NoData":
522
+ return true;
523
+ case "Loading":
524
+ return hasValidProgress(current);
525
+ case "Success":
526
+ return hasProperty(current, "value") && hasValidProgress(current);
527
+ case "Failure":
528
+ return (
529
+ hasProperty(current, "cause") && Cause.isCause(current.cause) && hasValidProgress(current)
530
+ );
531
+ case "Optimistic":
532
+ if (
533
+ visited.has(current) ||
534
+ !hasProperty(current, "value") ||
535
+ !hasProperty(current, "previous")
536
+ ) {
537
+ return false;
538
+ }
539
+ visited.add(current);
540
+ current = current.previous;
541
+ break;
542
+ default:
543
+ return false;
544
+ }
545
+ }
546
+ return false;
547
+ };
100
548
 
549
+ /**
550
+ * Tests whether a success or failure carries active refresh progress.
551
+ * @remarks
552
+ * ## Why
553
+ * Refreshing keeps an existing result visible while making new work observable.
554
+ * ## Ownership and lifetime
555
+ * This pure predicate acquires no resources and does not retain its argument.
556
+ * @example
557
+ * ```ts
558
+ * import { isRefreshing, success } from "@typed/async-data"
559
+ * isRefreshing(success("cached", { loaded: 0 }))
560
+ * ```
561
+ * @category Refinements
562
+ * @since 1.0.0
563
+ */
101
564
  export const isRefreshing = <A, E>(asyncData: AsyncData<A, E>): asyncData is Refreshing<A, E> =>
102
565
  (asyncData._tag === "Success" || asyncData._tag === "Failure") &&
103
566
  asyncData.progress !== undefined;
104
567
 
568
+ /**
569
+ * Tests whether the base state is loading or refreshing through optimistic history.
570
+ * @remarks
571
+ * ## Why
572
+ * Pending status follows the underlying operation rather than disappearing when optimistic values are layered above it; cycles return `false`.
573
+ *
574
+ * **Known type/runtime discrepancy:** an `Optimistic` wrapper over a pending base returns `true` at runtime, but the existing type predicate narrows to `Loading | Refreshing<A, E>` and excludes `Optimistic`. Do not use this function to narrow before reading `_tag`; use it only as a pending-status boolean and refine the original value separately. The predicate is retained for compatibility and is not an accurate description of every `true` result.
575
+ * ## Ownership and lifetime
576
+ * This iterative predicate acquires no resources and retains no visited objects after returning.
577
+ * @example
578
+ * ```ts
579
+ * import { isPending, loading, optimistic } from "@typed/async-data"
580
+ * isPending(optimistic(loading(), "draft"))
581
+ * ```
582
+ * @category Refinements
583
+ * @since 1.0.0
584
+ */
105
585
  export const isPending = <A, E>(
106
586
  asyncData: AsyncData<A, E>,
107
- ): asyncData is Loading | Refreshing<A, E> =>
108
- asyncData._tag === "Loading" || isRefreshing(asyncData);
587
+ ): asyncData is Loading | Refreshing<A, E> => {
588
+ const visited = new WeakSet<object>();
589
+ let current: AsyncData<A, E> = asyncData;
590
+ while (isOptimistic(current)) {
591
+ if (visited.has(current)) {
592
+ return false;
593
+ }
594
+ visited.add(current);
595
+ current = current.previous;
596
+ }
597
+ return current._tag === "Loading" || isRefreshing(current);
598
+ };
109
599
 
600
+ /**
601
+ * The canonical shared initial AsyncData object.
602
+ * @remarks
603
+ * ## Why
604
+ * A shared singleton avoids allocating an equivalent empty state and gives callers an exact constructor value.
605
+ * ## Ownership and lifetime
606
+ * The module owns this resource-free singleton for the process lifetime. It is not frozen: mutation through an unsafe cast changes the shared value for every caller.
607
+ * @example
608
+ * ```ts
609
+ * import { NoData } from "@typed/async-data"
610
+ * const initial = NoData
611
+ * ```
612
+ * @category Constructors
613
+ * @since 1.0.0
614
+ */
110
615
  export const NoData: NoData = { _tag: "NoData" };
111
616
 
617
+ /**
618
+ * Creates a loading state with optional progress.
619
+ * @remarks
620
+ * ## Why
621
+ * The constructor keeps state creation consistent with the discriminated union.
622
+ * ## Ownership and lifetime
623
+ * This pure function acquires no resources and returns a new wrapper.
624
+ * @example
625
+ * ```ts
626
+ * import { loading } from "@typed/async-data"
627
+ * const state = loading({ loaded: 2, total: 5 })
628
+ * ```
629
+ * @category Constructors
630
+ * @since 1.0.0
631
+ */
112
632
  export const loading = (progress?: Progress): Loading => ({ _tag: "Loading", progress });
113
633
 
634
+ /**
635
+ * Creates a successful state with optional refresh progress.
636
+ * @remarks
637
+ * ## Why
638
+ * The constructor preserves the payload type while making refresh state explicit.
639
+ * ## Ownership and lifetime
640
+ * This pure function acquires no resources; the returned wrapper retains the supplied value.
641
+ * @example
642
+ * ```ts
643
+ * import { success } from "@typed/async-data"
644
+ * const state = success("ready")
645
+ * ```
646
+ * @category Constructors
647
+ * @since 1.0.0
648
+ */
114
649
  export const success = <A>(value: A, progress?: Progress): Success<A> => ({
115
650
  _tag: "Success",
116
651
  value,
117
652
  progress,
118
653
  });
119
654
 
655
+ /**
656
+ * Creates a failed state while preserving the complete Effect Cause.
657
+ * @remarks
658
+ * ## Why
659
+ * Accepting `Cause` prevents typed failures, defects, and interruption from being collapsed into one error value.
660
+ * ## Ownership and lifetime
661
+ * This pure function acquires no resources; the returned wrapper retains the supplied persistent Cause.
662
+ * @example
663
+ * ```ts
664
+ * import { failure } from "@typed/async-data"
665
+ * import { Cause } from "effect"
666
+ * const state = failure(Cause.fail("offline"))
667
+ * ```
668
+ * @category Constructors
669
+ * @since 1.0.0
670
+ */
120
671
  export const failure = <E>(cause: Cause.Cause<E>, progress?: Progress): Failure<E> => ({
121
672
  _tag: "Failure",
122
673
  cause,
123
674
  progress,
124
675
  });
125
676
 
677
+ /**
678
+ * Adds an optimistic value above an existing AsyncData history.
679
+ * @remarks
680
+ * ## Why
681
+ * Keeping `previous` makes rollback and reconciliation explicit rather than mutating or discarding earlier state.
682
+ * ## Ownership and lifetime
683
+ * This pure function acquires no resources; the wrapper retains both supplied values.
684
+ * @example
685
+ * ```ts
686
+ * import { optimistic, success } from "@typed/async-data"
687
+ * const state = optimistic(success("saved"), "saving")
688
+ * ```
689
+ * @category Constructors
690
+ * @since 1.0.0
691
+ */
126
692
  export const optimistic = <A, E>(previous: AsyncData<A, E>, value: A): Optimistic<A, E> => ({
127
693
  _tag: "Optimistic",
128
694
  value,
129
695
  previous,
130
696
  });
131
697
 
698
+ const optimisticHistory = <A, E>(data: AsyncData<A, E>) => {
699
+ const values: Array<A> = [];
700
+ const visited = new WeakSet<object>();
701
+ let current = data;
702
+ while (isOptimistic(current)) {
703
+ if (visited.has(current)) {
704
+ throw new TypeError("Cyclic Optimistic history");
705
+ }
706
+ visited.add(current);
707
+ values.push(current.value);
708
+ current = current.previous;
709
+ }
710
+ return { base: current, values };
711
+ };
712
+
713
+ const rebuildOptimistic = <A, E>(base: AsyncData<A, E>, values: ReadonlyArray<A>) => {
714
+ let current = base;
715
+ for (let index = values.length - 1; index >= 0; index--) {
716
+ current = optimistic(current, values[index]!);
717
+ }
718
+ return current;
719
+ };
720
+
721
+ const refreshingProgress = (progress?: Progress, existing?: Progress): Progress =>
722
+ progress ?? existing ?? { loaded: 0 };
723
+
724
+ /**
725
+ * Starts loading while preserving successful, failed, and optimistic history.
726
+ * @remarks
727
+ * ## Why
728
+ * Refreshes should keep usable values or causes visible, and optimistic layers must remain in their original order. Cyclic history throws `TypeError`.
729
+ * ## Ownership and lifetime
730
+ * This pure transformation acquires no resources and returns new plain wrappers where rebuilding is needed.
731
+ * @example
732
+ * ```ts
733
+ * import { startLoading, success } from "@typed/async-data"
734
+ * const refreshing = startLoading(success("cached"))
735
+ * ```
736
+ * @category Transformations
737
+ * @since 1.0.0
738
+ */
132
739
  export const startLoading = <A, E>(data: AsyncData<A, E>, progress?: Progress): AsyncData<A, E> => {
133
- if (isSuccess(data)) {
134
- return success(data.value, progress);
135
- } else if (isFailure(data)) {
136
- return failure(data.cause, progress);
137
- } else if (isOptimistic(data)) {
138
- return optimistic(startLoading(data.previous, progress), data.value);
740
+ const { base, values } = optimisticHistory(data);
741
+ let result: AsyncData<A, E>;
742
+ if (isSuccess(base)) {
743
+ result = success(base.value, refreshingProgress(progress, base.progress));
744
+ } else if (isFailure(base)) {
745
+ result = failure(base.cause, refreshingProgress(progress, base.progress));
746
+ } else if (isLoading(base)) {
747
+ result = loading(progress ?? base.progress);
139
748
  } else {
140
- return loading(progress);
749
+ result = loading(progress);
141
750
  }
751
+ return rebuildOptimistic(result, values);
142
752
  };
143
753
 
754
+ /**
755
+ * Stops loading or refreshing without discarding optimistic history.
756
+ * @remarks
757
+ * ## Why
758
+ * Removing progress settles success and failure while leaving initial or loading state semantics predictable. Cyclic history throws `TypeError`.
759
+ * ## Ownership and lifetime
760
+ * This pure transformation acquires no resources and returns new plain wrappers where rebuilding is needed.
761
+ * @example
762
+ * ```ts
763
+ * import { startLoading, stopLoading, success } from "@typed/async-data"
764
+ * const settled = stopLoading(startLoading(success("cached")))
765
+ * ```
766
+ * @category Transformations
767
+ * @since 1.0.0
768
+ */
144
769
  export const stopLoading = <A, E>(data: AsyncData<A, E>): AsyncData<A, E> => {
145
- if (isSuccess(data)) {
146
- return success(data.value);
147
- } else if (isFailure(data)) {
148
- return failure(data.cause);
149
- } else if (isOptimistic(data)) {
150
- return optimistic(stopLoading(data.previous), data.value);
770
+ const { base, values } = optimisticHistory(data);
771
+ let result: AsyncData<A, E>;
772
+ if (isSuccess(base)) {
773
+ result = success(base.value);
774
+ } else if (isFailure(base)) {
775
+ result = failure(base.cause);
151
776
  } else {
152
- return data;
777
+ result = base;
153
778
  }
779
+ return rebuildOptimistic(result, values);
154
780
  };
155
781
 
782
+ /**
783
+ * Exhaustively folds every AsyncData variant, in data-first or data-last form.
784
+ * @remarks
785
+ * ## Why
786
+ * Centralized exhaustive dispatch exposes values and Causes with their full state while TypeScript unifies branch result types.
787
+ * ## Ownership and lifetime
788
+ * Matching is synchronous, acquires no resources, and retains nothing beyond callback behavior.
789
+ * @example
790
+ * ```ts
791
+ * import { match } from "@typed/async-data"
792
+ * const label = match({ NoData: () => "empty", Loading: () => "loading", Failure: () => "failed", Success: String, Optimistic: String })
793
+ * ```
794
+ * @category Folding
795
+ * @since 1.0.0
796
+ */
156
797
  export const match: {
157
798
  <A, E, R1, R2, R3, R4, R5>(matchers: {
158
799
  NoData: (data: NoData) => R1;
@@ -198,6 +839,22 @@ export const match: {
198
839
  },
199
840
  );
200
841
 
842
+ /**
843
+ * Returns the current successful or optimistic value as an Effect `Option`.
844
+ * @remarks
845
+ * ## Why
846
+ * `Option` distinguishes an absent value from a present `undefined` payload and composes with Effect's data APIs.
847
+ * ## Ownership and lifetime
848
+ * This pure lookup acquires no resources and does not retain the state.
849
+ * @example
850
+ * ```ts
851
+ * import { getSuccess, success } from "@typed/async-data"
852
+ * const value = getSuccess(success(1))
853
+ * ```
854
+ * See [Effect Option](https://effect.website/docs/data-types/option/).
855
+ * @category Accessors
856
+ * @since 1.0.0
857
+ */
201
858
  export function getSuccess<A, E>(data: AsyncData<A, E>): Option.Option<A> {
202
859
  return match(data, {
203
860
  NoData: Option.none,
@@ -209,6 +866,19 @@ export function getSuccess<A, E>(data: AsyncData<A, E>): Option.Option<A> {
209
866
  }
210
867
 
211
868
  /**
869
+ * Returns the complete Cause only when the current state is a Failure.
870
+ * @remarks
871
+ * ## Why
872
+ * The accessor keeps typed errors, defects, and interruption intact for callers that need full failure diagnostics.
873
+ * ## Ownership and lifetime
874
+ * This pure lookup acquires no resources and returns a reference to the persistent Cause.
875
+ * @example
876
+ * ```ts
877
+ * import { failure, getCause } from "@typed/async-data"
878
+ * import { Cause } from "effect"
879
+ * const cause = getCause(failure(Cause.fail("offline")))
880
+ * ```
881
+ * @category Accessors
212
882
  * @since 1.0.0
213
883
  */
214
884
  export function getCause<A, E>(data: AsyncData<A, E>): Option.Option<Cause.Cause<E>> {
@@ -222,6 +892,19 @@ export function getCause<A, E>(data: AsyncData<A, E>): Option.Option<Cause.Cause
222
892
  }
223
893
 
224
894
  /**
895
+ * Returns the first typed failure found in a Failure Cause.
896
+ * @remarks
897
+ * ## Why
898
+ * This convenience accessor intentionally excludes defects and interruption; use `getCause` when those distinctions matter.
899
+ * ## Ownership and lifetime
900
+ * This pure lookup acquires no resources and does not retain the state.
901
+ * @example
902
+ * ```ts
903
+ * import { failure, getError } from "@typed/async-data"
904
+ * import { Cause } from "effect"
905
+ * const error = getError(failure(Cause.fail("offline")))
906
+ * ```
907
+ * @category Accessors
225
908
  * @since 1.0.0
226
909
  */
227
910
  export function getError<A, E>(data: AsyncData<A, E>): Option.Option<E> {
@@ -234,26 +917,60 @@ export function getError<A, E>(data: AsyncData<A, E>): Option.Option<E> {
234
917
  });
235
918
  }
236
919
 
920
+ /**
921
+ * Maps successful and every optimistic value while preserving state structure.
922
+ * @remarks
923
+ * ## Why
924
+ * Value transformations should not erase progress, Causes, or optimistic rollback history. Cyclic history throws `TypeError`.
925
+ * ## Ownership and lifetime
926
+ * This pure transformation acquires no resources and returns new plain wrappers.
927
+ * @example
928
+ * ```ts
929
+ * import { map, success } from "@typed/async-data"
930
+ * const state = map(success(2), (n) => n * 2)
931
+ * ```
932
+ * @category Transformations
933
+ * @since 1.0.0
934
+ */
237
935
  export const map: {
238
936
  <A, B>(f: (a: A) => B): <E>(data: AsyncData<A, E>) => AsyncData<B, E>;
239
937
  <A, E, B>(data: AsyncData<A, E>, f: (a: A) => B): AsyncData<B, E>;
240
938
  } = dual(2, function map<A, E, B>(data: AsyncData<A, E>, f: (a: A) => B): AsyncData<B, E> {
241
- if (isSuccess(data)) {
242
- return success(f(data.value), data.progress);
243
- } else if (isOptimistic(data)) {
244
- return optimistic(map(data.previous, f), f(data.value));
939
+ const { base, values } = optimisticHistory(data);
940
+ let result: AsyncData<B, E>;
941
+ if (isSuccess(base)) {
942
+ result = success(f(base.value), base.progress);
245
943
  } else {
246
- return data;
944
+ result = base;
247
945
  }
946
+ for (let index = values.length - 1; index >= 0; index--) {
947
+ result = optimistic(result, f(values[index]!));
948
+ }
949
+ return result;
248
950
  });
249
951
 
952
+ /**
953
+ * Replaces a successful or outer optimistic value with another AsyncData value.
954
+ * @remarks
955
+ * ## Why
956
+ * State-producing transformations can change both value and error types while non-value states pass through unchanged.
957
+ * ## Ownership and lifetime
958
+ * This pure transformation acquires no resources; ownership follows the AsyncData returned by the callback.
959
+ * @example
960
+ * ```ts
961
+ * import { flatMap, success } from "@typed/async-data"
962
+ * const parsed = flatMap(success("2"), (text) => success(Number(text)))
963
+ * ```
964
+ * @category Transformations
965
+ * @since 1.0.0
966
+ */
250
967
  export const flatMap: {
251
- <A, E, B, E2>(
252
- f: (a: A, data: Success<A> | Optimistic<A, E>) => AsyncData<B, E2>,
253
- ): (data: AsyncData<A, E>) => AsyncData<B, E | E2>;
968
+ <A, B, E2>(
969
+ f: (a: A, data: Success<A> | Optimistic<A, unknown>) => AsyncData<B, E2>,
970
+ ): <E>(data: AsyncData<A, E>) => AsyncData<B, E | E2>;
254
971
  <A, E, B, E2>(
255
972
  data: AsyncData<A, E>,
256
- f: (a: A, data: Success<A> | Optimistic<A, E>) => AsyncData<B, E>,
973
+ f: (a: A, data: Success<A> | Optimistic<A, E>) => AsyncData<B, E2>,
257
974
  ): AsyncData<B, E | E2>;
258
975
  } = dual(2, function <
259
976
  A,
@@ -271,21 +988,70 @@ export const flatMap: {
271
988
  }
272
989
  });
273
990
 
991
+ /**
992
+ * Maps typed failures inside the base Cause while preserving defects, interruption, progress, and optimistic history.
993
+ * @remarks
994
+ * ## Why
995
+ * Error adaptation should use Effect Cause semantics instead of flattening the failure channel. Cyclic history throws `TypeError`.
996
+ * ## Ownership and lifetime
997
+ * This pure transformation acquires no resources and returns new plain wrappers.
998
+ * @example
999
+ * ```ts
1000
+ * import { failure, mapError } from "@typed/async-data"
1001
+ * import { Cause } from "effect"
1002
+ * const state = mapError(failure(Cause.fail(404)), String)
1003
+ * ```
1004
+ * @category Transformations
1005
+ * @since 1.0.0
1006
+ */
274
1007
  export const mapError: {
275
1008
  <A, E, E2>(f: (e: E) => E2): (data: AsyncData<A, E>) => AsyncData<A, E2>;
276
1009
  <A, E, E2>(data: AsyncData<A, E>, f: (e: E) => E2): AsyncData<A, E2>;
277
1010
  } = dual(2, function mapError<A, E, E2>(data: AsyncData<A, E>, f: (e: E) => E2): AsyncData<A, E2> {
278
- if (isFailure(data)) {
279
- return failure(Cause.map(data.cause, f), data.progress);
280
- } else if (isOptimistic(data)) {
281
- return optimistic(mapError(data.previous, f), data.value);
1011
+ const { base, values } = optimisticHistory(data);
1012
+ let result: AsyncData<A, E2>;
1013
+ if (isFailure(base)) {
1014
+ result = failure(Cause.map(base.cause, f), base.progress);
282
1015
  } else {
283
- return data;
1016
+ result = base;
284
1017
  }
1018
+ return rebuildOptimistic(result, values);
285
1019
  });
286
1020
 
1021
+ /**
1022
+ * Converts an Effect Exit to Success or Failure without losing its Cause.
1023
+ * @remarks
1024
+ * ## Why
1025
+ * Exit is Effect's complete computation result, so preserving its Cause keeps typed failures, defects, and interruption available.
1026
+ * ## Ownership and lifetime
1027
+ * This pure conversion acquires no resources and retains the Exit payload or Cause.
1028
+ * @example
1029
+ * ```ts
1030
+ * import { fromExit } from "@typed/async-data"
1031
+ * import { Exit } from "effect"
1032
+ * const state = fromExit(Exit.succeed(1))
1033
+ * ```
1034
+ * @category Conversions
1035
+ * @since 1.0.0
1036
+ */
287
1037
  export const fromExit = <A, E>(exit: Exit.Exit<A, E>): AsyncData<A, E> =>
288
1038
  Exit.isSuccess(exit) ? success(exit.value) : failure(exit.cause);
289
1039
 
1040
+ /**
1041
+ * Converts an Effect Result to Success or a typed Failure Cause.
1042
+ * @remarks
1043
+ * ## Why
1044
+ * Result has only typed success/failure, so a failed result becomes `Cause.fail` without inventing defects or interruption.
1045
+ * ## Ownership and lifetime
1046
+ * This pure conversion acquires no resources and retains the Result payload.
1047
+ * @example
1048
+ * ```ts
1049
+ * import { fromResult } from "@typed/async-data"
1050
+ * import { Result } from "effect"
1051
+ * const state = fromResult(Result.succeed(1))
1052
+ * ```
1053
+ * @category Conversions
1054
+ * @since 1.0.0
1055
+ */
290
1056
  export const fromResult = <A, E>(result: Result.Result<A, E>): AsyncData<A, E> =>
291
1057
  Result.isSuccess(result) ? success(result.success) : failure(Cause.fail(result.failure));