@effect-app/vue 4.0.0-beta.99 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/CHANGELOG.md +2701 -0
  2. package/dist/atomQuery.d.ts +120 -0
  3. package/dist/atomQuery.d.ts.map +1 -0
  4. package/dist/atomQuery.js +434 -0
  5. package/dist/commander.d.ts +318 -43
  6. package/dist/commander.d.ts.map +1 -1
  7. package/dist/commander.js +593 -81
  8. package/dist/confirm.d.ts +5 -3
  9. package/dist/confirm.d.ts.map +1 -1
  10. package/dist/confirm.js +12 -14
  11. package/dist/dependencyMetadata.d.ts +18 -0
  12. package/dist/dependencyMetadata.d.ts.map +1 -0
  13. package/dist/dependencyMetadata.js +67 -0
  14. package/dist/errorReporter.d.ts +6 -4
  15. package/dist/errorReporter.d.ts.map +1 -1
  16. package/dist/errorReporter.js +14 -19
  17. package/dist/form.d.ts +5 -5
  18. package/dist/form.d.ts.map +1 -1
  19. package/dist/form.js +16 -10
  20. package/dist/internal/tanstackQuery.d.ts +9 -0
  21. package/dist/internal/tanstackQuery.d.ts.map +1 -0
  22. package/dist/internal/tanstackQuery.js +204 -0
  23. package/dist/intl.d.ts +4 -4
  24. package/dist/intl.d.ts.map +1 -1
  25. package/dist/intl.js +2 -2
  26. package/dist/lib.d.ts +8 -10
  27. package/dist/lib.d.ts.map +1 -1
  28. package/dist/lib.js +37 -12
  29. package/dist/liveQueryInvalidation.d.ts +16 -0
  30. package/dist/liveQueryInvalidation.d.ts.map +1 -0
  31. package/dist/liveQueryInvalidation.js +115 -0
  32. package/dist/makeClient.d.ts +232 -109
  33. package/dist/makeClient.d.ts.map +1 -1
  34. package/dist/makeClient.js +416 -84
  35. package/dist/makeContext.d.ts.map +1 -1
  36. package/dist/makeIntl.d.ts.map +1 -1
  37. package/dist/makeUseCommand.d.ts +3 -2
  38. package/dist/makeUseCommand.d.ts.map +1 -1
  39. package/dist/makeUseCommand.js +2 -2
  40. package/dist/mutate.d.ts +110 -41
  41. package/dist/mutate.d.ts.map +1 -1
  42. package/dist/mutate.js +236 -59
  43. package/dist/query.d.ts +161 -44
  44. package/dist/query.d.ts.map +1 -1
  45. package/dist/query.js +382 -113
  46. package/dist/queryLifetime.d.ts +25 -0
  47. package/dist/queryLifetime.d.ts.map +1 -0
  48. package/dist/queryLifetime.js +54 -0
  49. package/dist/routeParams.d.ts +4 -4
  50. package/dist/routeParams.d.ts.map +1 -1
  51. package/dist/routeParams.js +4 -3
  52. package/dist/runtime.d.ts +1 -17
  53. package/dist/runtime.d.ts.map +1 -1
  54. package/dist/runtime.js +2 -38
  55. package/dist/suspense.d.ts +19 -0
  56. package/dist/suspense.d.ts.map +1 -0
  57. package/dist/suspense.js +38 -0
  58. package/dist/toast.d.ts +1 -45
  59. package/dist/toast.d.ts.map +1 -1
  60. package/dist/toast.js +2 -32
  61. package/dist/withToast.d.ts +1 -24
  62. package/dist/withToast.d.ts.map +1 -1
  63. package/dist/withToast.js +2 -45
  64. package/docs/atom-query-api-redesign.md +207 -0
  65. package/docs/mutation-command-atoms.md +40 -0
  66. package/docs/query-key-invalidation.md +107 -0
  67. package/examples/streamMutation.ts +72 -0
  68. package/package.json +35 -94
  69. package/src/atomQuery.ts +600 -0
  70. package/src/commander.ts +1064 -116
  71. package/src/confirm.ts +12 -14
  72. package/src/dependencyMetadata.ts +84 -0
  73. package/src/errorReporter.ts +65 -75
  74. package/src/form.ts +22 -15
  75. package/src/index.ts +7 -7
  76. package/src/internal/tanstackQuery.ts +308 -0
  77. package/src/intl.ts +2 -2
  78. package/src/lib.ts +51 -23
  79. package/src/liveQueryInvalidation.ts +138 -0
  80. package/src/makeClient.ts +972 -263
  81. package/src/makeIntl.ts +2 -2
  82. package/src/makeUseCommand.ts +8 -5
  83. package/src/mutate.ts +407 -154
  84. package/src/query.ts +758 -242
  85. package/src/queryLifetime.ts +74 -0
  86. package/src/routeParams.ts +7 -7
  87. package/src/runtime.ts +1 -54
  88. package/src/suspense.ts +43 -0
  89. package/src/toast.ts +1 -52
  90. package/src/withToast.ts +1 -99
  91. package/test/Mutation.test.ts +104 -17
  92. package/test/dependencyInvalidation.test.ts +444 -0
  93. package/test/dist/dependencyInvalidation.test.d.ts.map +1 -0
  94. package/test/dist/form.test.d.ts.map +1 -1
  95. package/test/dist/inactiveInvalidation.test.d.ts.map +1 -0
  96. package/test/dist/interrupt-refetch-repro.test.d.ts.map +1 -0
  97. package/test/dist/lib.test.d.ts.map +1 -0
  98. package/test/dist/liveQueryInvalidation.test.d.ts.map +1 -0
  99. package/test/dist/query-span.test.d.ts.map +1 -0
  100. package/test/dist/queryOptions.test.d.ts.map +1 -0
  101. package/test/dist/streamFinal.test.d.ts.map +1 -0
  102. package/test/dist/streamFn.test.d.ts.map +1 -0
  103. package/test/dist/stubs.d.ts +3681 -256
  104. package/test/dist/stubs.d.ts.map +1 -1
  105. package/test/dist/stubs.js +166 -25
  106. package/test/dist/suspense-regression.test.d.ts.map +1 -0
  107. package/test/dist/suspense.test.d.ts.map +1 -0
  108. package/test/form-validation-errors.test.ts +2 -1
  109. package/test/form.test.ts +2 -1
  110. package/test/inactiveInvalidation.test.ts +172 -0
  111. package/test/interrupt-refetch-repro.test.ts +625 -0
  112. package/test/lib.test.ts +240 -0
  113. package/test/liveQueryInvalidation.test.ts +108 -0
  114. package/test/makeClient.test.ts +414 -32
  115. package/test/query-span.test.ts +92 -0
  116. package/test/queryOptions.test.ts +88 -0
  117. package/test/streamFinal.test.ts +64 -0
  118. package/test/streamFn.test.ts +457 -0
  119. package/test/stubs.ts +194 -36
  120. package/test/suspense-regression.test.ts +168 -0
  121. package/test/suspense.test.ts +160 -0
  122. package/tsconfig.examples.json +20 -0
  123. package/tsconfig.json +9 -1
  124. package/tsconfig.src.json +34 -34
  125. package/tsconfig.test.json +2 -2
  126. package/vitest.config.ts +5 -5
  127. package/eslint.config.mjs +0 -24
  128. package/tsconfig.json.bak +0 -12
