effectweb 0.1.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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +9 -0
  3. package/dist/AsyncContent.d.ts +20 -0
  4. package/dist/AsyncContent.js +75 -0
  5. package/dist/actions.d.ts +27 -0
  6. package/dist/actions.js +31 -0
  7. package/dist/cache.d.ts +32 -0
  8. package/dist/cache.js +147 -0
  9. package/dist/collection.d.ts +15 -0
  10. package/dist/collection.js +24 -0
  11. package/dist/component.d.ts +33 -0
  12. package/dist/component.js +58 -0
  13. package/dist/diagnostics.d.ts +32 -0
  14. package/dist/diagnostics.js +57 -0
  15. package/dist/dom.d.ts +66 -0
  16. package/dist/dom.js +617 -0
  17. package/dist/effectEvent.d.ts +18 -0
  18. package/dist/effectEvent.js +67 -0
  19. package/dist/errors.d.ts +5 -0
  20. package/dist/errors.js +23 -0
  21. package/dist/form.d.ts +19 -0
  22. package/dist/form.js +13 -0
  23. package/dist/index.d.ts +22 -0
  24. package/dist/index.js +21 -0
  25. package/dist/jsx-runtime.d.ts +1 -0
  26. package/dist/jsx-runtime.js +1 -0
  27. package/dist/jsx.d.ts +86 -0
  28. package/dist/jsx.js +1 -0
  29. package/dist/load.d.ts +13 -0
  30. package/dist/load.js +11 -0
  31. package/dist/mount.d.ts +25 -0
  32. package/dist/mount.js +61 -0
  33. package/dist/pages.d.ts +40 -0
  34. package/dist/pages.js +86 -0
  35. package/dist/program.d.ts +48 -0
  36. package/dist/program.js +154 -0
  37. package/dist/query.d.ts +22 -0
  38. package/dist/query.js +12 -0
  39. package/dist/resource.d.ts +34 -0
  40. package/dist/resource.js +59 -0
  41. package/dist/runtime.d.ts +16 -0
  42. package/dist/runtime.js +27 -0
  43. package/dist/session.d.ts +65 -0
  44. package/dist/session.js +186 -0
  45. package/dist/share.d.ts +2 -0
  46. package/dist/share.js +40 -0
  47. package/dist/state.d.ts +2 -0
  48. package/dist/state.js +7 -0
  49. package/dist/task.d.ts +53 -0
  50. package/dist/task.js +70 -0
  51. package/dist/tasks.d.ts +69 -0
  52. package/dist/tasks.js +126 -0
  53. package/dist/testing.d.ts +26 -0
  54. package/dist/testing.js +44 -0
  55. package/package.json +137 -0
