effectweb 0.2.2 → 0.3.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 (65) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +94 -2
  3. package/dist/AsyncContent.d.ts +2 -2
  4. package/dist/AsyncContent.js +36 -24
  5. package/dist/actions.d.ts +4 -4
  6. package/dist/actions.js +1 -1
  7. package/dist/cache-internals.d.ts +14 -0
  8. package/dist/cache-internals.js +11 -0
  9. package/dist/cache.d.ts +17 -15
  10. package/dist/cache.js +98 -27
  11. package/dist/collection.d.ts +10 -10
  12. package/dist/collection.js +32 -6
  13. package/dist/component.d.ts +8 -7
  14. package/dist/diagnostics.d.ts +25 -0
  15. package/dist/diagnostics.js +167 -1
  16. package/dist/dom-attributes.json +71 -0
  17. package/dist/dom.d.ts +36 -13
  18. package/dist/dom.js +547 -84
  19. package/dist/form.d.ts +61 -0
  20. package/dist/form.js +98 -0
  21. package/dist/html-attributes.d.ts +602 -0
  22. package/dist/html-attributes.js +1 -0
  23. package/dist/http.d.ts +5 -0
  24. package/dist/http.js +7 -0
  25. package/dist/index.d.ts +12 -10
  26. package/dist/index.js +7 -5
  27. package/dist/jsx.d.ts +165 -33
  28. package/dist/jsx.js +2 -1
  29. package/dist/lazy.d.ts +23 -0
  30. package/dist/lazy.js +58 -0
  31. package/dist/load.d.ts +3 -4
  32. package/dist/mount.d.ts +8 -11
  33. package/dist/mount.js +1 -3
  34. package/dist/owner.d.ts +11 -9
  35. package/dist/owner.js +27 -38
  36. package/dist/pages.d.ts +23 -23
  37. package/dist/pages.js +15 -10
  38. package/dist/program.d.ts +19 -10
  39. package/dist/program.js +172 -66
  40. package/dist/query-internals.d.ts +11 -0
  41. package/dist/query-internals.js +10 -0
  42. package/dist/query.d.ts +24 -20
  43. package/dist/query.js +60 -7
  44. package/dist/resource.d.ts +4 -10
  45. package/dist/resource.js +14 -5
  46. package/dist/runtime.d.ts +1 -2
  47. package/dist/runtime.js +3 -3
  48. package/dist/session.d.ts +12 -18
  49. package/dist/session.js +15 -15
  50. package/dist/share.d.ts +5 -4
  51. package/dist/share.js +5 -44
  52. package/dist/sharing.d.ts +6 -0
  53. package/dist/sharing.js +93 -0
  54. package/dist/snapshot.d.ts +16 -5
  55. package/dist/snapshot.js +6 -7
  56. package/dist/state.d.ts +0 -1
  57. package/dist/state.js +0 -1
  58. package/dist/svg-attributes.d.ts +12 -0
  59. package/dist/svg-attributes.js +1 -0
  60. package/dist/task.d.ts +9 -8
  61. package/dist/task.js +75 -47
  62. package/dist/tasks.d.ts +20 -15
  63. package/dist/tasks.js +39 -25
  64. package/dist/testing.d.ts +10 -15
  65. package/package.json +5 -1
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 TeleVecha contributors
3
+ Copyright (c) 2026 EffectWeb contributors
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -4,6 +4,98 @@ Immutable Effect models and JSX compiled to direct DOM updates, without signals,
4
4
 
5
5
  Use with `@effectweb/compiler/vite` and Effect `4.0.0-rc.112`. The package includes programs, named tasks, async presentation, query caching, DOM lifetimes, and `effectweb/testing` helpers.
6
6
 