@@ -0,0 +1,207 @@
1
+ # Atom query API redesign
2
+
3
+ This branch can stop treating Effect atoms as an implementation detail hidden behind a TanStack-shaped API. The useful durable primitive is the atom; Vue refs, suspense promises, refetch helpers, and future hydration should all be adapters around it.
4
+
5
+ ## Source findings
6
+
7
+ Relevant upstream Effect v4 atom APIs checked locally:
8
+
9
+ - `repos/effect/packages/effect/src/unstable/reactivity/Atom.ts`
10
+ - `AtomRuntime.atom` turns an `Effect` into `Atom<AsyncResult<A, E>>`.
11
+ - `Atom.family` gives structural cache identity by input.
12
+ - `Atom.swr`, `Atom.withRefresh`, `Atom.setIdleTTL`, and `Atom.keepAlive` already model stale reads, polling, idle lifetime, and keep-alive.
13
+ - `Atom.mapResult` and `Atom.transform` are the right composition layer for `select` and derived queries.
14
+ - `Atom.optimistic` / `Atom.optimisticFn` can replace our current no-op optimistic `useUpdateQuery` facade.
15
+ - `Atom.toStreamResult`, `Atom.getResult`, `Atom.refresh`, and `Atom.mount` provide Effect-native conversion points.
16
+ - `repos/effect/packages/effect/src/unstable/reactivity/AtomRegistry.ts`
17
+ - `getResult(registry, atom, { suspendOnWaiting: true })` is the precise operation we need for awaitable refetch and Vue suspense.
18
+ - Registries are independent; relying on the module-global `defaultRegistry` leaks a policy decision into the query engine.
19
+ - `repos/effect/packages/effect/src/unstable/reactivity/AtomRpc.ts`
20
+ - Upstream RPC integration exposes `query(tag, payload)` as an atom and `mutation(tag)` as an atom result function. This is the shape we should mirror for effect-app clients.
21
+ - `repos/effect/packages/effect/src/unstable/reactivity/Hydration.ts`
22
+ - Serializable atoms support `dehydrate` / `hydrate`; Nuxt SSR can use that later without inventing query-specific prefetch state.
23
+ - `repos/effect/packages/atom/vue/src/index.ts`
24
+ - Vue integration is intentionally thin: `useAtomValue`, `useAtom`, `injectRegistry`, `registryKey`.
25
+ - There is no special query abstraction upstream; the app layer should own the ergonomic client API.
26
+
27
+ ## Current problems
28
+
29
+ - `query.ts` still exposes TanStack vocabulary and types: `UseQueryReturnType`, `QueryObserverResult`, `RefetchOptions`, `initialData`, `placeholderData`, `gcTime`, `refetchInterval`, and tuple returns.
30
+ - The raw query atom is hidden in the fourth tuple slot. That makes composition awkward and encourages helper APIs to tunnel through private handles.
31
+ - Query family identity must be stable by query key plus projection hash, while observer options stay outside the family. Otherwise projected and unprojected clients can accidentally share a base atom.
32
+ - `useUpdateQuery` accepts an updater but cannot apply it because query atoms are read-only derived atoms there; it currently refreshes and ignores the updater.
33
+ - Legacy stream query `.query()` is still collapsed into `Stream.runCollect`, so the compatibility API behaves like a slow normal query. Stream query clients now also expose atom-native `.atom()`, `.family()`, and `.queryNew()` helpers backed by `Atom.pull`, so new call sites can observe incremental pull state without waiting for stream completion.
34
+ - The default atom registry is used in invalidation-await logic. That works for today but blocks scoped registries, SSR hydration, and tests that provide a custom registry.
35
+
36
+ ## Compatibility boundary
37
+
38
+ Keep the existing public APIs intact while the internals move to atom-native composition:
39
+
40
+ - `client.X.query(...)` keeps its current tuple return shape.
41
+ - `client.X.suspense(...)` keeps returning a Promise of that tuple shape.
42
+ - Existing options remain accepted on old APIs, but their behavior should be fixed internally. In particular, handler-global base atoms should be shared, while observer-specific behavior such as `select`, polling, SWR/focus policy, and structural sharing is layered on top.
43
+
44
+ Only expose breaking or meaningfully different APIs under new names:
45
+
46
+ - `client.X.atom(input, options?)`
47
+ - `client.X.queryNew(input, options?)`
48
+ - `client.X.suspenseNew(input, options?)`
49
+
50
+ This lets the app test the new shape in real call sites without forcing a broad migration, and lets old API users benefit from the internal option-sharing fix.
51
+
52
+ ## Target client shape
53
+
54
+ Keep query helpers on the typed clients, like mutations:
55
+
56
+ ```ts
57
+ const atom = client.Carts.List.atom(input, options)
58
+ const query = client.Carts.List.queryNew(input, options)
59
+ const suspense = await client.Carts.List.suspenseNew(input, options)
60
+ ```
61
+
62
+ `suspenseNew` stays a `Promise`, because Vue setup / Suspense wants a Promise boundary.
63
+
64
+ The proposed breaking return shape is an object:
65
+
66
+ ```ts
67
+ interface QueryView<A, E> {
68
+ readonly result: ComputedRef<AsyncResult.AsyncResult<A, E>>
69
+ readonly data: ComputedRef<A | undefined>
70
+ readonly atom: ComputedRef<Atom.Atom<AsyncResult.AsyncResult<A, E>>>
71
+ readonly awaitResult: () => Effect.Effect<A, E, never>
72
+ readonly refetch: () => Effect.Effect<A, E, never>
73
+ readonly refresh: () => void
74
+ }
75
+
76
+ interface SuspenseQueryView<A, E> extends Omit<QueryView<A, E>, "data"> {
77
+ readonly data: ComputedRef<A>
78
+ }
79
+ ```
80
+
81
+ `queryNew()` returns `QueryView<A, E>`. `suspenseNew()` returns `Promise<SuspenseQueryView<A, E>>`.
82
+
83
+ This removes tuple slot meaning, makes `refetch` obviously awaitable, and exposes the atom for composition.
84
+
85
+ ## Atom-first internals
86
+
87
+ Split the implementation into two layers:
88
+
89
+ - `atomQuery.ts`: build and compose atoms.
90
+ - `queryAtom(handler, input, options)` returns `Atom<AsyncResult<A, E>>`.
91
+ - `queryFamily(handler)` is stable by query key plus projection hash and does not capture observer options.
92
+ - `awaitQueryAtom(registry, atom)` delegates to `AtomRegistry.getResult`.
93
+ - `refreshQueryAtom(registry, atom)` delegates to `registry.refresh`.
94
+ - `query.ts`: Vue adapter only.
95
+ - Resolve refs/getters/options.
96
+ - Call `useAtomValue`.
97
+ - Return `QueryView` / `SuspenseQueryView`.
98
+ - Convert to Promise only inside `useSuspenseQuery`.
99
+
100
+ Options that affect a single observer should wrap the shared raw atom rather than mutate handler-family identity:
101
+
102
+ ```ts
103
+ const raw = family(input)
104
+ const selected = select ? Atom.mapResult(raw, select) : raw
105
+ const refreshed = refreshEvery
106
+ ? Atom.withRefresh(refreshEvery)(selected)
107
+ : selected
108
+ const viewed = Atom.swr({ staleTime, revalidateOnFocus, focusSignal })(
109
+ refreshed
110
+ )
111
+ ```
112
+
113
+ TTL is the awkward option. If TTL is part of the base atom, it should be a client/default policy, not the first observer's option. For old APIs, keep accepting `gcTime`, but normalize it into a base-atom policy that cannot be captured accidentally by the first observer. For new APIs, prefer an atom-native `idleTTL` or `timeToLive` name.
114
+
115
+ ## TanStack parity: observers, TTL, and cancellation
116
+
117
+ Observer cleanup and cache lifetime are separate concerns:
118
+
119
+ - Vue observers are registered through `@effect/atom-vue`'s `useAtomValue`, which uses a Vue `watchEffect` cleanup around `registry.subscribe`. Non-suspense component unmounts therefore remove observer subscriptions through normal Vue scope disposal.
120
+ - Suspense helpers still load through native query atoms. The `Promise` returned by `.suspense()` / `.suspenseNew()` is only the Vue Suspense boundary that waits for `AtomRegistry.getResult`. Suspense setup runs in an explicit child `effectScope`; unmount stops that scope, removes the observer subscription, and aborts the waiting Effect so the Suspense Promise does not remain pending after the component is gone.
121
+ - Observer-specific wrappers (`select`, SWR, focus revalidation, polling, structural sharing) must not get their own idle TTL. Otherwise a wrapper can keep source atoms observed after the component unmounts. TTL belongs to the canonical handler+input query atom.
122
+ - The canonical query atom keeps its configured idle TTL after the last observer is gone. This preserves cached data and lets invalidation still reach cached-but-unmounted queries during the idle window.
123
+
124
+ Current difference from TanStack Query:
125
+
126
+ - TanStack creates an `AbortSignal` for each fetch. When the last observer is removed, it cancels the active retryer only if the query function consumed the signal; otherwise it cancels retries and lets the in-flight request finish so the result can populate cache.
127
+ - Atom query currently does not cancel the canonical query atom's in-flight Effect merely because the last observer unmounted, because the canonical atom node can remain alive for `idleTTL`.
128
+ - If canonical fetch cancellation is added later, it must match TanStack's observable state semantics: cancellation is control flow, not query data. Interrupting an in-flight fetch must not store an interrupt cause as the final `AsyncResult`; it should revert to the previous settled state, or to initial/idle when there is no previous result.
129
+ - The desired parity target is: last observer gone -> remove observer subscriptions immediately; if no observers remain, interrupt the active fetch without recording an interrupt failure; keep the previous cached result until `idleTTL` expires.
130
+
131
+ ## Option names
132
+
133
+ Use atom-native names on the new APIs. Keep old option names on old APIs:
134
+
135
+ - `gcTime` -> `idleTTL` or `timeToLive`
136
+ - `refetchInterval` -> `refreshEvery`
137
+ - `refetchOnWindowFocus` -> `revalidateOnFocus`
138
+ - `select` can stay; it is a familiar projection name and maps cleanly to `Atom.mapResult`.
139
+ - Drop `initialData` and `placeholderData` from the new core API. Keep them accepted by old APIs if compatibility requires it, but implement them as wrappers/fallbacks over atoms rather than query-engine state.
140
+
141
+ ## Composition API
142
+
143
+ Expose enough atoms that app code can compose before Vue refs:
144
+
145
+ ```ts
146
+ const cartsAtom = client.Carts.List.atom(undefined)
147
+ const spotsAtom = client.Spots.List.atom(undefined)
148
+
149
+ const pageAtom = Atom.make((get) => ({
150
+ carts: get.result(cartsAtom, { suspendOnWaiting: true }),
151
+ spots: get.result(spotsAtom, { suspendOnWaiting: true })
152
+ }))
153
+ ```
154
+
155
+ For plain result composition, add an atom-native replacement for `composeQueries`:
156
+
157
+ ```ts
158
+ const combined = composeQueryAtoms({
159
+ carts: client.Carts.List.atom(undefined),
160
+ spots: client.Spots.List.atom(undefined)
161
+ })
162
+ ```
163
+
164
+ The existing `composeQueries` can remain as a Vue-ref convenience wrapper during migration.
165
+
166
+ ## Optimistic updates
167
+
168
+ Replace `useUpdateQuery(query, input, updater)` with atom-level helpers:
169
+
170
+ ```ts
171
+ const carts = client.Carts.List.optimistic(input)
172
+ const updateCart = client.Carts.Update.optimistic(carts, reducer)
173
+ ```
174
+
175
+ Internally this should use `Atom.optimistic` / `Atom.optimisticFn`, not a manual cache patch. The mutation can still invalidate reactivity keys after success; optimistic atoms handle temporary UI state and rollback.
176
+
177
+ ## Registry and full-stack notes
178
+
179
+ - The frontend plugin should provide an app-level `AtomRegistry` via `registryKey` instead of relying on `@effect/atom-vue`'s fallback `defaultRegistry`.
180
+ - `invalidateAndAwait` should await against the active registry, not a module global. A registry-aware mutation layer is cleaner than hidden global state.
181
+ - Serializable query atoms can unlock Nuxt SSR hydration later:
182
+ - mark query atoms with `Atom.serializable` using request schema + stable input key,
183
+ - dehydrate after server setup,
184
+ - hydrate the client registry before mounting.
185
+ - Stream query clients expose pull atoms for real progress:
186
+ - `client.Progress.Stream.atom(input)` returns `Atom.Writable<Atom.PullResult<A, E>, void>`,
187
+ - `client.Progress.Stream.family()` returns the reusable atom family,
188
+ - `client.Progress.Stream.queryNew(input)` returns a Vue view with `result`, `items`, `latest`, `done`, `pull`, and `pullAndAwait`.
189
+ - Legacy `.query()` remains collect-to-array compatibility until call sites migrate.
190
+
191
+ ## Migration plan
192
+
193
+ 1. Refactor internals so a base query atom is shared per handler+input, then old and new APIs layer observer options on top. This fixes old API behavior without changing old API shape.
194
+ 2. Add `client.X.atom(input, options?)`, `client.X.queryNew(input, options?)`, and `client.X.suspenseNew(input, options?)`. Update type tests to assert atom exposure and object-return typing.
195
+ 3. Keep `.query()` and `.suspense()` as compatibility APIs. They can delegate to the new internal engine and adapt the result back to tuple shape.
196
+ 4. Add one or two real frontend example conversions to `queryNew` / `suspenseNew`, preferably places that exercise:
197
+ - a suspense read with `data` and `result`,
198
+ - an awaitable `refetch`,
199
+ - optionally `atom` composition if a small call site exists.
200
+ 5. Keep most frontend call sites on the old tuple APIs so both surfaces are exercised during the transition.
201
+ 6. Replace `useUpdateQuery` call sites with atom refresh or optimistic helpers after the new query surface proves out.
202
+ 7. Convert legacy stream query call sites from collect-to-array `.query()` to pull/accumulating `.queryNew()` / `.atom()` APIs.
203
+ 8. Add registry provider + hydration experiments after the client API is stable.
204
+
205
+ ## Recommendation
206
+
207
+ Do direct atom exposure and the breaking object API together, but under additive names first. Keep `suspense()` as the compatibility Promise tuple and add `suspenseNew()` as the Promise object API. The Promise should remain a framework boundary over `AtomRegistry.getResult`, not the internal shape. The API should make the atom visible, because that is where Effect v4 gives us composition, refresh, hydration, streams, and optimistic state.
@@ -0,0 +1,40 @@
1
+ # Mutation and command atoms
2
+
3
+ Keep query and cache state atom-native. Keep ordinary mutations Effect-based and wrap them in `Command` when they need UI state. Use an atom-backed mutation only when its state is itself shared application state.
4
+
5
+ ## Why
6
+
7
+ Query state is durable, keyed by handler and input, shared across components, and refreshed by invalidation. Command state usually belongs to one invocation: `waiting`, `blocked`, progress, errors, toasts, and follow-up Effects.
8
+
9
+ `makeMutation` already records invalidation keys and data-dependency writes, then awaits affected atom queries. A mutation does not need to be an atom for cache coherence.
10
+
11
+ `Command.fn` and mutation `.wrap()` add action identity, local reactive state, error handling, confirmation, toasts, and stream progress around an Effect.
12
+
13
+ ## Prefer `Command`
14
+
15
+ Use the existing Effect-based mutation path when:
16
+
17
+ - The mutation returns `void` or a small value.
18
+ - Its shared effect is query invalidation.
19
+ - Only the initiating surface needs `waiting`, `blocked`, progress, or errors.
20
+ - The result is immediately composed with validation, navigation, emitted events, or other Effects.
21
+ - Nothing must observe the invocation after that surface unmounts.
22
+
23
+ ## Prefer an atom
24
+
25
+ Use atom-backed mutation state when:
26
+
27
+ - Distant components observe the same invocation.
28
+ - State is keyed per entity and instances must be tracked independently.
29
+ - Other atoms derive from the mutation state.
30
+ - Long-running progress must survive component remounts.
31
+ - Optimistic state belongs in the same graph as the canonical entity state.
32
+
33
+ Effect provides `Atom.fn`, `AtomRuntime.fn`, and mutation helpers in `AtomRpc` and `AtomHttpApi`. These expose one `AsyncResult` state cell. Before using one as an application command, define its keying, lifetime, reset behavior, concurrency, and how individual callers receive results.
34
+
35
+ ## Recommended split
36
+
37
+ 1. Queries, entities, caches, and live projections: atoms.
38
+ 2. Cache invalidation: the existing atom-query invalidation path.
39
+ 3. Ordinary user actions: Effect-based mutations wrapped with `Command` as needed.
40
+ 4. Shared, keyed, or long-lived action state: an atom-backed command abstraction with explicit concurrency semantics.
@@ -0,0 +1,107 @@
1
+ # Query-key invalidation semantics
2
+
3
+ How the Atom query engine reproduces TanStack Query's **hierarchical (namespace) invalidation**, and where it intentionally differs.
4
+
5
+ ## The behavior we must match
6
+
7
+ TanStack `queryClient.invalidateQueries({ queryKey: K })` defaults to `exact: false`:
8
+ **K matches every query whose key has K as a prefix.**
9
+
10
+ ```
11
+ invalidate ["A","B"] -> matches ["A","B"], ["A","B","C"], ["A","B",input], ...
12
+ invalidate ["A","B","C"] -> does NOT match ["A","B"] (K longer than the key)
13
+ ```
14
+
15
+ Two requirements: don't invalidate **more** than TanStack would, and don't invalidate **less**.
16
+
17
+ ## How the Atom engine does it
18
+
19
+ TanStack stores each full key and runs a prefix compare at invalidation time. The Atom
20
+ engine inverts that: each query **pre-registers all prefixes of its full key**, and
21
+ invalidation does an exact structural-hash lookup.
22
+
23
+ - Full key: `fullKey = [...baseKey, input]` where `baseKey = makeQueryKey(self)` is the
24
+ hierarchical, input-independent namespace (e.g. `["$Project","$Configuration","get"]`).
25
+ - On mount, the query atom registers `prefixesOf(fullKey)` — every non-empty prefix:
26
+
27
+ ```
28
+ ["$Project","$Configuration","get", input]
29
+ registers:
30
+ ["$Project"]
31
+ ["$Project","$Configuration"]
32
+ ["$Project","$Configuration","get"]
33
+ ["$Project","$Configuration","get", input]
34
+ ```
35
+
36
+ See `buildQueryFamily` → `prefixesOf` → `factory.withReactivity` in
37
+ [`src/atomQuery.ts`](../src/atomQuery.ts) (registration around L416-424).
38
+ - Invalidation hashes K and fires the handlers registered under that exact hash
39
+ (`Reactivity.invalidate` → `keysToHashes` in
40
+ `repos/effect/packages/effect/src/unstable/reactivity/Reactivity.ts`).
41
+
42
+ ### Why this equals TanStack's match set
43
+
44
+ ```
45
+ K matches a query
46
+ <=> K equals one of that query's registered prefixes
47
+ <=> K is a prefix of the query's full key
48
+ ```
49
+
50
+ That is exactly TanStack's `exact:false` rule.
51
+
52
+ - **Not too much** — `invalidate ["A","B"]` hits only queries whose full key starts with
53
+ `["A","B"]`. An over-specific K (longer than a query's key) matches nothing in both
54
+ systems, because a query never registers a prefix longer than itself.
55
+ - **Not too little** — a short namespace `["A"]` reaches every query under it, because
56
+ each registered the `["A"]` prefix. The mutation default `getQueryKey`
57
+ (e.g. `["$Project"]`, `defaultGetQueryKey` in [`src/mutate.ts`](../src/mutate.ts)) is
58
+ always a leading prefix of `baseKey`, so default namespace invalidation reaches all
59
+ inputs and sub-resources.
60
+
61
+ ## Why multiple keys must be registered
62
+
63
+ This is the crux. A single exact-key registration would only fire when invalidation used
64
+ that exact key — losing TanStack's "parent namespace invalidates all children" behavior.
65
+ Registering **every prefix** is what restores hierarchical reach under an exact-lookup
66
+ mechanism. Drop a prefix and that namespace level stops invalidating its children
67
+ (invalidates too little); register a key that isn't a prefix and it invalidates unrelated
68
+ queries (too much).
69
+
70
+ ## Hashing consistency (register ↔ invalidate)
71
+
72
+ Both sides route through `keysToHashes`, which hashes **each element** of the keys array:
73
+
74
+ - Registration: `withReactivity(reactivityKeys)` → `registerUnsafe` — `reactivityKeys` is a
75
+ list of prefix-arrays, each hashed via `Hash.hash`.
76
+ - Invalidation: mutations pass `ReadonlyArray<ReadonlyArray<unknown>>` — a _list of
77
+ key-arrays_ (`buildInvalidateCache` in [`src/mutate.ts`](../src/mutate.ts)) — so each
78
+ element is a full namespaced array, matching the registration shape.
79
+
80
+ The await-tracking map (`keyAtoms` in `atomQuery.ts`) keys on the same `Hash.hash(key)`, so
81
+ `invalidateAndAwait` resolves once exactly the matched live queries have settled.
82
+
83
+ ## Intentional differences / caveats
84
+
85
+ 1. **Hash-only matching, no equality fallback.** TanStack compares keys with
86
+ `partialDeepEqual`. The Atom path keys its handler map purely on `Hash.hash` with no
87
+ tiebreak. A hash collision between two distinct keys would cross-invalidate
88
+ (over-invalidate). Probability is low (array hashes combine element hashes) but it is
89
+ the one real semantic divergence from TanStack's exact compare. Same applies to
90
+ `keyAtoms` and `uniqueKeys`.
91
+ 2. **Input is one opaque trailing element** (`[...baseKey, input]`). You can invalidate at
92
+ any namespace granularity but not _within_ an input (no "all inputs where
93
+ status=active"). TanStack with the same key shape behaves identically — parity, just a
94
+ granularity ceiling.
95
+ 3. **O(depth) registrations per live query** (depth+1 prefixes, in both the reactivity
96
+ handler map and `keyAtoms`). Negligible at typical depths of 2-4.
97
+ 4. **Reaches only "alive" atoms** (mounted, or cached within `idleTTL`). `setIdleTTL` is
98
+ applied last in the atom chain so cached-but-unmounted queries stay registered.
99
+ Invalidation **marks** those idle entries stale (TanStack `refetchType: "active"`) and
100
+ **awaits** the refetch of any atom that still has observers. A query GC'd past idle TTL
101
+ is gone and refetches fresh on next mount — matching TanStack `gcTime` semantics.
102
+
103
+ ## Verdict
104
+
105
+ Sound. The prefix-registration faithfully reproduces TanStack's hierarchical invalidation
106
+ in both directions (not too much, not too little). The only behavioral gap is the
107
+ theoretical hash-collision case in caveat 1.
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Example: stream-based mutation for a long-running operation.
3
+ *
4
+ * The server streams a tagged union of progress updates and a final result.
5
+ * The Vue ref is updated for every emitted value; `AsyncResult` stays in the
6
+ * `waiting` state until the stream ends.
7
+ *
8
+ * When using `makeClient` / `clientFor`, stream-type requests are exposed as
9
+ * `mutate` on the client object. Use `client.exportData.mutate` with `streamFn`
10
+ * combinators, or use `client.exportData.fn` to define a full command.
11
+ *
12
+ * The example below shows the low-level `asStreamResult` API.
13
+ */
14
+ import * as Effect from "effect-app/Effect"
15
+ import * as S from "effect-app/Schema"
16
+ import * as Stream from "effect/Stream"
17
+ import { asStreamResult } from "../src/mutate.js"
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Domain model
21
+ // ---------------------------------------------------------------------------
22
+
23
+ /** Intermediate progress report, e.g. "5 of 400 items processed". */
24
+ export class OperationProgress extends S.TaggedClass<OperationProgress>()("OperationProgress", {
25
+ completed: S.NonNegativeInt,
26
+ total: S.NonNegativeInt
27
+ }) {}
28
+
29
+ /** The final result produced once the operation is complete. */
30
+ export class ExportComplete extends S.TaggedClass<ExportComplete>()("ExportComplete", {
31
+ fileUrl: S.NonEmptyString
32
+ }) {}
33
+
34
+ /** Tagged union emitted by the stream. */
35
+ export type ExportEvent = OperationProgress | ExportComplete
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Simulated stream (replace with a real RPC / SSE stream in production)
39
+ // ---------------------------------------------------------------------------
40
+
41
+ /**
42
+ * Produces `total` progress updates followed by a single `ExportComplete`.
43
+ * Each step is separated by a 50 ms delay to simulate real async work.
44
+ */
45
+ const makeExportStream = (total: S.NonNegativeInt): Stream.Stream<ExportEvent> =>
46
+ Stream.concat(
47
+ Stream.range(1, total).pipe(
48
+ Stream.map((completed) => new OperationProgress({ completed: S.NonNegativeInt(completed), total })),
49
+ Stream.tap(() => Effect.sleep("50 millis"))
50
+ ),
51
+ Stream.make(new ExportComplete({ fileUrl: S.NonEmptyString("https://example.com/export.csv") }))
52
+ )
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Option A: low-level `asStreamResult` (call inside a `setup()` function)
56
+ // ---------------------------------------------------------------------------
57
+
58
+ export const useExportMutation = () => {
59
+ /**
60
+ * `result` - reactive ref, always reflects the latest stream event.
61
+ * `AsyncResult` tag:
62
+ * - Initial (waiting=true) - operation in progress
63
+ * - Success (waiting=true) - progress update received, still running
64
+ * - Success (waiting=false) - final result, operation complete
65
+ * - Failure - operation failed
66
+ *
67
+ * `execute` - call with the desired `total` to kick off the stream.
68
+ */
69
+ const [result, execute] = asStreamResult((total: S.NonNegativeInt) => makeExportStream(total))
70
+
71
+ return { result, execute }
72
+ }
package/package.json CHANGED
@@ -1,36 +1,38 @@
1
1
  {
2
2
  "name": "@effect-app/vue",
3
- "version": "4.0.0-beta.99",
3
+ "version": "4.0.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
- "homepage": "https://github.com/effect-ts-app/libs/tree/main/packages/vue",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/effect-app/libs.git",
9
+ "directory": "packages/vue"
10
+ },
11
+ "homepage": "https://github.com/effect-app/libs/tree/main/packages/vue",
7
12
  "dependencies": {
8
- "@formatjs/intl": "^4.1.5",
9
- "@tanstack/query-core": "5.96.2",
13
+ "@formatjs/intl": "^4.1.12",
10
14
  "@tanstack/vue-query": "5.96.2",
11
- "@vueuse/core": "^14.2.1",
15
+ "@vueuse/core": "^14.3.0",
12
16
  "change-case": "^5.4.4",
13
- "query-string": "^9.3.1",
14
- "effect-app": "4.0.0-beta.99"
17
+ "query-string": "^9.4.0",
18
+ "effect-app": "4.0.0"
15
19
  },
16
20
  "peerDependencies": {
17
- "@effect/atom-vue": "^4.0.0-beta.47",
18
- "@effect/platform-browser": "^4.0.0-beta.47",
19
- "@sentry/browser": "^10.47.0",
20
- "effect": "^4.0.0-beta.47",
21
- "vue": "^3.5.32"
21
+ "@effect/atom-vue": "^4.0.1",
22
+ "@effect/platform-browser": "^4.0.1",
23
+ "@sentry/browser": "^10.55.0",
24
+ "effect": "^4.0.1",
25
+ "vue": "^3.5.35"
22
26
  },
23
27
  "devDependencies": {
24
- "@effect/vitest": "^4.0.0-beta.47",
25
- "@formatjs/icu-messageformat-parser": "^3.5.3",
26
- "@types/node": "25.5.2",
27
- "@vitejs/plugin-vue": "^6.0.5",
28
- "intl-messageformat": "^11.2.0",
29
- "json5": "^2.2.3",
30
- "typescript": "~6.0.2",
31
- "vite": "^8.0.6",
32
- "vitest": "^4.1.3",
33
- "@effect-app/eslint-shared-config": "0.5.7-beta.9"
28
+ "@effect/vitest": "4.0.1",
29
+ "@formatjs/icu-messageformat-parser": "^3.5.10",
30
+ "@types/node": "25.9.1",
31
+ "@vitejs/plugin-vue": "^6.0.7",
32
+ "intl-messageformat": "^11.2.7",
33
+ "typescript": "~6.0.3",
34
+ "vite": "^8.0.15",
35
+ "vitest": "^4.1.7"
34
36
  },
35
37
  "typesVersions": {
36
38
  "*": {
@@ -44,69 +46,10 @@
44
46
  "types": "./dist/index.d.ts",
45
47
  "default": "./dist/index.js"
46
48
  },
47
- "./commander": {
48
- "types": "./dist/commander.d.ts",
49
- "default": "./dist/commander.js"
50
- },
51
- "./confirm": {
52
- "types": "./dist/confirm.d.ts",
53
- "default": "./dist/confirm.js"
54
- },
55
- "./errorReporter": {
56
- "types": "./dist/errorReporter.d.ts",
57
- "default": "./dist/errorReporter.js"
58
- },
59
- "./form": {
60
- "types": "./dist/form.d.ts",
61
- "default": "./dist/form.js"
62
- },
63
- "./intl": {
64
- "types": "./dist/intl.d.ts",
65
- "default": "./dist/intl.js"
66
- },
67
- "./lib": {
68
- "types": "./dist/lib.d.ts",
69
- "default": "./dist/lib.js"
70
- },
71
- "./makeClient": {
72
- "types": "./dist/makeClient.d.ts",
73
- "default": "./dist/makeClient.js"
74
- },
75
- "./makeContext": {
76
- "types": "./dist/makeContext.d.ts",
77
- "default": "./dist/makeContext.js"
78
- },
79
- "./makeIntl": {
80
- "types": "./dist/makeIntl.d.ts",
81
- "default": "./dist/makeIntl.js"
82
- },
83
- "./makeUseCommand": {
84
- "types": "./dist/makeUseCommand.d.ts",
85
- "default": "./dist/makeUseCommand.js"
86
- },
87
- "./mutate": {
88
- "types": "./dist/mutate.d.ts",
89
- "default": "./dist/mutate.js"
90
- },
91
- "./query": {
92
- "types": "./dist/query.d.ts",
93
- "default": "./dist/query.js"
94
- },
95
- "./routeParams": {
96
- "types": "./dist/routeParams.d.ts",
97
- "default": "./dist/routeParams.js"
98
- },
99
- "./runtime": {
100
- "types": "./dist/runtime.d.ts",
101
- "default": "./dist/runtime.js"
102
- },
103
- "./toast": {
104
- "types": "./dist/toast.d.ts",
105
- "default": "./dist/toast.js"
106
- },
107
- "./withToast": {
108
- "types": "./dist/withToast.d.ts",
109
- "default": "./dist/withToast.js"
49
+ "./internal/*": null,
50
+ "./*": {
51
+ "types": "./dist/*.d.ts",
52
+ "default": "./dist/*.js"
110
53
  }
111
54
  },
112
55
  "gitHead": "bd8e27eea3eff97db8739d577d67e7336c078d28",
@@ -120,24 +63,22 @@
120
63
  ],
121
64
  "scripts": {
122
65
  "watch": "pnpm build:tsc -w",
123
- "build:tsc": "pnpm clean-dist && effect-app-cli packagejson pnpm check",
124
- "check": "tsc --build",
66
+ "build:tsc": "pnpm clean-dist && pnpm check && node ../../scripts/rewrite-dist-declaration-imports.mjs dist",
67
+ "check": "tsgo --build",
125
68
  "build": "pnpm build:tsc",
126
- "watch2": "pnpm clean-dist && NODE_OPTIONS=--max-old-space-size=6144 tsc -w",
69
+ "watch2": "pnpm clean-dist && NODE_OPTIONS=--max-old-space-size=6144 tsgo -w",
127
70
  "clean": "rm -rf dist",
128
71
  "clean-dist": "sh ../../scripts/clean-dist.sh",
129
72
  "circular": "pnpm circular:src && pnpm circular:dist",
130
73
  "circular:src": "madge --circular --ts-config ./tsconfig.json --extensions ts ./src",
131
74
  "circular:dist": "madge --circular --extensions js ./dist",
132
- "compile": "NODE_OPTIONS=--max-old-space-size=6144 tsc --noEmit",
133
- "lint": "NODE_OPTIONS=--max-old-space-size=6144 ESLINT_TS=1 eslint ./src",
134
- "lint:watch": "ESLINT_TS=1 esw -w --changed --clear --ext ts,tsx .",
135
- "lint-fix": "pnpm lint --fix",
75
+ "compile": "NODE_OPTIONS=--max-old-space-size=6144 tsgo --noEmit",
76
+ "lint": "oxlint --quiet --type-aware ./src && pnpm exec dprint check --config ../../dprint.jsonc .",
77
+ "lint-fix": "oxlint --quiet --type-aware --fix ./src && pnpm exec dprint fmt --config ../../dprint.jsonc .",
136
78
  "test": "vitest",
137
79
  "test:run": "pnpm run test run --passWithNoTests",
138
80
  "testsuite": "pnpm lint && pnpm circular && pnpm run test:run",
139
81
  "ncu": "ncu",
140
- "pub": "pnpm prepublish && npm publish --access public",
141
- "prepublish": "cp -f ./tsconfig.json ./tsconfig.json.bak && node ../../scripts/mergeTsConfig.mjs ./tsconfig.json"
82
+ "pub": "npm publish --access public"
142
83
  }
143
84
  }