@@ -0,0 +1,5 @@
1
+ export type ReportError = (error: unknown) => void;
2
+ export declare const reportError: ReportError;
3
+ /** Reporters are observers. A broken reporter must not prevent cleanup or sibling updates. */
4
+ export declare function reportSafely(report: ReportError, error: unknown): void;
5
+ export declare function runAll(work: readonly (() => void)[], report?: ReportError): void;
package/dist/errors.js ADDED
@@ -0,0 +1,23 @@
1
+ export const reportError = (error) => console.error(error);
2
+ /** Reporters are observers. A broken reporter must not prevent cleanup or sibling updates. */
3
+ export function reportSafely(report, error) {
4
+ try {
5
+ report(error);
6
+ }
7
+ catch (reporterError) {
8
+ console.error(reporterError);
9
+ }
10
+ }
11
+ export function runAll(work, report = reportError) {
12
+ const errors = [];
13
+ for (const run of work) {
14
+ try {
15
+ run();
16
+ }
17
+ catch (error) {
18
+ errors.push(error);
19
+ }
20
+ }
21
+ if (errors.length)
22
+ reportSafely(report, new AggregateError(errors, 'UI work failed'));
23
+ }
package/dist/form.d.ts ADDED
@@ -0,0 +1,19 @@
1
+ import type { EffectEventRequest } from './effectEvent.js';
2
+ type EventResult = void | boolean | EffectEventRequest;
3
+ type ValueEvent<T> = {
4
+ readonly currentTarget: T;
5
+ };
6
+ /** Capture native values synchronously; returned Effect requests retain the listener's owner. */
7
+ export declare const inputText: (change: (value: string) => EventResult) => (event: ValueEvent<{
8
+ readonly value: string;
9
+ }>) => EventResult;
10
+ export declare const inputChecked: (change: (value: boolean) => EventResult) => (event: ValueEvent<{
11
+ readonly checked: boolean;
12
+ }>) => EventResult;
13
+ /** Empty and invalid numeric inputs stay absent rather than becoming zero or NaN. */
14
+ export declare const inputNumber: (change: (value: number | undefined) => EventResult) => (event: ValueEvent<{
15
+ readonly valueAsNumber: number;
16
+ }>) => EventResult;
17
+ /** Prevention always runs during the native event, before task drop/replace policy is applied. */
18
+ export declare const submit: (run: () => EventResult) => (event: Pick<SubmitEvent, "preventDefault">) => EventResult;
19
+ export {};
package/dist/form.js ADDED
@@ -0,0 +1,13 @@
1
+ /** Capture native values synchronously; returned Effect requests retain the listener's owner. */
2
+ export const inputText = (change) => (event) => change(event.currentTarget.value);
3
+ export const inputChecked = (change) => (event) => change(event.currentTarget.checked);
4
+ /** Empty and invalid numeric inputs stay absent rather than becoming zero or NaN. */
5
+ export const inputNumber = (change) => (event) => {
6
+ const value = event.currentTarget.valueAsNumber;
7
+ return change(Number.isNaN(value) ? undefined : value);
8
+ };
9
+ /** Prevention always runs during the native event, before task drop/replace policy is applied. */
10
+ export const submit = (run) => (event) => {
11
+ event.preventDefault();
12
+ return run();
13
+ };
@@ -0,0 +1,22 @@
1
+ export { collection, sequence, type Rows } from './collection.js';
2
+ export { component, localComponent, programView } from './component.js';
3
+ export { patchModel } from './state.js';
4
+ export { defineActions, type ActionMessage } from './actions.js';
5
+ export { taskComponent, taskControls, type TaskModel, type TaskMessage } from './task.js';
6
+ export { effectEvent } from './effectEvent.js';
7
+ export { mountView, view, type View } from './dom.js';
8
+ export type { JSX } from './jsx.js';
9
+ export { Portal, domBinding, domMount, type DomMount } from './mount.js';
10
+ export { pages, type PagesMessage, type PagesModel } from './pages.js';
11
+ export { program, actionCommand, effectCommand, type Command, type Program, type Send, type Transition, } from './program.js';
12
+ export { available, resourceComponent, resourceError, type ResourceMessage, type ResourceModel, } from './resource.js';
13
+ export { fromPromise, type UiLoad, type UiPage } from './load.js';
14
+ export { observeBindings, observePrograms, type BindingUpdate, type ProgramUpdate, } from './diagnostics.js';
15
+ export { inputText, inputChecked, inputNumber, submit } from './form.js';
16
+ export { slot, type Slot, type CompiledContent } from './dom.js';
17
+ export { AsyncContent, type AsyncContentProps } from './AsyncContent.js';
18
+ export { defineTasks, type TaskDefinition, type TasksModel, type TasksMessage, type TaskResults, } from './tasks.js';
19
+ export { uiRuntime, type UiRuntime } from './runtime.js';
20
+ export { query, type Query } from './query.js';
21
+ export { makeUiModel, makeUiModel as makeQueryCache, makePagedResource, type UiModel, } from './cache.js';
22
+ export { queryResource, resource, pagedResource, lifetime, projectionCache, sessionGroup, type SessionContext, type Read, } from './session.js';
package/dist/index.js ADDED
@@ -0,0 +1,21 @@
1
+ export { collection, sequence } from './collection.js';
2
+ export { component, localComponent, programView } from './component.js';
3
+ export { patchModel } from './state.js';
4
+ export { defineActions } from './actions.js';
5
+ export { taskComponent, taskControls } from './task.js';
6
+ export { effectEvent } from './effectEvent.js';
7
+ export { mountView, view } from './dom.js';
8
+ export { Portal, domBinding, domMount } from './mount.js';
9
+ export { pages } from './pages.js';
10
+ export { program, actionCommand, effectCommand, } from './program.js';
11
+ export { available, resourceComponent, resourceError, } from './resource.js';
12
+ export { fromPromise } from './load.js';
13
+ export { observeBindings, observePrograms, } from './diagnostics.js';
14
+ export { inputText, inputChecked, inputNumber, submit } from './form.js';
15
+ export { slot } from './dom.js';
16
+ export { AsyncContent } from './AsyncContent.js';
17
+ export { defineTasks, } from './tasks.js';
18
+ export { uiRuntime } from './runtime.js';
19
+ export { query } from './query.js';
20
+ export { makeUiModel, makeUiModel as makeQueryCache, makePagedResource, } from './cache.js';
21
+ export { queryResource, resource, pagedResource, lifetime, projectionCache, sessionGroup, } from './session.js';
@@ -0,0 +1 @@
1
+ export type { JSX } from './jsx.js';
@@ -0,0 +1 @@
1
+ export {};
package/dist/jsx.d.ts ADDED
@@ -0,0 +1,86 @@
1
+ import type { EffectEventRequest } from './effectEvent.js';
2
+ import type { CompiledContent } from './dom.js';
3
+ import type { DomMount } from './mount.js';
4
+ /** JSX is a compiler input. No renderer library owns its types. */
5
+ export declare namespace JSX {
6
+ export type Element = Node | string | number | boolean | null | undefined | CompiledContent | Element[];
7
+ export type EventHandler<T, E extends Event> = (event: E & {
8
+ currentTarget: T;
9
+ target: EventTarget & globalThis.Element;
10
+ }) => void | boolean | EffectEventRequest;
11
+ export type EventHandlerUnion<T, E extends Event> = EventHandler<T, E>;
12
+ export type CSSProperties = {
13
+ [property: string]: string | number | undefined | null;
14
+ };
15
+ export interface Events<T> {
16
+ onClick?: EventHandler<T, MouseEvent> | undefined;
17
+ onDblClick?: EventHandler<T, MouseEvent> | undefined;
18
+ onContextMenu?: EventHandler<T, MouseEvent> | undefined;
19
+ onInput?: EventHandler<T, InputEvent> | undefined;
20
+ onChange?: EventHandler<T, Event> | undefined;
21
+ onSubmit?: EventHandler<T, SubmitEvent> | undefined;
22
+ onKeyDown?: EventHandler<T, KeyboardEvent> | undefined;
23
+ onKeyUp?: EventHandler<T, KeyboardEvent> | undefined;
24
+ onPointerDown?: EventHandler<T, PointerEvent> | undefined;
25
+ onPointerUp?: EventHandler<T, PointerEvent> | undefined;
26
+ onPointerMove?: EventHandler<T, PointerEvent> | undefined;
27
+ onPointerEnter?: EventHandler<T, PointerEvent> | undefined;
28
+ onPointerLeave?: EventHandler<T, PointerEvent> | undefined;
29
+ onPointerCancel?: EventHandler<T, PointerEvent> | undefined;
30
+ onMouseDown?: EventHandler<T, MouseEvent> | undefined;
31
+ onMouseEnter?: EventHandler<T, MouseEvent> | undefined;
32
+ onMouseLeave?: EventHandler<T, MouseEvent> | undefined;
33
+ onFocus?: EventHandler<T, FocusEvent> | undefined;
34
+ onBlur?: EventHandler<T, FocusEvent> | undefined;
35
+ onScroll?: EventHandler<T, Event> | undefined;
36
+ onWheel?: EventHandler<T, WheelEvent> | undefined;
37
+ onLoad?: EventHandler<T, Event> | undefined;
38
+ onError?: EventHandler<T, Event> | undefined;
39
+ onLoadedMetadata?: EventHandler<T, Event> | undefined;
40
+ onTimeUpdate?: EventHandler<T, Event> | undefined;
41
+ onEnded?: EventHandler<T, Event> | undefined;
42
+ onPlay?: EventHandler<T, Event> | undefined;
43
+ onPause?: EventHandler<T, Event> | undefined;
44
+ onPaste?: EventHandler<T, ClipboardEvent> | undefined;
45
+ onDragOver?: EventHandler<T, DragEvent> | undefined;
46
+ onDragLeave?: EventHandler<T, DragEvent> | undefined;
47
+ onDrop?: EventHandler<T, DragEvent> | undefined;
48
+ }
49
+ type Scalar = string | number | boolean;
50
+ type DomAttributes<T> = {
51
+ [K in keyof T as K extends string ? T[K] extends Scalar | null ? K | Lowercase<K> : never : never]?: K extends 'min' | 'max' | 'step' | 'value' ? string | number | undefined : T[K] extends boolean ? boolean | undefined : T[K] extends number ? number | string | undefined : T[K] | undefined;
52
+ };
53
+ type CaptureEvents<T> = {
54
+ [K in keyof Events<T> as `${K}Capture`]?: Events<T>[K];
55
+ };
56
+ export type Attributes<T> = Events<T> & CaptureEvents<T> & DomAttributes<T> & {
57
+ children?: Element | undefined;
58
+ class?: string | undefined;
59
+ className?: string | undefined;
60
+ classList?: Record<string, boolean | undefined> | undefined;
61
+ style?: string | CSSProperties | undefined;
62
+ use?: DomMount | undefined;
63
+ role?: string | undefined;
64
+ tabindex?: number | undefined;
65
+ loading?: 'lazy' | 'eager' | undefined;
66
+ download?: string | boolean | undefined;
67
+ autocomplete?: string | undefined;
68
+ inputmode?: string | undefined;
69
+ spellcheck?: boolean | 'true' | 'false' | undefined;
70
+ contenteditable?: boolean | 'true' | 'false' | undefined;
71
+ [data: `data-${string}`]: Scalar | null | undefined;
72
+ [aria: `aria-${string}`]: Scalar | null | undefined;
73
+ };
74
+ type SvgAttributes = Partial<Record<'viewBox' | 'fill' | 'stroke' | 'stroke-width' | 'stroke-linecap' | 'stroke-linejoin' | 'stroke-dasharray' | 'stroke-dashoffset' | 'stroke-miterlimit' | 'fill-rule' | 'clip-rule' | 'fill-opacity' | 'stroke-opacity' | 'd' | 'points' | 'x' | 'y' | 'x1' | 'x2' | 'y1' | 'y2' | 'cx' | 'cy' | 'r' | 'rx' | 'ry' | 'width' | 'height' | 'xmlns' | 'transform' | 'preserveAspectRatio' | 'offset' | 'stop-color' | 'stop-opacity' | 'clip-path' | 'mask' | 'opacity' | 'pathLength', string | number | undefined>>;
75
+ type Html = {
76
+ [Tag in keyof HTMLElementTagNameMap]: Attributes<HTMLElementTagNameMap[Tag]>;
77
+ };
78
+ type Svg = {
79
+ [Tag in Exclude<keyof SVGElementTagNameMap, keyof HTMLElementTagNameMap>]: Attributes<SVGElementTagNameMap[Tag]> & SvgAttributes;
80
+ };
81
+ export type IntrinsicElements = Html & Svg;
82
+ export interface ElementChildrenAttribute {
83
+ children: unknown;
84
+ }
85
+ export {};
86
+ }
package/dist/jsx.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/dist/load.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ import { Effect } from 'effect';
2
+ /** Loaders stay lazy and use one error/cancellation model, whether their work is sync or async. */
3
+ export type UiLoad<A, E = unknown, R = never> = Effect.Effect<A, E, R>;
4
+ export declare const loadEffect: <A, E, R>(load: () => UiLoad<A, E, R>) => UiLoad<A, E, R>;
5
+ /** Adapt Promise libraries at the integration boundary. Pass the signal to APIs that support it. */
6
+ export declare const fromPromise: <A>(load: (signal: AbortSignal) => PromiseLike<A>) => UiLoad<A>;
7
+ export interface UiPage<A, Cursor> {
8
+ items: A[];
9
+ totalCount?: number;
10
+ next: Cursor | undefined;
11
+ }
12
+ /** Read a local cache when executed, then acquire missing data through the same Effect contract. */
13
+ export declare function cachedLoad<A, E, R>(read: () => A | undefined, load: () => UiLoad<A, E, R>): UiLoad<A, E, R>;
package/dist/load.js ADDED
@@ -0,0 +1,11 @@
1
+ import { Effect } from 'effect';
2
+ export const loadEffect = (load) => Effect.suspend(load);
3
+ /** Adapt Promise libraries at the integration boundary. Pass the signal to APIs that support it. */
4
+ export const fromPromise = (load) => Effect.tryPromise({ try: load, catch: (error) => error });
5
+ /** Read a local cache when executed, then acquire missing data through the same Effect contract. */
6
+ export function cachedLoad(read, load) {
7
+ return Effect.suspend(() => {
8
+ const value = read();
9
+ return value === undefined ? load() : Effect.succeed(value);
10
+ });
11
+ }
@@ -0,0 +1,25 @@
1
+ import { type ReportError } from './errors.js';
2
+ import { Effect } from 'effect';
3
+ import { type UiRuntime } from './runtime.js';
4
+ import type { JSX } from './jsx.js';
5
+ type Work<R = never> = (() => void) | {
6
+ dispose: () => void;
7
+ update?: () => void;
8
+ } | Effect.Effect<never, unknown, R>;
9
+ export declare function Portal(_props: {
10
+ children?: JSX.Element;
11
+ }): JSX.Element;
12
+ export interface DomMount {
13
+ readonly identity: unknown;
14
+ readonly data: unknown;
15
+ readonly acquire: (element: Element, input: () => unknown) => Work;
16
+ }
17
+ /** Element-owned work. Changed acquisition functions replace and interrupt the prior lifetime. */
18
+ export declare function domMount<T extends Element, R = never>(start: (element: T) => Work<R>, ...provided: [R] extends [never] ? [runtime?: UiRuntime<R>] : [runtime: UiRuntime<R>]): DomMount;
19
+ /** A stable DOM lifecycle with fresh immutable inputs, for focus, measurements and native events. */
20
+ export declare function domBinding<T extends Element, A, R = never>(data: A, acquire: (element: T, input: () => A) => Work<R>, ...provided: [R] extends [never] ? [runtime?: UiRuntime<R>] : [runtime: UiRuntime<R>]): DomMount;
21
+ export declare function startMount(element: Element, mount: DomMount, report?: ReportError): {
22
+ update(next: DomMount): boolean;
23
+ dispose(): void;
24
+ };
25
+ export {};
package/dist/mount.js ADDED
@@ -0,0 +1,61 @@
1
+ import { reportError, reportSafely } from './errors.js';
2
+ import { Effect, Fiber } from 'effect';
3
+ import { defaultUiRuntime } from './runtime.js';
4
+ export function Portal(_props) {
5
+ throw new Error('Portal must be compiled inside a view');
6
+ }
7
+ /** Element-owned work. Changed acquisition functions replace and interrupt the prior lifetime. */
8
+ export function domMount(start, ...provided) {
9
+ const runtime = provided[0] ?? defaultUiRuntime;
10
+ return {
11
+ identity: start,
12
+ data: undefined,
13
+ acquire: (element) => {
14
+ const work = start(element);
15
+ return Effect.isEffect(work) ? runtime.provide(work) : work;
16
+ },
17
+ };
18
+ }
19
+ /** A stable DOM lifecycle with fresh immutable inputs, for focus, measurements and native events. */
20
+ export function domBinding(data, acquire, ...provided) {
21
+ return {
22
+ identity: acquire,
23
+ data,
24
+ acquire: (element, input) => {
25
+ const work = acquire(element, input);
26
+ const runtime = provided[0] ?? defaultUiRuntime;
27
+ return Effect.isEffect(work) ? runtime.provide(work) : work;
28
+ },
29
+ };
30
+ }
31
+ export function startMount(element, mount, report = reportError) {
32
+ let data = mount.data;
33
+ const work = mount.acquire(element, () => data);
34
+ const fiber = Effect.isEffect(work) ? Effect.runFork(work) : undefined;
35
+ let disposed = false;
36
+ fiber?.addObserver((exit) => {
37
+ if (exit._tag === 'Failure' && !disposed)
38
+ reportSafely(report, exit.cause);
39
+ });
40
+ return {
41
+ update(next) {
42
+ if (next.identity !== mount.identity)
43
+ return false;
44
+ data = next.data;
45
+ if (!fiber && typeof work === 'object' && 'update' in work)
46
+ work.update?.();
47
+ return true;
48
+ },
49
+ dispose() {
50
+ if (disposed)
51
+ return;
52
+ disposed = true;
53
+ if (fiber)
54
+ Effect.runFork(Fiber.interrupt(fiber));
55
+ else if (typeof work === 'function')
56
+ work();
57
+ else if ('dispose' in work)
58
+ work.dispose();
59
+ },
60
+ };
61
+ }
@@ -0,0 +1,40 @@
1
+ import { Cause } from 'effect';
2
+ import * as AsyncResult from 'effect/unstable/reactivity/AsyncResult';
3
+ import type { UiLoad, UiPage } from './load.js';
4
+ import { type Transition } from './program.js';
5
+ export interface PagesModel<Props, A, Cursor> {
6
+ readonly props: Props;
7
+ readonly key: string | undefined;
8
+ readonly result: AsyncResult.AsyncResult<UiPage<A, Cursor>, unknown>;
9
+ readonly append: boolean;
10
+ }
11
+ export type PagesMessage<A, Cursor> = {
12
+ type: 'More';
13
+ } | {
14
+ type: 'Retry';
15
+ } | {
16
+ type: 'Refresh';
17
+ } | {
18
+ type: 'Loaded';
19
+ page: UiPage<A, Cursor>;
20
+ append: boolean;
21
+ } | {
22
+ type: 'Failed';
23
+ cause: Cause.Cause<unknown>;
24
+ };
25
+ /**
26
+ * Pagination owns the `page` command slot. Key changes clear results and replace the request;
27
+ * same-key refreshes keep the last success, and retries repeat the failed cursor.
28
+ * Feed request inputs through receive; pass { refresh: true } for explicit same-key submissions.
29
+ */
30
+ export declare function pages<Props, A, Cursor>(definition: {
31
+ key: (props: Props) => string | undefined;
32
+ load: (props: Props, cursor: Cursor | undefined) => UiLoad<UiPage<A, Cursor>>;
33
+ itemKey: (item: A) => string;
34
+ }): {
35
+ init: (props: Props) => PagesModel<Props, A, Cursor>;
36
+ receive(model: PagesModel<Props, A, Cursor>, props: Props, options?: {
37
+ refresh?: boolean;
38
+ }): Transition<PagesModel<Props, A, Cursor>, PagesMessage<A, Cursor>>;
39
+ update(model: PagesModel<Props, A, Cursor>, message: PagesMessage<A, Cursor>): Transition<PagesModel<Props, A, Cursor>, PagesMessage<A, Cursor>>;
40
+ };
package/dist/pages.js ADDED
@@ -0,0 +1,86 @@
1
+ import { Cause, Option } from 'effect';
2
+ import * as AsyncResult from 'effect/unstable/reactivity/AsyncResult';
3
+ import { effectCommand } from './program.js';
4
+ import { available } from './resource.js';
5
+ /**
6
+ * Pagination owns the `page` command slot. Key changes clear results and replace the request;
7
+ * same-key refreshes keep the last success, and retries repeat the failed cursor.
8
+ * Feed request inputs through receive; pass { refresh: true } for explicit same-key submissions.
9
+ */
10
+ export function pages(definition) {
11
+ const request = (model, append) => {
12
+ const previous = available(model.result);
13
+ if (append && (model.result.waiting || !previous || previous.next === undefined))
14
+ return { model };
15
+ if (model.key === undefined)
16
+ return { model };
17
+ const cursor = append ? previous?.next : undefined;
18
+ return {
19
+ model: { ...model, append, result: AsyncResult.waiting(model.result) },
20
+ commands: [
21
+ effectCommand('page', () => definition.load(model.props, cursor), {
22
+ onSuccess: (page) => ({ type: 'Loaded', page, append }),
23
+ onFailure: (cause) => ({ type: 'Failed', cause }),
24
+ }),
25
+ ],
26
+ };
27
+ };
28
+ return {
29
+ init: (props) => ({
30
+ props,
31
+ key: undefined,
32
+ result: AsyncResult.initial(),
33
+ append: false,
34
+ }),
35
+ receive(model, props, options = {}) {
36
+ const key = definition.key(props);
37
+ if (key === model.key) {
38
+ const next = { ...model, props };
39
+ return options.refresh ? request(next, false) : { model: next };
40
+ }
41
+ const next = {
42
+ ...model,
43
+ props,
44
+ key,
45
+ append: false,
46
+ result: AsyncResult.initial(),
47
+ };
48
+ return key === undefined ? { model: next, cancel: ['page'] } : request(next, false);
49
+ },
50
+ update(model, message) {
51
+ switch (message.type) {
52
+ case 'More':
53
+ return request(model, true);
54
+ case 'Retry':
55
+ return request(model, model.append);
56
+ case 'Refresh':
57
+ return request(model, false);
58
+ case 'Failed':
59
+ return {
60
+ model: {
61
+ ...model,
62
+ result: AsyncResult.failureWithPrevious(message.cause, {
63
+ previous: Option.some(model.result),
64
+ }),
65
+ },
66
+ };
67
+ case 'Loaded': {
68
+ const previous = message.append ? available(model.result) : undefined;
69
+ const known = new Set(previous?.items.map(definition.itemKey));
70
+ const incoming = message.page.items.filter((item) => {
71
+ const key = definition.itemKey(item);
72
+ if (known.has(key))
73
+ return false;
74
+ known.add(key);
75
+ return true;
76
+ });
77
+ const page = {
78
+ ...message.page,
79
+ items: previous ? [...previous.items, ...incoming] : incoming,
80
+ };
81
+ return { model: { ...model, result: AsyncResult.success(page) } };
82
+ }
83
+ }
84
+ },
85
+ };
86
+ }
@@ -0,0 +1,48 @@
1
+ import { Cause, Effect, Stream } from 'effect';
2
+ export type Send<Message> = (message: Message) => void;
3
+ /** A slot owns either one completion or a stream of progress/events, canceled together. */
4
+ export type Command<Message, R = never> = {
5
+ readonly slot: string;
6
+ } & ({
7
+ readonly effect: Effect.Effect<Message, never, R>;
8
+ readonly stream?: never;
9
+ readonly action?: never;
10
+ } | {
11
+ readonly stream: Stream.Stream<Message, never, R>;
12
+ readonly effect?: never;
13
+ readonly action?: never;
14
+ } | {
15
+ readonly action: Effect.Effect<void, unknown, R>;
16
+ readonly effect?: never;
17
+ readonly stream?: never;
18
+ });
19
+ /** A lazy request settles successes, typed failures and defects through one message contract. */
20
+ export declare function effectCommand<A, E, Success, Failure, R = never>(slot: string, load: () => Effect.Effect<A, E, R>, handlers: {
21
+ readonly onSuccess: (value: A) => Success;
22
+ readonly onFailure: (cause: Cause.Cause<E>) => Failure;
23
+ }): Command<Success | Failure, R>;
24
+ /** Owned work with no model result. Failures use the program's error reporter. */
25
+ export declare function actionCommand<R = never>(slot: string, action: () => Effect.Effect<unknown, unknown, R>): Command<never, R>;
26
+ export declare function mapCommand<A, B, R = never>(command: Command<A, R>, map: (message: A) => B): Command<B, R>;
27
+ export interface Transition<Model, Message, R = never> {
28
+ readonly model: Model;
29
+ readonly commands?: readonly Command<Message, R>[];
30
+ readonly cancel?: readonly string[];
31
+ }
32
+ export interface Program<Model, Message> {
33
+ readonly model: () => Model;
34
+ readonly send: Send<Message>;
35
+ readonly subscribe: (listener: (model: Model) => void) => () => void;
36
+ readonly dispose: () => void;
37
+ }
38
+ export interface RunningProgram<Model, Message> extends Program<Model, Message> {
39
+ readonly activeSlots: () => readonly string[];
40
+ readonly awaitIdle: (slot?: string) => Promise<void>;
41
+ }
42
+ /** One immutable source of truth. Reducers are synchronous; commands own async work. */
43
+ export declare function program<Model, Message>(options: {
44
+ initial: Model;
45
+ name?: string;
46
+ update: (model: Model, message: Message) => Transition<Model, Message>;
47
+ onDefect?: (cause: unknown) => void;
48
+ }): RunningProgram<Model, Message>;
@@ -0,0 +1,154 @@
1
+ import { reportError, reportSafely } from './errors.js';
2
+ import { traceProgram, nextProgramId, hasProgramObservers } from './diagnostics.js';
3
+ import { Cause, Effect, Fiber, Option, Stream } from 'effect';
4
+ import * as Atom from 'effect/unstable/reactivity/Atom';
5
+ import * as AtomRegistry from 'effect/unstable/reactivity/AtomRegistry';
6
+ /** A lazy request settles successes, typed failures and defects through one message contract. */
7
+ export function effectCommand(slot, load, handlers) {
8
+ return { slot, effect: Effect.suspend(load).pipe(Effect.matchCause(handlers)) };
9
+ }
10
+ /** Owned work with no model result. Failures use the program's error reporter. */
11
+ export function actionCommand(slot, action) {
12
+ return { slot, action: Effect.suspend(action).pipe(Effect.asVoid) };
13
+ }
14
+ export function mapCommand(command, map) {
15
+ if (command.action)
16
+ return { slot: command.slot, action: command.action };
17
+ return command.stream
18
+ ? { slot: command.slot, stream: command.stream.pipe(Stream.map(map)) }
19
+ : { slot: command.slot, effect: command.effect.pipe(Effect.map(map)) };
20
+ }
21
+ /** One immutable source of truth. Reducers are synchronous; commands own async work. */
22
+ export function program(options) {
23
+ const id = nextProgramId();
24
+ const trace = (kind, slot, message) => hasProgramObservers() &&
25
+ traceProgram({
26
+ program: id,
27
+ ...(options.name ? { name: options.name } : {}),
28
+ kind,
29
+ ...(slot ? { slot } : {}),
30
+ ...(message &&
31
+ typeof message === 'object' &&
32
+ 'type' in message &&
33
+ typeof message.type === 'string'
34
+ ? { message: message.type }
35
+ : {}),
36
+ });
37
+ const waiters = new Set();
38
+ const notify = () => {
39
+ for (const waiter of waiters) {
40
+ if (disposed ||
41
+ (!draining && (waiter.slot ? !running.has(waiter.slot) : running.size === 0))) {
42
+ waiters.delete(waiter);
43
+ waiter.done();
44
+ }
45
+ }
46
+ };
47
+ const registry = AtomRegistry.make();
48
+ const atom = Atom.make(options.initial);
49
+ const release = registry.mount(atom);
50
+ const running = new Map();
51
+ let disposed = false;
52
+ let draining = false;
53
+ const queue = [];
54
+ const cancel = (slot) => {
55
+ const previous = running.get(slot);
56
+ running.delete(slot);
57
+ if (previous)
58
+ trace('cancel', slot);
59
+ if (previous?.fiber)
60
+ Effect.runFork(Fiber.interrupt(previous.fiber));
61
+ };
62
+ const send = (message) => {
63
+ if (disposed)
64
+ return;
65
+ queue.push(message);
66
+ if (draining)
67
+ return;
68
+ draining = true;
69
+ try {
70
+ while (queue.length && !disposed) {
71
+ const message = queue.shift();
72
+ const transition = options.update(registry.get(atom), message);
73
+ trace('update', undefined, message);
74
+ for (const slot of transition.cancel ?? [])
75
+ cancel(slot);
76
+ registry.set(atom, transition.model);
77
+ for (const command of transition.commands ?? []) {
78
+ if (disposed)
79
+ break;
80
+ cancel(command.slot);
81
+ const token = {};
82
+ const runningCommand = { token };
83
+ running.set(command.slot, runningCommand);
84
+ trace('start', command.slot);
85
+ const effect = command.stream
86
+ ? command.stream.pipe(Stream.runForEach((message) => Effect.sync(() => {
87
+ if (!disposed && running.get(command.slot)?.token === token)
88
+ send(message);
89
+ })), Effect.as(Option.none()))
90
+ : command.action
91
+ ? command.action.pipe(Effect.as(Option.none()))
92
+ : command.effect.pipe(Effect.map(Option.some));
93
+ const fiber = Effect.runFork(effect);
94
+ runningCommand.fiber = fiber;
95
+ if (disposed) {
96
+ Effect.runFork(Fiber.interrupt(fiber));
97
+ break;
98
+ }
99
+ fiber.addObserver((exit) => {
100
+ if (disposed || running.get(command.slot)?.token !== token)
101
+ return;
102
+ running.delete(command.slot);
103
+ trace(exit._tag === 'Success' ? 'complete' : 'defect', command.slot);
104
+ if (exit._tag === 'Success') {
105
+ if (Option.isSome(exit.value))
106
+ send(exit.value.value);
107
+ }
108
+ else
109
+ reportSafely(options.onDefect ?? reportError, exit.cause);
110
+ notify();
111
+ });
112
+ }
113
+ }
114
+ }
115
+ catch (error) {
116
+ queue.length = 0;
117
+ throw error;
118
+ }
119
+ finally {
120
+ draining = false;
121
+ notify();
122
+ }
123
+ };
124
+ return {
125
+ model: () => registry.get(atom),
126
+ activeSlots: () => [...running.keys()],
127
+ awaitIdle: (slot) => disposed || (!draining && (slot ? !running.has(slot) : running.size === 0))
128
+ ? Promise.resolve()
129
+ : new Promise((done) => {
130
+ waiters.add({ slot, done });
131
+ }),
132
+ send,
133
+ subscribe: (listener) => registry.subscribe(atom, (model) => {
134
+ try {
135
+ listener(model);
136
+ }
137
+ catch (error) {
138
+ reportSafely(options.onDefect ?? reportError, error);
139
+ }
140
+ }),
141
+ dispose() {
142
+ if (disposed)
143
+ return;
144
+ disposed = true;
145
+ queue.length = 0;
146
+ for (const slot of running.keys())
147
+ cancel(slot);
148
+ release();
149
+ registry.dispose();
150
+ trace('dispose');
151
+ notify();
152
+ },
153
+ };
154
+ }