7
- See [setup and example](https://github.com/DerpyCrabs/EffectWeb#vite-setup) and the [authoring guide](https://github.com/DerpyCrabs/EffectWeb/blob/main/docs/authoring.md).
7
+ See [setup and example](https://github.com/DerpyCrabs/EffectWeb#vite-setup).
8
8
 
9
- Early API; runtime and compiler versions advance together. Client-side only. Persistence and multi-tab coordination belong to the application.
9
+ Runtime and compiler versions advance together. Client-side only. Persistence and multi-tab coordination belong to the application.
10
+
11
+ ## Query identity and account ownership
12
+
13
+ A query definition has its own identity. Within one cache, all request arguments form its key automatically:
14
+
15
+ ```ts
16
+ const page = query({
17
+ name: 'message-page',
18
+ load: (args: { accountId: string; threadId: string; cursor?: string }) =>
19
+ api.messages(args.accountId, args.threadId, args.cursor),
20
+ });
21
+ ```
22
+
23
+ Request arguments use the same canonical encoding for cache lookup, prefetch, writes, invalidation, and `queryResource.select`. Object property order is ignored; array order matters. Strings, finite numbers, booleans, `null`, and explicit `undefined` are supported recursively in dense arrays and plain objects. Missing properties differ from properties containing `undefined`, and `0` differs from `-0`. Functions, symbols, bigint, nonfinite numbers, cycles, class instances, accessors, nonenumerable properties, sparse arrays, and arrays with extra properties throw `TypeError`. Repeated references to the same plain object are supported. Published argument objects are frozen and are not cloned.
24
+
25
+ Pass every load-relevant value as an argument, including account, filters, pagination, locale, and permissions when they affect the result. Custom `key` projections are rejected. Provide service instances through the Effect environment instead of query arguments. Separate query definitions do not share entries even if their names and arguments match. `select(undefined)` disables selection; an explicit `undefined` inside an argument object remains part of its identity. A query without arguments uses `true` for selection and prefetch.
26
+
27
+ Give authenticated data an account lifetime:
28
+
29
+ ```ts
30
+ const account = modelOwner({ accountId }, { runtime });
31
+ const cache = account.own(makeQueryCache(runtime));
32
+ const messages = observeQuery(account, cache, page, (result) => {
33
+ // Publish the immutable result into application state.
34
+ });
35
+ messages.select({ accountId, threadId });
36
+
37
+ // Sign out or switch account: dispose this lifetime, then create the next one.
38
+ account.dispose();
39
+ ```
40
+
41
+ Independent caches isolate account data even when services derive authentication from their environment. If an application deliberately reuses a cache across accounts, include account identity in every authenticated query key and call `cache.resetResources()` at the boundary before selecting the new account. Reset interrupts old resources and returns existing query observers to their initial state; select again to enter the new generation. Disposing only a query observer releases its subscription; it does not erase shared cached values. Own shared caches at the account or application scope, not in individual views.
42
+
43
+ Use `cache.setQueryData(query, args, value)` to seed or replace a cached success, and `cache.updateQueryData(query, args, update)` to change an existing success. The updater receives readonly data; returning `undefined` skips the write. Both publish to current observers and supersede pending loads for those arguments. Use `setQueryData` to store a successful `undefined` value. Query definitions and cache registries are opaque; use the public cache and observation methods.
44
+
45
+ ## Write concurrency
46
+
47
+ `modelOwner.run(slot, effect, policy)` and `defineTasks(owner, definitions)` support these policies:
48
+
49
+ | Policy | Behavior for an occupied slot |
50
+ | --------------- | ----------------------------------------------------------------------- |
51
+ | `drop` | Ignore the new request. |
52
+ | `replace` | Cancel active work and discard pending requests; start the new request. |
53
+ | `parallel` | Start the new request alongside active work. |
54
+ | `queue` | Append the request; run it after earlier work finishes. |
55
+ | `latest-queued` | Keep active work; replace all pending requests with the newest request. |
56
+
57
+ Every command requires an explicit `policy`. Create stable operation identities with `commandSlot('save')`; equal diagnostic names do not share a slot. Component `defineTasks(...).tasks(...)` and `taskComponent` support all except `parallel`, since their single result slot represents serial work. Command mapping and runtime service provisioning preserve the policy. Actions sharing a slot share concurrency and cancellation. A queued request waits for every active request in that slot if policies are mixed; keep a consistent policy per slot for predictable write behavior.
58
+
59
+ Use `queue` when each accepted operation matters, such as appending messages. Use `latest-queued` when saving complete document snapshots and only the newest pending snapshot matters:
60
+
61
+ ```ts
62
+ const writes = defineTasks(owner, {
63
+ save: { policy: 'latest-queued', run: (document: Snapshot<Document>) => storage.save(document) },
64
+ });
65
+
66
+ writes.save(owner.read().document);
67
+ ```
68
+
69
+ Component tasks capture the model snapshot and input when `Run` is submitted, including requests that wait in a queue. Editing fields afterward does not replace that captured model. Controller tasks retain their supplied arguments; their factories execute when work starts. Pass `owner.read()` data as arguments to capture submission state, or read inside the Effect when execution-time state is intended. Effects and inputs are retained, not deep-cloned; keep supplied data immutable. Dropped and coalesced pending factories are never invoked.
70
+
71
+ Queue progress continues after successes, typed failures, or defects. Component results remain `waiting` while more work is pending, publish each settlement, and retain the latest successful value if a later write fails. Action failures use the owner's error reporter. `cancel`, component reset or identity change, and disposal discard pending requests and interrupt active work; stale command completions cannot publish afterward. `awaitIdle` includes pending work. Cancellation cannot undo an external write that already completed. Transactions admit their whole command batch before starting Effects, so replacements and cancellation can remove superseded work without executing it.
72
+
73
+ ## Lazy views and portals
74
+
75
+ `lazyView(() => fromPromise(() => import('./Reader').then((module) => module.Reader)))` loads a view when mounted. It accepts typed `pending` and `failure` views and a `runtime` when the loader requires services. Each unresolved placement owns its load; unmounting interrupts it. Successful definitions are cached for future mounts. Render a lazy view through `ViewBinding` with its current model and sender.
76
+
77
+ `<Portal mount={model.dialogHost}>...</Portal>` renders into a supplied HTML or SVG element. Omitting `mount` uses the document body. Portal content retains its owner, events, and cleanup; changing between HTML and SVG targets rebuilds content in the correct namespace.
78
+
79
+ ## Inspect source dependencies
80
+
81
+ Mount a development panel before mounting the application so it sees initial evaluations:
82
+
83
+ ```ts
84
+ import { mountBindingInspector } from 'effectweb/diagnostics';
85
+
86
+ const removeInspector = mountBindingInspector(document.querySelector<HTMLElement>('#inspector')!);
87
+ // Mount the application here. On teardown or HMR:
88
+ // removeInspector();
89
+ ```
90
+
91
+ The live, filterable table shows original file/line/column, source expressions, inferred snapshot dependencies, the latest changed dependency names, and derive/binding evaluation counts. It uses development compiler metadata; production builds emit none. Counts include initial evaluations, aggregate instances of the same source expression, and measure evaluations rather than actual DOM writes. Change reasons use reference/value equality, without retaining previous or next values.
92
+
93
+ For custom tooling, `inspectBindings({ limit: 200 })` returns `entries()`, `subscribe(listener)`, `clear()`, and `dispose()`. Entries are immutable metadata, newest first, with at most 1000 source records. Least recently updated sources are evicted and start fresh if seen again. `dispose()` unsubscribes and clears retained metadata. `mountBindingInspector(element, inspector)` can share an inspector; removing that panel leaves the supplied inspector running. Low-level `observeBindings` remains available.
94
+
95
+ The inspector covers instrumented derivations and text/attribute bindings; it is not a snapshot recorder, time-travel debugger, or complete profile of branch/list reconciliation. Source labels describe the compiler's inferred dependencies, not a proof that an opaque helper has no hidden state.
96
+
97
+ ## Immutable inputs and outputs
98
+
99
+ Published `Snapshot<T>` values are recursively readonly, including nested arrays/tuples and async success data. View/slot composition, component inputs, owner patches, and task field updates accept readonly branches without casts. Query cache reads, subscriptions, and prefetch publish the same readonly data. Plain objects and arrays are frozen before publication in every build; snapshot protection cannot be disabled.
100
+
101
+ Functions, Effects, DOM nodes, and explicitly marked service classes retain their own API. See the [authoring guide](https://github.com/DerpyCrabs/EffectWeb/blob/main/docs/authoring.md) for migration examples, `SnapshotOpaque`, state ownership, form composition, and reconciling async loads with live updates.
@@ -13,8 +13,8 @@ export interface AsyncContentProps<A, E> {
13
13
  }
14
14
  type Props = AsyncContentProps<unknown, unknown>;
15
15
  /** Presentation only: resource owners choose identity, loading, caching and cancellation. */
16
- export declare const AsyncContent: {
17
- <A, E>(props: AsyncContentProps<A, E>): JSX.Element;
16
+ export declare const AsyncContent: JSX.ComponentType & {
17
+ <A, E>(this: never, props: AsyncContentProps<A, E>): JSX.Element;
18
18
  readonly build: View<Props, never>["build"];
19
19
  };
20
20
  export {};
@@ -1,9 +1,11 @@
1
1
  import * as _ew_dom from "./dom.js";
2
+ import { commandSlot } from "./program.js";
2
3
  import { Cause, Effect, Option } from "effect";
3
4
  import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
4
5
  import { component } from "./component.js";
5
6
  import { view } from "./index.js";
6
7
  import { effectCommand } from "./program.js";
8
+ const commandPending = commandSlot("pending");
7
9
  const implementation = component({
8
10
  init: (props) => ({
9
11
  props,
@@ -19,7 +21,7 @@ const implementation = component({
19
21
  pending: false,
20
22
  visible: false
21
23
  },
22
- cancel: ["pending"]
24
+ cancel: [commandPending]
23
25
  };
24
26
  if (model.pending && model.props.pendingDelay === props.pendingDelay)
25
27
  return { model: {
@@ -33,8 +35,9 @@ const implementation = component({
33
35
  pending: true,
34
36
  visible: delay === 0
35
37
  },
36
- cancel: ["pending"],
37
- commands: delay === 0 ? [] : [effectCommand("pending", () => Effect.sleep(delay), {
38
+ cancel: [commandPending],
39
+ commands: delay === 0 ? [] : [effectCommand(commandPending, () => Effect.sleep(delay), {
40
+ policy: "replace",
38
41
  onSuccess: () => ({ type: "ShowPending" }),
39
42
  onFailure: () => ({ type: "ShowPending" })
40
43
  })]
@@ -45,27 +48,36 @@ const implementation = component({
45
48
  visible: true
46
49
  } }),
47
50
  view: /* @__PURE__ */ _ew_dom.compiled((_ew_scope_1, _ew_parent_2, _ew_before_3) => {
48
- const _ew_derived_4 = _ew_scope_1.derive(() => [_ew_scope_1.value?.props], () => ((_ew_capture_5) => _ew_capture_5.props)(_ew_scope_1.value));
49
- const _ew_derived_6 = _ew_scope_1.derive(() => [_ew_derived_4()?.result], () => ((_ew_capture_7) => AsyncResult.value(_ew_capture_7.result))(_ew_derived_4()));
50
- const _ew_derived_8 = _ew_scope_1.derive(() => [_ew_derived_4()?.result, _ew_derived_4()?.result?.waiting], () => ((_ew_capture_9) => AsyncResult.isFailure(_ew_capture_9.result) && !_ew_capture_9.result.waiting ? _ew_capture_9.result : undefined)(_ew_derived_4()));
51
- _ew_dom.branch(_ew_scope_1, _ew_parent_2, _ew_before_3, () => Option.isSome(_ew_derived_6()), (_ew_scope_1, _ew_parent_10, _ew_before_11) => {
52
- _ew_dom.text(_ew_scope_1, _ew_parent_10, _ew_before_11, () => [_ew_derived_4(), _ew_derived_6()?.value], () => _ew_derived_4().content(_ew_derived_6().value));
53
- _ew_dom.branch(_ew_scope_1, _ew_parent_10, _ew_before_11, () => _ew_derived_4().result.waiting, (_ew_scope_1, _ew_parent_12, _ew_before_13) => {
54
- _ew_dom.text(_ew_scope_1, _ew_parent_12, _ew_before_13, () => [_ew_derived_4()?.refreshing], () => _ew_derived_4().refreshing);
55
- }, (_ew_scope_1, _ew_parent_12, _ew_before_13) => { });
56
- _ew_dom.branch(_ew_scope_1, _ew_parent_10, _ew_before_11, () => _ew_derived_8() && _ew_derived_4().failure, (_ew_scope_1, _ew_parent_14, _ew_before_15) => {
57
- _ew_dom.text(_ew_scope_1, _ew_parent_14, _ew_before_15, () => [_ew_derived_4(), _ew_derived_8()?.cause], () => _ew_derived_4().failure(_ew_derived_8().cause));
58
- }, (_ew_scope_1, _ew_parent_14, _ew_before_15) => { });
59
- }, (_ew_scope_1, _ew_parent_10, _ew_before_11) => {
60
- _ew_dom.branch(_ew_scope_1, _ew_parent_10, _ew_before_11, () => _ew_derived_8() && _ew_derived_4().failure, (_ew_scope_1, _ew_parent_16, _ew_before_17) => {
61
- _ew_dom.text(_ew_scope_1, _ew_parent_16, _ew_before_17, () => [_ew_derived_4(), _ew_derived_8()?.cause], () => _ew_derived_4().failure(_ew_derived_8().cause));
62
- }, (_ew_scope_1, _ew_parent_16, _ew_before_17) => {
63
- _ew_dom.branch(_ew_scope_1, _ew_parent_16, _ew_before_17, () => _ew_scope_1.value.pending, (_ew_scope_1, _ew_parent_18, _ew_before_19) => {
64
- _ew_dom.branch(_ew_scope_1, _ew_parent_18, _ew_before_19, () => _ew_scope_1.value.visible, (_ew_scope_1, _ew_parent_20, _ew_before_21) => {
65
- _ew_dom.text(_ew_scope_1, _ew_parent_20, _ew_before_21, () => [_ew_derived_4()?.pending], () => _ew_derived_4().pending);
66
- }, (_ew_scope_1, _ew_parent_20, _ew_before_21) => { });
67
- }, (_ew_scope_1, _ew_parent_18, _ew_before_19) => {
68
- _ew_dom.text(_ew_scope_1, _ew_parent_18, _ew_before_19, () => [_ew_derived_4()?.empty], () => _ew_derived_4().empty);
51
+ const _ew_derived_4 = _ew_scope_1.derive(() => [_ew_scope_1.value?.props], () => {
52
+ const _ew_capture_6 = _ew_scope_1.value;
53
+ return _ew_capture_6.props;
54
+ });
55
+ const _ew_derived_7 = _ew_scope_1.derive(() => [_ew_derived_4()?.result], () => {
56
+ const _ew_capture_9 = _ew_derived_4();
57
+ return AsyncResult.value(_ew_capture_9.result);
58
+ });
59
+ const _ew_derived_10 = _ew_scope_1.derive(() => [_ew_derived_4()?.result, _ew_derived_4()?.result?.waiting], () => {
60
+ const _ew_capture_12 = _ew_derived_4();
61
+ return AsyncResult.isFailure(_ew_capture_12.result) && !_ew_capture_12.result.waiting ? _ew_capture_12.result : undefined;
62
+ });
63
+ _ew_dom.branch(_ew_scope_1, _ew_parent_2, _ew_before_3, () => Option.isSome(_ew_derived_7()), (_ew_scope_1, _ew_parent_13, _ew_before_14) => {
64
+ _ew_dom.text(_ew_scope_1, _ew_parent_13, _ew_before_14, () => [_ew_derived_4(), _ew_derived_7()?.value], () => _ew_derived_4().content(_ew_derived_7().value));
65
+ _ew_dom.branch(_ew_scope_1, _ew_parent_13, _ew_before_14, () => _ew_derived_4().result.waiting, (_ew_scope_1, _ew_parent_15, _ew_before_16) => {
66
+ _ew_dom.text(_ew_scope_1, _ew_parent_15, _ew_before_16, () => [_ew_derived_4()?.refreshing], () => _ew_derived_4().refreshing);
67
+ }, (_ew_scope_1, _ew_parent_15, _ew_before_16) => { });
68
+ _ew_dom.branch(_ew_scope_1, _ew_parent_13, _ew_before_14, () => _ew_derived_10() && _ew_derived_4().failure, (_ew_scope_1, _ew_parent_17, _ew_before_18) => {
69
+ _ew_dom.text(_ew_scope_1, _ew_parent_17, _ew_before_18, () => [_ew_derived_4(), _ew_derived_10()?.cause], () => _ew_derived_4().failure(_ew_derived_10().cause));
70
+ }, (_ew_scope_1, _ew_parent_17, _ew_before_18) => { });
71
+ }, (_ew_scope_1, _ew_parent_13, _ew_before_14) => {
72
+ _ew_dom.branch(_ew_scope_1, _ew_parent_13, _ew_before_14, () => _ew_derived_10() && _ew_derived_4().failure, (_ew_scope_1, _ew_parent_19, _ew_before_20) => {
73
+ _ew_dom.text(_ew_scope_1, _ew_parent_19, _ew_before_20, () => [_ew_derived_4(), _ew_derived_10()?.cause], () => _ew_derived_4().failure(_ew_derived_10().cause));
74
+ }, (_ew_scope_1, _ew_parent_19, _ew_before_20) => {
75
+ _ew_dom.branch(_ew_scope_1, _ew_parent_19, _ew_before_20, () => _ew_scope_1.value.pending, (_ew_scope_1, _ew_parent_21, _ew_before_22) => {
76
+ _ew_dom.branch(_ew_scope_1, _ew_parent_21, _ew_before_22, () => _ew_scope_1.value.visible, (_ew_scope_1, _ew_parent_23, _ew_before_24) => {
77
+ _ew_dom.text(_ew_scope_1, _ew_parent_23, _ew_before_24, () => [_ew_derived_4()?.pending], () => _ew_derived_4().pending);
78
+ }, (_ew_scope_1, _ew_parent_23, _ew_before_24) => { });
79
+ }, (_ew_scope_1, _ew_parent_21, _ew_before_22) => {
80
+ _ew_dom.text(_ew_scope_1, _ew_parent_21, _ew_before_22, () => [_ew_derived_4()?.empty], () => _ew_derived_4().empty);
69
81
  });
70
82
  });
71
83
  });
package/dist/actions.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { Send, Transition } from './program.js';
2
- type Handler<Model> = (model: Model, ...args: never[]) => Transition<Model, unknown>;
2
+ import type { Snapshot } from './snapshot.js';
3
+ type Handler<Model, R> = (model: Snapshot<Model>, ...args: never[]) => Transition<Model, unknown, R>;
3
4
  type Arguments<F> = F extends (model: never, ...args: infer Args) => unknown ? Args : never;
4
5
  type HandlerMessage<Handlers> = {
5
6
  [Name in keyof Handlers]: {
@@ -18,10 +19,9 @@ type Creators<Handlers> = {
18
19
  type Dispatch<Handlers> = {
19
20
  [Name in keyof Handlers]: (...args: Arguments<Handlers[Name]>) => void;
20
21
  };
21
- /** Declare action names and payloads once. Dispatch still crosses the ordinary program message queue. */
22
- export declare function defineActions<Model>(): <Handlers extends Record<string, Handler<Model>>>(handlers: Handlers) => {
22
+ export declare function defineActions<Model, R = never>(): <Handlers extends Record<string, Handler<Model, R>>>(handlers: Handlers) => {
23
23
  message: Creators<Handlers>;
24
24
  bind(this: void, send: Send<HandlerMessage<Handlers>>): Dispatch<Handlers>;
25
- update(this: void, model: Model, action: HandlerMessage<Handlers>): ReturnType<Handlers[keyof Handlers]>;
25
+ update(this: void, model: Snapshot<Model>, action: HandlerMessage<Handlers>): ReturnType<Handlers[keyof Handlers]>;
26
26
  };
27
27
  export {};
package/dist/actions.js CHANGED
@@ -1,4 +1,3 @@
1
- /** Declare action names and payloads once. Dispatch still crosses the ordinary program message queue. */
2
1
  export function defineActions() {
3
2
  return (handlers) => {
4
3
  const message = Object.create(null);
@@ -24,6 +23,7 @@ export function defineActions() {
24
23
  if (!Object.hasOwn(handlers, action.type))
25
24
  throw new Error(`Unknown action: ${String(action.type)}`);
26
25
  const handler = handlers[action.type];
26
+ // oxlint-disable-next-line typescript/no-unsafe-return -- The indexed handler and ReturnType refer to the same validated generic member.
27
27
  return handler(model, ...action.args);
28
28
  },
29
29
  };
@@ -0,0 +1,14 @@
1
+ import type * as AsyncResult from 'effect/unstable/reactivity/AsyncResult';
2
+ import type * as Atom from 'effect/unstable/reactivity/Atom';
3
+ import type * as AtomRegistry from 'effect/unstable/reactivity/AtomRegistry';
4
+ import type { QueryCache } from './cache.js';
5
+ import type { Query } from './query.js';
6
+ import type { Snapshot } from './snapshot.js';
7
+ export interface CacheInternals<R> {
8
+ readonly registry: AtomRegistry.AtomRegistry;
9
+ readonly generation: Atom.Writable<number>;
10
+ query<Args, A, E>(definition: Query<Args, A, E, R>, args: Args | Snapshot<Args>): Atom.Atom<AsyncResult.AsyncResult<Snapshot<A>, E>>;
11
+ }
12
+ export declare function registerCache<R>(cache: QueryCache<R>, internals: CacheInternals<R>): void;
13
+ /** Private implementation access; deliberately absent from package exports. */
14
+ export declare function cacheInternals<R>(cache: QueryCache<R>): CacheInternals<R>;
@@ -0,0 +1,11 @@
1
+ const caches = new WeakMap();
2
+ export function registerCache(cache, internals) {
3
+ caches.set(cache, internals);
4
+ }
5
+ /** Private implementation access; deliberately absent from package exports. */
6
+ export function cacheInternals(cache) {
7
+ const internals = caches.get(cache);
8
+ if (!internals)
9
+ throw new TypeError('Use makeQueryCache() to create a cache.');
10
+ return internals;
11
+ }
package/dist/cache.d.ts CHANGED
@@ -1,23 +1,25 @@
1
+ import { type Snapshot } from './snapshot.js';
1
2
  import { Effect } from 'effect';
2
- import * as AsyncResult from 'effect/unstable/reactivity/AsyncResult';
3
- import * as Atom from 'effect/unstable/reactivity/Atom';
4
- import * as AtomRegistry from 'effect/unstable/reactivity/AtomRegistry';
5
- import type { Query } from './query.js';
3
+ import { type Query } from './query.js';
6
4
  import type { UiRuntime } from './runtime.js';
7
5
  export { loadEffect, type UiLoad } from './load.js';
8
6
  export { shareValue } from './share.js';
9
7
  export declare function makeQueryCache(): QueryCache<never>;
10
8
  export declare function makeQueryCache<R>(runtime: UiRuntime<R>): QueryCache<R>;
11
- declare function createQueryCache<R>(runtime?: UiRuntime<R>): {
12
- registry: AtomRegistry.AtomRegistry;
13
- generation: Atom.Writable<number, number>;
14
- resource<A, E = unknown>(key: string, load: () => Effect.Effect<A, E>): Atom.Atom<AsyncResult.AsyncResult<A, E>>;
15
- query: <Args, A, E>(definition: Query<Args, A, E, R>, args: Args) => Atom.Atom<AsyncResult.AsyncResult<A, E>>;
16
- /** Prefetch and views observe the same atom; failure types and shared cancellation remain intact. */
17
- prefetch<Args, A, E>(definition: Query<Args, A, E, R>, args: Args): Effect.Effect<A, E>;
18
- invalidateQuery<Args, A, E>(definition: Query<Args, A, E, R>, ...selected: [] | [Args]): void;
19
- invalidate(prefix: string): void;
9
+ /** A cache owns one registry and the query resources published through it. */
10
+ export interface QueryCache<R = never> {
11
+ prefetch<Args, A, E>(definition: Query<Args, A, E, R>, args: NoInfer<Args> | Snapshot<NoInfer<Args>>): Effect.Effect<Snapshot<A>, E>;
12
+ /** Publish a protected success, including undefined, and supersede any pending load for this key. */
13
+ setQueryData<Args, A, E>(definition: Query<Args, A, E, R>, args: NoInfer<Args> | Snapshot<NoInfer<Args>>, value: NoInfer<A> | Snapshot<NoInfer<A>>): Snapshot<A>;
14
+ /**
15
+ * Update an existing success, including one retained during refresh or failure, and supersede its load.
16
+ * No success or an undefined updater return skips the write. Use setQueryData to seed or store undefined.
17
+ * Updaters run synchronously on readonly data; a throw leaves the cached value unchanged.
18
+ */
19
+ updateQueryData<Args, A, E>(definition: Query<Args, A, E, R>, args: NoInfer<Args> | Snapshot<NoInfer<Args>>, update: (previous: Snapshot<A>) => NoInfer<A> | Snapshot<NoInfer<A>> | undefined): Snapshot<A> | undefined;
20
+ invalidateQuery<Args, A, E>(definition: Query<Args, A, E, R>, ...selected: [] | [NoInfer<Args> | Snapshot<NoInfer<Args>>]): void;
21
+ /** Observe account resets without exposing registry mutation. */
22
+ onReset(listener: () => void): () => void;
20
23
  resetResources(): void;
21
24
  dispose(): void;
22
- };
23
- export type QueryCache<R = never> = ReturnType<typeof createQueryCache<R>>;
25
+ }
package/dist/cache.js CHANGED
@@ -1,9 +1,13 @@
1
+ import { protectSnapshot } from './snapshot.js';
1
2
  import { Effect, Option } from 'effect';
2
3
  import * as AsyncResult from 'effect/unstable/reactivity/AsyncResult';
3
4
  import * as Atom from 'effect/unstable/reactivity/Atom';
4
5
  import * as AtomRegistry from 'effect/unstable/reactivity/AtomRegistry';
5
6
  import { loadEffect } from './load.js';
6
- import { shareValue } from './share.js';
7
+ import { shareData } from './sharing.js';
8
+ import { encodeQueryKey } from './query.js';
9
+ import { registerCache } from './cache-internals.js';
10
+ import { queryDefinition } from './query-internals.js';
7
11
  export { loadEffect } from './load.js';
8
12
  export { shareValue } from './share.js';
9
13
  export function makeQueryCache(runtime) {
@@ -13,19 +17,44 @@ function createQueryCache(runtime) {
13
17
  const registry = AtomRegistry.make({ defaultIdleTTL: 30_000 });
14
18
  const generation = Atom.keepAlive(Atom.make(0));
15
19
  const resources = new Map();
16
- const acquire = (key, load, share = shareValue) => {
20
+ // Follow registry-node lifetime; retaining definitions must not retain evicted data.
21
+ const values = new WeakMap();
22
+ const identities = new WeakMap();
23
+ let disposed = false;
24
+ let nextId = 0;
25
+ const identity = (definition) => {
26
+ let id = identities.get(definition);
27
+ if (id === undefined) {
28
+ id = ++nextId;
29
+ identities.set(definition, id);
30
+ }
31
+ return id;
32
+ };
33
+ const acquire = (key, load, share = (previous, next) => shareData(previous, next)) => {
17
34
  let entry = resources.get(key);
18
35
  if (entry)
19
36
  entry.load = load;
20
37
  else {
38
+ const loaded = Atom.make((get) => {
39
+ const previous = Option.flatMap(get.self(), AsyncResult.value);
40
+ return loadEffect(() => next.load()).pipe(Effect.map((value) => {
41
+ const shared = Option.isSome(previous)
42
+ ? share(previous.value, value)
43
+ : value;
44
+ const snapshot = protectSnapshot(shared);
45
+ remember(next, snapshot);
46
+ return snapshot;
47
+ }));
48
+ });
21
49
  const next = {
22
50
  load,
23
- atom: Atom.make((get) => {
24
- const previous = Option.flatMap(get.self(), AsyncResult.value);
25
- return loadEffect(() => next.load()).pipe(Effect.map((value) => {
26
- next.loadedAt = Date.now();
27
- return Option.isSome(previous) ? share(previous.value, value) : value;
28
- }));
51
+ atom: Atom.writable(loaded.read, (context, value) => {
52
+ Atom.batch(() => {
53
+ // Refresh disposes the load lifetime; setSelf publishes without starting another load.
54
+ context.refreshSelf();
55
+ remember(next, value);
56
+ context.setSelf(AsyncResult.success(value));
57
+ });
29
58
  }),
30
59
  };
31
60
  entry = next;
@@ -42,33 +71,71 @@ function createQueryCache(runtime) {
42
71
  }
43
72
  return { entry, atom: entry.atom };
44
73
  };
45
- const queryKey = (definition, args) => `query:${definition.id}:${definition.key(args)}`;
46
- const selectQuery = (definition, args) => {
74
+ const remember = (entry, value) => {
75
+ entry.loadedAt = Date.now();
76
+ const node = registry.getNodes().get(entry.atom);
77
+ if (node)
78
+ values.set(node, { value });
79
+ };
80
+ const previousValue = (entry) => {
81
+ const node = entry && registry.getNodes().get(entry.atom);
82
+ return node ? values.get(node) : undefined;
83
+ };
84
+ const checkWritable = () => {
85
+ if (disposed)
86
+ throw new Error('Cannot write to a disposed query cache.');
87
+ };
88
+ const queryKey = (definition, args) => `query:${identity(definition)}:${encodeQueryKey(args)}`;
89
+ const acquireQuery = (definition, args) => {
90
+ const config = queryDefinition(definition);
91
+ protectSnapshot(args);
47
92
  const { entry, atom } = acquire(queryKey(definition, args), () => {
48
- const effect = Effect.suspend(() => definition.load(args));
93
+ const effect = Effect.suspend(() => config.load(args));
49
94
  return runtime ? runtime.provide(effect) : effect;
50
- }, definition.share);
51
- entry.queryId = definition.id;
95
+ }, config.share);
96
+ entry.query = definition;
97
+ return { entry, atom, config };
98
+ };
99
+ const selectQuery = (definition, args) => {
100
+ const { entry, atom, config } = acquireQuery(definition, args);
52
101
  if (registry.getNodes().has(atom) &&
53
102
  entry.loadedAt !== undefined &&
54
- Date.now() - entry.loadedAt >= definition.staleTime) {
103
+ Date.now() - entry.loadedAt >= config.staleTime) {
55
104
  const current = registry.get(atom);
56
105
  if (!current.waiting)
57
106
  registry.refresh(atom);
58
107
  }
59
108
  return atom;
60
109
  };
61
- return {
62
- registry,
63
- generation,
64
- resource(key, load) {
65
- return acquire(key, load).atom;
66
- },
67
- query: selectQuery,
68
- /** Prefetch and views observe the same atom; failure types and shared cancellation remain intact. */
110
+ const cache = {
69
111
  prefetch(definition, args) {
70
112
  return Effect.suspend(() => AtomRegistry.getResult(registry, selectQuery(definition, args), { suspendOnWaiting: true }));
71
113
  },
114
+ setQueryData(definition, args, value) {
115
+ checkWritable();
116
+ const { entry, config } = acquireQuery(definition, args);
117
+ const previous = previousValue(entry);
118
+ const next = protectSnapshot(value);
119
+ const shared = previous
120
+ ? config.share
121
+ ? config.share(previous.value, next)
122
+ : shareData(previous.value, next)
123
+ : next;
124
+ const snapshot = protectSnapshot(shared);
125
+ checkWritable();
126
+ registry.set(entry.atom, snapshot);
127
+ return snapshot;
128
+ },
129
+ updateQueryData(definition, args, update) {
130
+ checkWritable();
131
+ queryDefinition(definition);
132
+ protectSnapshot(args);
133
+ const previous = previousValue(resources.get(queryKey(definition, args)));
134
+ if (!previous)
135
+ return undefined;
136
+ const next = update(previous.value);
137
+ return next === undefined ? undefined : cache.setQueryData(definition, args, next);
138
+ },
72
139
  invalidateQuery(definition, ...selected) {
73
140
  if (selected.length) {
74
141
  const entry = resources.get(queryKey(definition, selected[0]));
@@ -77,14 +144,13 @@ function createQueryCache(runtime) {
77
144
  }
78
145
  else {
79
146
  for (const entry of resources.values())
80
- if (entry.queryId === definition.id)
147
+ if (entry.query === definition)
81
148
  registry.refresh(entry.atom);
82
149
  }
83
150
  },
84
- invalidate(prefix) {
85
- for (const [key, entry] of resources)
86
- if (key.startsWith(prefix))
87
- registry.refresh(entry.atom);
151
+ onReset(listener) {
152
+ registry.get(generation);
153
+ return registry.subscribe(generation, listener);
88
154
  },
89
155
  resetResources() {
90
156
  Atom.batch(() => {
@@ -97,8 +163,13 @@ function createQueryCache(runtime) {
97
163
  });
98
164
  },
99
165
  dispose() {
166
+ if (disposed)
167
+ return;
168
+ disposed = true;
100
169
  registry.dispose();
101
170
  resources.clear();
102
171
  },
103
172
  };
173
+ registerCache(cache, { registry, generation, query: selectQuery });
174
+ return Object.freeze(cache);
104
175
  }
@@ -1,5 +1,5 @@
1
+ import type { Snapshot } from './snapshot.js';
1
2
  export type Identity = string | number;
2
- /** Identity belongs to the domain collection, not to each place that renders it. */
3
3
  export interface Rows<A> {
4
4
  readonly items: readonly A[];
5
5
  readonly identity: (item: A, index: number) => Identity;
@@ -8,16 +8,16 @@ export interface Rows<A> {
8
8
  filter(predicate: (item: A, index: number) => boolean): Rows<A>;
9
9
  slice(start?: number, end?: number): Rows<A>;
10
10
  }
11
- export declare function collection<A>(identity: (item: A, index: number) => Identity): {
12
- from: (items: readonly A[]) => Rows<A>;
13
- share: {
14
- <B extends A>(previous: readonly B[], next: B[]): B[];
15
- <B extends A>(previous: readonly B[], next: readonly B[]): readonly B[];
16
- };
17
- };
11
+ /** Validate without retaining values. Positions are zero-based array indices. */
12
+ export declare function validateIdentities<A>(items: readonly A[], identity: (item: A, index: number) => Identity): Identity[];
13
+ export interface Collection<A> {
14
+ from(this: void, items: readonly (A | Snapshot<A>)[]): Rows<Snapshot<A>>;
15
+ share<B extends A | Snapshot<A>>(this: void, previous: readonly B[], next: readonly B[]): readonly Snapshot<B>[];
16
+ }
17
+ export declare function collection<A>(identity: (item: Snapshot<A>, index: number) => Identity): Collection<A>;
18
18
  /** Use positional identity for ordered values without stable entity IDs. */
19
- export declare function sequence<A>(items: readonly A[]): Rows<A>;
19
+ export declare function sequence<A>(items: readonly A[]): Rows<Snapshot<A>>;
20
20
  /** Rows keyed by their domain IDs. Reuses the wrapper for the same immutable array. */
21
21
  export declare function entities<A extends {
22
22
  readonly id: Identity;
23
- }>(items: readonly A[] | undefined): Rows<A>;
23
+ }>(items: readonly A[] | undefined): Rows<Snapshot<A>>;
@@ -1,8 +1,30 @@
1
- import { shareValue } from './share.js';
2
- export function collection(identity) {
1
+ import { shareData } from './sharing.js';
2
+ /** Validate without retaining values. Positions are zero-based array indices. */
3
+ export function validateIdentities(items, identity) {
4
+ const seen = new Map();
5
+ return items.map((item, index) => {
6
+ const key = identity(item, index);
7
+ if (typeof key !== 'string' && typeof key !== 'number')
8
+ throw new Error(`Invalid collection identity at index ${index}. Use a string or number domain identity, or sequence(items) for positional identity.`);
9
+ const previous = seen.get(key);
10
+ if (previous !== undefined)
11
+ throw new Error(`Duplicate collection identity at indices ${previous} and ${index}. Identity must be unique within the collection; use a composite domain identity when IDs are only locally unique.`);
12
+ seen.set(key, index);
13
+ return key;
14
+ });
15
+ }
16
+ function makeCollection(identity) {
3
17
  const cache = new WeakMap();
18
+ const validated = new WeakSet();
19
+ const validate = (items) => {
20
+ if (validated.has(items))
21
+ return;
22
+ validateIdentities(items, identity);
23
+ validated.add(items);
24
+ };
4
25
  const comparisons = new WeakMap();
5
26
  const from = (items) => {
27
+ validate(items);
6
28
  const cached = cache.get(items);
7
29
  if (cached)
8
30
  return cached;
@@ -18,6 +40,8 @@ export function collection(identity) {
18
40
  return rows;
19
41
  };
20
42
  function share(previous, next) {
43
+ validate(previous);
44
+ validate(next);
21
45
  if (previous === next)
22
46
  return previous;
23
47
  let pairs = comparisons.get(previous);
@@ -36,13 +60,10 @@ export function collection(identity) {
36
60
  if (index >= previous.length || !Object.is(identity(old, index), key)) {
37
61
  if (!byIdentity) {
38
62
  byIdentity = new Map(previous.map((value, i) => [identity(value, i), value]));
39
- const keys = next.map(identity);
40
- if (byIdentity.size !== previous.length || new Set(keys).size !== keys.length)
41
- throw new Error('Duplicate collection identity. Identity must be unique within the collection.');
42
63
  }
43
64
  old = byIdentity.get(key);
44
65
  }
45
- const value = old === undefined ? item : shareValue(old, item);
66
+ const value = old === undefined ? item : shareData(old, item);
46
67
  if (!Object.is(value, previous[index]))
47
68
  equal = false;
48
69
  if (!Object.is(value, item) && !result)
@@ -60,6 +81,11 @@ export function collection(identity) {
60
81
  }
61
82
  return { from, share };
62
83
  }
84
+ export function collection(identity) {
85
+ // Snapshot changes access permissions, not runtime representation. The implementation
86
+ // only borrows supplied items, and never inserts values of a wider type.
87
+ return makeCollection(identity);
88
+ }
63
89
  const positions = collection((_item, index) => index);
64
90
  /** Use positional identity for ordered values without stable entity IDs. */
65
91
  export function sequence(items) {