@effect-app/vue 4.0.0-beta.29 → 4.0.0-beta.291

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 (131) hide show
  1. package/CHANGELOG.md +2167 -0
  2. package/dist/atomQuery.d.ts +107 -0
  3. package/dist/atomQuery.d.ts.map +1 -0
  4. package/dist/atomQuery.js +345 -0
  5. package/dist/commander.d.ts +634 -0
  6. package/dist/commander.d.ts.map +1 -0
  7. package/dist/commander.js +1070 -0
  8. package/dist/{experimental/confirm.d.ts → confirm.d.ts} +6 -4
  9. package/dist/confirm.d.ts.map +1 -0
  10. package/dist/confirm.js +26 -0
  11. package/dist/dependencyMetadata.d.ts +10 -0
  12. package/dist/dependencyMetadata.d.ts.map +1 -0
  13. package/dist/dependencyMetadata.js +29 -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 +14 -5
  18. package/dist/form.d.ts.map +1 -1
  19. package/dist/form.js +46 -13
  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 +194 -0
  23. package/dist/intl.d.ts +15 -0
  24. package/dist/intl.d.ts.map +1 -0
  25. package/dist/intl.js +9 -0
  26. package/dist/lib.d.ts +7 -9
  27. package/dist/lib.d.ts.map +1 -1
  28. package/dist/lib.js +35 -10
  29. package/dist/makeClient.d.ts +212 -336
  30. package/dist/makeClient.d.ts.map +1 -1
  31. package/dist/makeClient.js +388 -411
  32. package/dist/makeContext.d.ts.map +1 -1
  33. package/dist/makeIntl.d.ts.map +1 -1
  34. package/dist/{experimental/makeUseCommand.d.ts → makeUseCommand.d.ts} +4 -3
  35. package/dist/makeUseCommand.d.ts.map +1 -0
  36. package/dist/makeUseCommand.js +13 -0
  37. package/dist/mutate.d.ts +108 -40
  38. package/dist/mutate.d.ts.map +1 -1
  39. package/dist/mutate.js +211 -58
  40. package/dist/query.d.ts +139 -45
  41. package/dist/query.d.ts.map +1 -1
  42. package/dist/query.js +325 -122
  43. package/dist/routeParams.d.ts +4 -4
  44. package/dist/routeParams.d.ts.map +1 -1
  45. package/dist/routeParams.js +4 -3
  46. package/dist/runtime.d.ts +1 -14
  47. package/dist/runtime.d.ts.map +1 -1
  48. package/dist/runtime.js +2 -26
  49. package/dist/suspense.d.ts +19 -0
  50. package/dist/suspense.d.ts.map +1 -0
  51. package/dist/suspense.js +38 -0
  52. package/dist/toast.d.ts +2 -0
  53. package/dist/toast.d.ts.map +1 -0
  54. package/dist/toast.js +2 -0
  55. package/dist/withToast.d.ts +2 -0
  56. package/dist/withToast.d.ts.map +1 -0
  57. package/dist/withToast.js +2 -0
  58. package/docs/atom-query-api-redesign.md +207 -0
  59. package/docs/query-key-invalidation.md +106 -0
  60. package/examples/streamMutation.ts +72 -0
  61. package/package.json +30 -94
  62. package/src/atomQuery.ts +466 -0
  63. package/src/commander.ts +3413 -0
  64. package/src/{experimental/confirm.ts → confirm.ts} +13 -15
  65. package/src/dependencyMetadata.ts +38 -0
  66. package/src/errorReporter.ts +65 -75
  67. package/src/form.ts +61 -18
  68. package/src/index.ts +7 -7
  69. package/src/internal/tanstackQuery.ts +289 -0
  70. package/src/intl.ts +12 -0
  71. package/src/lib.ts +49 -21
  72. package/src/makeClient.ts +930 -1159
  73. package/src/makeIntl.ts +2 -2
  74. package/src/{experimental/makeUseCommand.ts → makeUseCommand.ts} +9 -6
  75. package/src/mutate.ts +372 -152
  76. package/src/query.ts +683 -260
  77. package/src/routeParams.ts +7 -7
  78. package/src/runtime.ts +1 -31
  79. package/src/suspense.ts +43 -0
  80. package/src/toast.ts +1 -0
  81. package/src/withToast.ts +1 -0
  82. package/test/Mutation.test.ts +181 -24
  83. package/test/dependencyInvalidation.test.ts +352 -0
  84. package/test/dist/dependencyInvalidation.test.d.ts.map +1 -0
  85. package/test/dist/form.test.d.ts.map +1 -1
  86. package/test/dist/lib.test.d.ts.map +1 -0
  87. package/test/dist/query-span.test.d.ts.map +1 -0
  88. package/test/dist/streamFinal.test.d.ts.map +1 -0
  89. package/test/dist/streamFn.test.d.ts.map +1 -0
  90. package/test/dist/stubs.d.ts +3756 -122
  91. package/test/dist/stubs.d.ts.map +1 -1
  92. package/test/dist/stubs.js +192 -32
  93. package/test/dist/suspense-regression.test.d.ts.map +1 -0
  94. package/test/dist/suspense.test.d.ts.map +1 -0
  95. package/test/form-validation-errors.test.ts +25 -20
  96. package/test/form.test.ts +22 -3
  97. package/test/lib.test.ts +240 -0
  98. package/test/makeClient.test.ts +437 -38
  99. package/test/query-span.test.ts +92 -0
  100. package/test/streamFinal.test.ts +64 -0
  101. package/test/streamFn.test.ts +457 -0
  102. package/test/stubs.ts +228 -43
  103. package/test/suspense-regression.test.ts +168 -0
  104. package/test/suspense.test.ts +160 -0
  105. package/tsconfig.examples.json +20 -0
  106. package/tsconfig.json +3 -1
  107. package/tsconfig.src.json +34 -34
  108. package/tsconfig.test.json +2 -2
  109. package/vitest.config.ts +5 -5
  110. package/dist/experimental/commander.d.ts +0 -359
  111. package/dist/experimental/commander.d.ts.map +0 -1
  112. package/dist/experimental/commander.js +0 -557
  113. package/dist/experimental/confirm.d.ts.map +0 -1
  114. package/dist/experimental/confirm.js +0 -28
  115. package/dist/experimental/intl.d.ts +0 -16
  116. package/dist/experimental/intl.d.ts.map +0 -1
  117. package/dist/experimental/intl.js +0 -5
  118. package/dist/experimental/makeUseCommand.d.ts.map +0 -1
  119. package/dist/experimental/makeUseCommand.js +0 -13
  120. package/dist/experimental/toast.d.ts +0 -47
  121. package/dist/experimental/toast.d.ts.map +0 -1
  122. package/dist/experimental/toast.js +0 -41
  123. package/dist/experimental/withToast.d.ts +0 -25
  124. package/dist/experimental/withToast.d.ts.map +0 -1
  125. package/dist/experimental/withToast.js +0 -45
  126. package/eslint.config.mjs +0 -24
  127. package/src/experimental/commander.ts +0 -1835
  128. package/src/experimental/intl.ts +0 -9
  129. package/src/experimental/toast.ts +0 -66
  130. package/src/experimental/withToast.ts +0 -99
  131. package/tsconfig.json.bak +0 -12
package/package.json CHANGED
@@ -1,36 +1,33 @@
1
1
  {
2
2
  "name": "@effect-app/vue",
3
- "version": "4.0.0-beta.29",
3
+ "version": "4.0.0-beta.291",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "homepage": "https://github.com/effect-ts-app/libs/tree/main/packages/vue",
7
7
  "dependencies": {
8
- "@formatjs/intl": "^4.1.2",
9
- "@tanstack/query-core": "5.83.1",
10
- "@tanstack/vue-query": "5.83.1",
11
- "@vueuse/core": "^14.2.1",
8
+ "@formatjs/intl": "^4.1.12",
9
+ "@tanstack/vue-query": "5.96.2",
10
+ "@vueuse/core": "^14.3.0",
12
11
  "change-case": "^5.4.4",
13
- "query-string": "^9.3.1",
14
- "effect-app": "4.0.0-beta.29"
12
+ "query-string": "^9.4.0",
13
+ "effect-app": "4.0.0-beta.291"
15
14
  },
16
15
  "peerDependencies": {
17
- "@effect/atom-vue": "^4.0.0-beta.37",
18
- "@effect/platform-browser": "^4.0.0-beta.37",
19
- "@sentry/browser": "^10.42.0",
20
- "effect": "^4.0.0-beta.37",
21
- "vue": "^3.5.29"
16
+ "@effect/atom-vue": "^4.0.0-beta.90",
17
+ "@effect/platform-browser": "^4.0.0-beta.90",
18
+ "@sentry/browser": "^10.55.0",
19
+ "effect": "^4.0.0-beta.90",
20
+ "vue": "^3.5.35"
22
21
  },
23
22
  "devDependencies": {
24
- "@effect/vitest": "^4.0.0-beta.37",
25
- "@formatjs/icu-messageformat-parser": "^3.5.1",
26
- "@types/node": "25.3.3",
27
- "@vitejs/plugin-vue": "^6.0.4",
28
- "intl-messageformat": "^11.1.2",
29
- "json5": "^2.2.3",
30
- "typescript": "~5.9.3",
31
- "vite": "^7.3.1",
32
- "vitest": "^4.0.18",
33
- "@effect-app/eslint-shared-config": "0.5.7-beta.2"
23
+ "@effect/vitest": "4.0.0-beta.90",
24
+ "@formatjs/icu-messageformat-parser": "^3.5.10",
25
+ "@types/node": "25.9.1",
26
+ "@vitejs/plugin-vue": "^6.0.7",
27
+ "intl-messageformat": "^11.2.7",
28
+ "typescript": "~6.0.3",
29
+ "vite": "^8.0.15",
30
+ "vitest": "^4.1.7"
34
31
  },
35
32
  "typesVersions": {
36
33
  "*": {
@@ -44,69 +41,10 @@
44
41
  "types": "./dist/index.d.ts",
45
42
  "default": "./dist/index.js"
46
43
  },
47
- "./errorReporter": {
48
- "types": "./dist/errorReporter.d.ts",
49
- "default": "./dist/errorReporter.js"
50
- },
51
- "./experimental/commander": {
52
- "types": "./dist/experimental/commander.d.ts",
53
- "default": "./dist/experimental/commander.js"
54
- },
55
- "./experimental/confirm": {
56
- "types": "./dist/experimental/confirm.d.ts",
57
- "default": "./dist/experimental/confirm.js"
58
- },
59
- "./experimental/intl": {
60
- "types": "./dist/experimental/intl.d.ts",
61
- "default": "./dist/experimental/intl.js"
62
- },
63
- "./experimental/makeUseCommand": {
64
- "types": "./dist/experimental/makeUseCommand.d.ts",
65
- "default": "./dist/experimental/makeUseCommand.js"
66
- },
67
- "./experimental/toast": {
68
- "types": "./dist/experimental/toast.d.ts",
69
- "default": "./dist/experimental/toast.js"
70
- },
71
- "./experimental/withToast": {
72
- "types": "./dist/experimental/withToast.d.ts",
73
- "default": "./dist/experimental/withToast.js"
74
- },
75
- "./form": {
76
- "types": "./dist/form.d.ts",
77
- "default": "./dist/form.js"
78
- },
79
- "./lib": {
80
- "types": "./dist/lib.d.ts",
81
- "default": "./dist/lib.js"
82
- },
83
- "./makeClient": {
84
- "types": "./dist/makeClient.d.ts",
85
- "default": "./dist/makeClient.js"
86
- },
87
- "./makeContext": {
88
- "types": "./dist/makeContext.d.ts",
89
- "default": "./dist/makeContext.js"
90
- },
91
- "./makeIntl": {
92
- "types": "./dist/makeIntl.d.ts",
93
- "default": "./dist/makeIntl.js"
94
- },
95
- "./mutate": {
96
- "types": "./dist/mutate.d.ts",
97
- "default": "./dist/mutate.js"
98
- },
99
- "./query": {
100
- "types": "./dist/query.d.ts",
101
- "default": "./dist/query.js"
102
- },
103
- "./routeParams": {
104
- "types": "./dist/routeParams.d.ts",
105
- "default": "./dist/routeParams.js"
106
- },
107
- "./runtime": {
108
- "types": "./dist/runtime.d.ts",
109
- "default": "./dist/runtime.js"
44
+ "./internal/*": null,
45
+ "./*": {
46
+ "types": "./dist/*.d.ts",
47
+ "default": "./dist/*.js"
110
48
  }
111
49
  },
112
50
  "gitHead": "bd8e27eea3eff97db8739d577d67e7336c078d28",
@@ -120,24 +58,22 @@
120
58
  ],
121
59
  "scripts": {
122
60
  "watch": "pnpm build:tsc -w",
123
- "build:tsc": "pnpm clean-dist && effect-app-cli packagejson pnpm check",
124
- "check": "tsc --build",
61
+ "build:tsc": "pnpm clean-dist && pnpm check && node ../../scripts/rewrite-dist-declaration-imports.mjs dist",
62
+ "check": "tsgo --build",
125
63
  "build": "pnpm build:tsc",
126
- "watch2": "pnpm clean-dist && NODE_OPTIONS=--max-old-space-size=6144 tsc -w",
64
+ "watch2": "pnpm clean-dist && NODE_OPTIONS=--max-old-space-size=6144 tsgo -w",
127
65
  "clean": "rm -rf dist",
128
66
  "clean-dist": "sh ../../scripts/clean-dist.sh",
129
67
  "circular": "pnpm circular:src && pnpm circular:dist",
130
68
  "circular:src": "madge --circular --ts-config ./tsconfig.json --extensions ts ./src",
131
69
  "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",
70
+ "compile": "NODE_OPTIONS=--max-old-space-size=6144 tsgo --noEmit",
71
+ "lint": "oxlint --quiet --type-aware ./src && pnpm exec dprint check --config ../../dprint.jsonc .",
72
+ "lint-fix": "oxlint --quiet --type-aware --fix ./src && pnpm exec dprint fmt --config ../../dprint.jsonc .",
136
73
  "test": "vitest",
137
74
  "test:run": "pnpm run test run --passWithNoTests",
138
75
  "testsuite": "pnpm lint && pnpm circular && pnpm run test:run",
139
76
  "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"
77
+ "pub": "npm publish --access public"
142
78
  }
143
79
  }
@@ -0,0 +1,466 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ /**
3
+ * Shared atom core for the query/mutation engine (used by query.ts + mutate.ts).
4
+ *
5
+ * Replaces the @tanstack/vue-query engine with Effect `Atom`, keeping the public
6
+ * `.query()/.suspense()/.mutate()` contract unchanged:
7
+ * - cache identity = the atom reference (one per [handler, input], via Atom.family)
8
+ * - invalidation key = reactivity keys (= the app's existing namespace query keys)
9
+ * - SWR + focus = Atom.swr (+ windowFocusSignal)
10
+ * - gcTime = Atom.setIdleTTL / Atom.keepAlive
11
+ * - retry = Effect.retry inside the atom effect
12
+ *
13
+ * Built over the app's RPC client through the existing `RequestHandlerWithInput`
14
+ * abstraction (mirrors AtomRpc's recipe; see docs/atom-query-plan.md).
15
+ */
16
+ import { defaultRegistry } from "@effect/atom-vue"
17
+ import { DataDependencies, makeQueryKey } from "effect-app/client"
18
+ import type { ClientForOptions } from "effect-app/client/clientFor"
19
+ import { ServiceUnavailableError } from "effect-app/client/errors"
20
+ import * as Effect from "effect-app/Effect"
21
+ import * as Option from "effect-app/Option"
22
+ import * as S from "effect-app/Schema"
23
+ import * as Cause from "effect/Cause"
24
+ import * as Duration from "effect/Duration"
25
+ import * as Equal from "effect/Equal"
26
+ import * as Hash from "effect/Hash"
27
+ import type * as Layer from "effect/Layer"
28
+ import * as Ref from "effect/Ref"
29
+ import * as Stream from "effect/Stream"
30
+ import type * as Tracer from "effect/Tracer"
31
+ import { isHttpClientError } from "effect/unstable/http/HttpClientError"
32
+ import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"
33
+ import * as Atom from "effect/unstable/reactivity/Atom"
34
+ import * as AtomRegistry from "effect/unstable/reactivity/AtomRegistry"
35
+ import * as Reactivity from "effect/unstable/reactivity/Reactivity"
36
+ import { clearQueryReadDependencies, setQueryReadDependencies } from "./dependencyMetadata.ts"
37
+ import { reportRuntimeError } from "./lib.ts"
38
+
39
+ /** All non-empty prefixes of a key, longest last. `[a,b,c]` -> `[[a],[a,b],[a,b,c]]`. */
40
+ const prefixesOf = (key: ReadonlyArray<unknown>): ReadonlyArray<ReadonlyArray<unknown>> =>
41
+ key.map((_, i) => key.slice(0, i + 1))
42
+
43
+ const uniqueKeys = (keys: ReadonlyArray<ReadonlyArray<unknown>>): ReadonlyArray<ReadonlyArray<unknown>> => {
44
+ const out: Array<ReadonlyArray<unknown>> = []
45
+ const seen = new Set<number>()
46
+ for (const key of keys) {
47
+ const hash = Hash.hash(key)
48
+ if (seen.has(hash)) continue
49
+ seen.add(hash)
50
+ out.push(key)
51
+ }
52
+ return out
53
+ }
54
+
55
+ // --- awaitable invalidation -------------------------------------------------------------------
56
+ // keyHash -> live query atoms registered under that key. A query atom is tracked while it is alive
57
+ // in the registry (mounted OR cached within idle-ttl) and removed on GC, so invalidation reaches
58
+ // cached-but-unmounted queries too (e.g. a list you navigated away from).
59
+ const keyAtoms = new Map<number, Set<Atom.Atom<AsyncResult.AsyncResult<any, any>>>>()
60
+
61
+ const trackByKeys =
62
+ (keys: ReadonlyArray<ReadonlyArray<unknown>>) =>
63
+ <A, E>(atom: Atom.Atom<AsyncResult.AsyncResult<A, E>>): Atom.Atom<AsyncResult.AsyncResult<A, E>> =>
64
+ Atom.transform(atom, (get) => {
65
+ for (const key of keys) {
66
+ const h = Hash.hash(key)
67
+ let set = keyAtoms.get(h)
68
+ if (!set) keyAtoms.set(h, set = new Set())
69
+ set.add(atom)
70
+ get.addFinalizer(() => {
71
+ set.delete(atom)
72
+ if (set.size === 0) keyAtoms.delete(h)
73
+ })
74
+ }
75
+ return get(atom)
76
+ }, { initialValueTarget: atom })
77
+
78
+ const trackWritableByKeys =
79
+ (keys: ReadonlyArray<ReadonlyArray<unknown>>) =>
80
+ <A, E, W>(atom: Atom.Writable<AsyncResult.AsyncResult<A, E>, W>): Atom.Writable<AsyncResult.AsyncResult<A, E>, W> => {
81
+ const tracked = trackByKeys(keys)(atom)
82
+ return Atom.writable(
83
+ (get) => get(tracked),
84
+ (ctx, value) => ctx.set(atom, value),
85
+ (refresh) => refresh(tracked)
86
+ )
87
+ }
88
+
89
+ /**
90
+ * Keep a query's recorded read-dependencies registered for as long as it is alive, and drop them when
91
+ * its atom is GC'd (mirrors `trackByKeys`).
92
+ *
93
+ * `recordReads` only stores the deps on an actual fetch. A query that unmounts and later remounts from
94
+ * cache (within idle-ttl) does NOT re-run the handler, so its registry entry — cleared by the previous
95
+ * teardown's finalizer — would never be restored, and a subsequent mutation could not derive it as an
96
+ * invalidation target. Re-asserting the last-known reads on every (re)subscribe closes that gap, so the
97
+ * registry mirrors the live query even across cache-hit remounts.
98
+ */
99
+ const trackReadDependencies =
100
+ (key: ReadonlyArray<unknown>, getReads: () => DataDependencies.DataDependencies) =>
101
+ <A, E>(atom: Atom.Atom<AsyncResult.AsyncResult<A, E>>): Atom.Atom<AsyncResult.AsyncResult<A, E>> =>
102
+ Atom.transform(atom, (get) => {
103
+ const reads = getReads()
104
+ if (DataDependencies.isNonEmpty(reads)) setQueryReadDependencies(key, reads)
105
+ get.addFinalizer(() => clearQueryReadDependencies(key))
106
+ return get(atom)
107
+ }, { initialValueTarget: atom })
108
+
109
+ const atomsForKeys = (keys: ReadonlyArray<unknown>): ReadonlyArray<Atom.Atom<AsyncResult.AsyncResult<any, any>>> => {
110
+ const atoms = new Set<Atom.Atom<AsyncResult.AsyncResult<any, any>>>()
111
+ for (const key of keys) {
112
+ const set = keyAtoms.get(Hash.hash(key))
113
+ if (set) { for (const a of set) atoms.add(a) }
114
+ }
115
+ return [...atoms]
116
+ }
117
+
118
+ /**
119
+ * Invalidate the given keys and AWAIT the result. The invalidation (refetch trigger) goes through
120
+ * the built-in `Reactivity` service — the same one query atoms register against via
121
+ * `factory.withReactivity`, shared via the runtime memoMap. The await uses our own `keyAtoms`
122
+ * tracking + `awaitAtomResult`, since `Reactivity.invalidate` returns void and can't be awaited.
123
+ *
124
+ * Resolves once the affected queries have settled, so a mutation can `yield*` this and know the
125
+ * affected queries are fresh. (The await reads via the module-global default registry — the one the
126
+ * vue composables resolve via `injectRegistry`'s fallback.)
127
+ */
128
+ export const invalidateAndAwait = (keys: ReadonlyArray<unknown>): Effect.Effect<void, never, Reactivity.Reactivity> =>
129
+ Effect.gen(function*() {
130
+ const atoms = atomsForKeys(keys)
131
+ yield* Effect.forEach(atoms, captureAtomQueryParentSpan, { discard: true, concurrency: "inherit" })
132
+ yield* Reactivity.invalidate(keys) // invalidates everything but only refreshes what's mounted
133
+ // for (const a of atoms) defaultRegistry.refresh(a) // refreshes everything even when not mounted
134
+ if (atoms.length === 0) return
135
+ yield* Effect.forEach(atoms, (a) => awaitAtomResult(defaultRegistry, a).pipe(Effect.exit))
136
+ })
137
+
138
+ const isPlainObject = (o: unknown): o is Record<string, unknown> => {
139
+ if (typeof o !== "object" || o === null) return false
140
+ const proto = Object.getPrototypeOf(o)
141
+ return proto === Object.prototype || proto === null
142
+ }
143
+
144
+ /**
145
+ * Structural sharing (tanstack `replaceEqualDeep`): walk `next` against `prev` and reuse `prev`'s
146
+ * reference for any unchanged array/object sub-tree, so unchanged data keeps referential identity
147
+ * (Vue skips re-rendering it). Leaves — including decoded Schema CLASS INSTANCES — are compared with
148
+ * Effect `Equal.equals` (structural), which reuses equal instances that tanstack's `===` could not.
149
+ */
150
+ export const replaceEqualDeep = (prev: any, next: any): any => {
151
+ if (prev === next) return prev
152
+ const bothArrays = Array.isArray(prev) && Array.isArray(next)
153
+ if (bothArrays || (isPlainObject(prev) && isPlainObject(next))) {
154
+ const a: Record<PropertyKey, any> = prev
155
+ const b: Record<PropertyKey, any> = next
156
+ const copy: Record<PropertyKey, any> = bothArrays ? [] : {}
157
+ const nextKeys: Array<PropertyKey> = bothArrays ? (b as Array<any>).map((_, i) => i) : Object.keys(b)
158
+ const nextSize = nextKeys.length
159
+ const prevSize = bothArrays ? (a as Array<any>).length : Object.keys(a).length
160
+ let equalItems = 0
161
+ for (let i = 0; i < nextSize; i++) {
162
+ const key = nextKeys[i]!
163
+ copy[key] = replaceEqualDeep(a[key], b[key])
164
+ if (copy[key] === a[key] && a[key] !== undefined) equalItems++
165
+ }
166
+ return prevSize === nextSize && equalItems === prevSize ? prev : copy
167
+ }
168
+ return Equal.equals(prev, next) ? prev : next
169
+ }
170
+
171
+ /** Atom combinator: share each new `Success` value structurally against the previous one. */
172
+ const structuralShare = <A, E>(
173
+ self: Atom.Atom<AsyncResult.AsyncResult<A, E>>
174
+ ): Atom.Atom<AsyncResult.AsyncResult<A, E>> =>
175
+ Atom.transform(self, (get) => {
176
+ const next = get(self)
177
+ if (next._tag !== "Success") return next
178
+ const prev = Option.flatMap(get.self<AsyncResult.AsyncResult<A, E>>(), AsyncResult.value)
179
+ if (Option.isNone(prev)) return next
180
+ const shared = replaceEqualDeep(prev.value, next.value)
181
+ return shared === next.value
182
+ ? next
183
+ : AsyncResult.success(shared, { waiting: next.waiting, timestamp: next.timestamp })
184
+ }, { initialValueTarget: self })
185
+
186
+ export interface AtomClientRuntime {
187
+ readonly runtime: Atom.AtomRuntime<any, never>
188
+ readonly factory: Atom.RuntimeFactory
189
+ }
190
+
191
+ /**
192
+ * Build one AtomRuntime (and its factory) from an already-built app context.
193
+ * Shares the ManagedRuntime's `memoMap` so layers are not built twice, and so
194
+ * query-registration and mutation-invalidation resolve the SAME `Reactivity`.
195
+ */
196
+ export const makeAtomClientRuntime = <R>(
197
+ getContext: () => Layer.Layer<R, never, never>,
198
+ memoMap: Layer.MemoMap
199
+ ): AtomClientRuntime => {
200
+ const factory = Atom.context({ memoMap })
201
+ const runtime = factory((_get) => getContext())
202
+ return { runtime, factory }
203
+ }
204
+
205
+ const isRetryable = (e: unknown): boolean =>
206
+ isHttpClientError(e)
207
+ || S.is(ServiceUnavailableError)(e)
208
+ || (typeof e === "object" && e !== null && (e as any)._tag === "RpcClientError")
209
+
210
+ export interface AtomQueryOptions {
211
+ /** background-refresh threshold (TanStack staleTime; default 5s) */
212
+ readonly staleTime?: Duration.Input
213
+ /** dispose-when-idle (TanStack gcTime; default 5min). "infinity" => keepAlive */
214
+ readonly gcTime?: Duration.Input | "infinity"
215
+ /**
216
+ * Revalidate a stale query on window focus AND on network reconnect (default on, matching
217
+ * tanstack refetchOnWindowFocus + refetchOnReconnect).
218
+ */
219
+ readonly revalidateOnFocus?: boolean
220
+ /**
221
+ * Reuse references of unchanged sub-trees across refetches (default on, matching tanstack
222
+ * structuralSharing). Uses Effect `Equal` so decoded Schema instances share too — more effective
223
+ * than tanstack's `===`, but a deep compare per refetch (O(rows·fields)). Set `false` for very
224
+ * large or mostly-changing result sets where the compare costs more than the saved re-renders.
225
+ */
226
+ readonly structuralSharing?: boolean
227
+ /** poll: re-fetch every N ms (tanstack refetchInterval). */
228
+ readonly refetchInterval?: number
229
+ }
230
+
231
+ const defaults = { staleTime: Duration.seconds(5), gcTime: Duration.minutes(5) }
232
+
233
+ export interface AtomQueryMetadata {
234
+ readonly staleTimeMs: number
235
+ }
236
+
237
+ const atomQueryMetadata = new WeakMap<Atom.Atom<AsyncResult.AsyncResult<any, any>>, AtomQueryMetadata>()
238
+ const atomQueryParentSpans = new WeakMap<Atom.Atom<AsyncResult.AsyncResult<any, any>>, Tracer.AnySpan>()
239
+
240
+ const atomSpanTarget = <A, E>(atom: Atom.Atom<AsyncResult.AsyncResult<A, E>>) => {
241
+ let target = atom
242
+ while (target.initialValueTarget !== undefined) {
243
+ target = target.initialValueTarget
244
+ }
245
+ return target
246
+ }
247
+
248
+ const takeAtomQueryParentSpan = <A, E>(atom: Atom.Atom<AsyncResult.AsyncResult<A, E>>) => {
249
+ const target = atomSpanTarget(atom)
250
+ const span = atomQueryParentSpans.get(target)
251
+ if (span !== undefined) atomQueryParentSpans.delete(target)
252
+ return span
253
+ }
254
+
255
+ const setAtomQueryParentSpan = <A, E>(
256
+ atom: Atom.Atom<AsyncResult.AsyncResult<A, E>>,
257
+ span: Tracer.AnySpan
258
+ ) => {
259
+ atomQueryParentSpans.set(atomSpanTarget(atom), span)
260
+ }
261
+
262
+ export const captureAtomQueryParentSpan = <A, E>(
263
+ atom: Atom.Atom<AsyncResult.AsyncResult<A, E>>
264
+ ): Effect.Effect<void> =>
265
+ Effect.currentParentSpan.pipe(
266
+ Effect.tap((span) => Effect.sync(() => setAtomQueryParentSpan(atom, span))),
267
+ Effect.ignore
268
+ )
269
+
270
+ export const refreshAtomWithCurrentSpan = <A, E>(
271
+ registry: AtomRegistry.AtomRegistry,
272
+ atom: Atom.Atom<AsyncResult.AsyncResult<A, E>>
273
+ ): Effect.Effect<void> =>
274
+ captureAtomQueryParentSpan(atom).pipe(
275
+ Effect.andThen(Effect.sync(() => registry.refresh(atom)))
276
+ )
277
+
278
+ const setAtomQueryMetadata = <A, E>(
279
+ atom: Atom.Atom<AsyncResult.AsyncResult<A, E>>,
280
+ opts: AtomQueryOptions = {}
281
+ ) => {
282
+ const staleTimeMs = staleTimeMsOf(opts)
283
+ const previous = atomQueryMetadata.get(atom)
284
+ atomQueryMetadata.set(atom, {
285
+ staleTimeMs: previous === undefined ? staleTimeMs : Math.min(previous.staleTimeMs, staleTimeMs)
286
+ })
287
+ return atom
288
+ }
289
+
290
+ export const getAtomQueryMetadata = <A, E>(
291
+ atom: Atom.Atom<AsyncResult.AsyncResult<A, E>>
292
+ ): AtomQueryMetadata | undefined => atomQueryMetadata.get(atom)
293
+
294
+ /** Exported so the vue hook can do refetch-on-mount-per-observer with the same rule as swr. */
295
+ export const isStaleResult = (r: AsyncResult.AsyncResult<any, any>, staleTimeMs: number): boolean => {
296
+ if (r.waiting) return false
297
+ const ts = r._tag === "Success"
298
+ ? r.timestamp
299
+ : r._tag === "Failure"
300
+ ? Option.getOrUndefined(Option.map(r.previousSuccess, (s) => s.timestamp))
301
+ : undefined
302
+ if (ts === undefined) return r._tag !== "Initial"
303
+ return Date.now() - ts >= staleTimeMs
304
+ }
305
+
306
+ export const staleTimeMsOf = (opts: AtomQueryOptions): number =>
307
+ Duration.toMillis(Duration.fromInputUnsafe(opts.staleTime ?? defaults.staleTime))
308
+
309
+ export const withQueryOptions = <A, E>(
310
+ self: Atom.Atom<AsyncResult.AsyncResult<A, E>>,
311
+ opts: AtomQueryOptions = {}
312
+ ): Atom.Atom<AsyncResult.AsyncResult<A, E>> => {
313
+ setAtomQueryMetadata(self, opts)
314
+ const staleTime: Duration.Input = opts.staleTime ?? defaults.staleTime
315
+ let atom = self
316
+ const revalidateOnFocus = opts.revalidateOnFocus ?? true
317
+ atom = Atom.swr({
318
+ staleTime,
319
+ revalidateOnFocus,
320
+ focusSignal: revalidateOnFocus ? focusOrReconnectSignal : undefined
321
+ })(atom)
322
+ if (opts.refetchInterval) atom = Atom.withRefresh(Duration.millis(opts.refetchInterval))(atom)
323
+ if (opts.structuralSharing ?? true) atom = structuralShare(atom)
324
+ return atom
325
+ }
326
+
327
+ /** Constant atom for disabled / `mode:"optional"`-None queries: stays Initial, never fetches. */
328
+ export const disabledQueryAtom: Atom.Atom<AsyncResult.AsyncResult<any, any>> = Atom.readable(() =>
329
+ AsyncResult.initial(false)
330
+ )
331
+
332
+ /**
333
+ * Bumps when the browser regains connectivity (the `online` event) — the tanstack
334
+ * `refetchOnReconnect` trigger. One shared listener (module-level). SSR-guarded.
335
+ */
336
+ const onlineSignal: Atom.Atom<number> = Atom.readable((get) => {
337
+ let count = 0
338
+ if (typeof window === "undefined") return count
339
+ const update = () => {
340
+ if (navigator.onLine) get.setSelf(++count)
341
+ }
342
+ window.addEventListener("online", update)
343
+ get.addFinalizer(() => window.removeEventListener("online", update))
344
+ return count
345
+ })
346
+
347
+ /**
348
+ * Focus OR reconnect, as a single signal for `swr` — both should stale-revalidate a query.
349
+ * swr takes one `focusSignal`, so we fold window-focus + reconnect into one derived atom;
350
+ * a bump from either triggers swr's stale check.
351
+ */
352
+ const focusOrReconnectSignal: Atom.Atom<number> = Atom.make((get) => get(Atom.windowFocusSignal) + get(onlineSignal))
353
+
354
+ /**
355
+ * Build the per-input atom family for a request handler — the query CACHE IDENTITY.
356
+ *
357
+ * This is the TanStack `queryKey = [handler, input]` equivalent and the piece that makes
358
+ * caching cross-component: `Atom.family` memoizes one atom per structurally-distinct input
359
+ * (v4 hashes the input via Hash/Equal), so every component querying the same handler+input
360
+ * reads the SAME atom instance => one fetch, one shared result in the global registry,
361
+ * ref-counted and GC'd on idle ttl. (The registry + ttl give lifetime; reactivity keys give
362
+ * invalidation; the family gives identity/sharing — all three are needed.)
363
+ *
364
+ * The family is created once per handler (see query.ts's per-handler cache), so it is shared
365
+ * process-wide via the registry.
366
+ *
367
+ * Invalidation is hierarchical: each atom registers under EVERY prefix of its full key
368
+ * `[...makeQueryKey(self), input]`. Since reactivity matches keys by exact hash, registering
369
+ * all prefixes means `invalidate(P)` refreshes every atom whose key starts with `P` — e.g.
370
+ * `["$X"]` refreshes all inputs, `["$X","$List",input]` only that input. (`makeQueryKey`'s
371
+ * collapsed form `getQueryKey` — what mutations invalidate by default — is one of the prefixes.)
372
+ */
373
+ export const buildQueryFamily = <I, A, E>(
374
+ rt: AtomClientRuntime,
375
+ self: {
376
+ readonly id: string
377
+ readonly handler: (i: I) => Effect.Effect<A, E, any>
378
+ readonly options?: ClientForOptions
379
+ readonly queryKeyProjectionHash?: string
380
+ }
381
+ ) => {
382
+ const baseKey = makeQueryKey(self) // hierarchical, input-independent
383
+
384
+ return Atom.family((input: I) => {
385
+ const fullKey = [...baseKey, input]
386
+ // Record the repository/server read-dependencies seen while fetching, keyed by `fullKey`, so a
387
+ // later mutation whose writes intersect them can derive this query as an invalidation target.
388
+ // The last recorded reads are retained on the (memoized) family atom so `trackReadDependencies`
389
+ // can re-assert them on a cache-hit remount that never re-runs the handler.
390
+ let lastReads: DataDependencies.DataDependencies = DataDependencies.empty()
391
+ let atom: Atom.Atom<AsyncResult.AsyncResult<A, E>>
392
+ atom = rt.runtime.atom(
393
+ Effect.suspend(() => {
394
+ const recordReads = Effect.gen(function*() {
395
+ const readsRef = yield* Ref.make(DataDependencies.empty())
396
+ const writesRef = yield* Ref.make(DataDependencies.empty())
397
+ const recorder = DataDependencies.makeDataDependencyRecorder(readsRef, writesRef)
398
+ const result = yield* self
399
+ .handler(input)
400
+ .pipe(Effect.provideService(DataDependencies.DataDependencyRecorder, recorder))
401
+ lastReads = yield* Ref.get(readsRef)
402
+ setQueryReadDependencies(fullKey, lastReads)
403
+ return result
404
+ })
405
+ const effect = recordReads.pipe(
406
+ Effect.retry({ times: 5, while: isRetryable }),
407
+ Effect.tapCauseIf(Cause.hasDies, (cause) => reportRuntimeError(cause)),
408
+ Effect.withSpan(`query ${self.id}`, {}, { captureStackTrace: false })
409
+ )
410
+ const parentSpan = takeAtomQueryParentSpan(atom)
411
+ return parentSpan === undefined
412
+ ? effect
413
+ : effect.pipe(Effect.withParentSpan(parentSpan, { captureStackTrace: false }))
414
+ })
415
+ )
416
+ // Register under every prefix of the full key => hierarchical (prefix) invalidation. Two roles:
417
+ // - withReactivity: `Reactivity.invalidate(key)` refreshes this atom (the actual refetch).
418
+ // - trackByKeys: records the atom in `keyAtoms` so the mutation can AWAIT its settle.
419
+ const projectedFullKey = self.queryKeyProjectionHash === undefined
420
+ ? fullKey
421
+ : [...baseKey, self.queryKeyProjectionHash, input]
422
+ const reactivityKeys = uniqueKeys([...prefixesOf(fullKey), ...prefixesOf(projectedFullKey)])
423
+ atom = rt.factory.withReactivity(reactivityKeys)(atom)
424
+ atom = trackByKeys(reactivityKeys)(atom)
425
+ atom = trackReadDependencies(fullKey, () => lastReads)(atom)
426
+ // gcTime LAST so the whole chain (incl. the registration + tracking) stays alive through the
427
+ // idle window, letting invalidation reach a cached-but-unmounted query.
428
+ atom = Atom.setIdleTTL(atom, defaults.gcTime)
429
+ return setAtomQueryMetadata(Atom.withLabel(`query:${self.id}`)(atom))
430
+ })
431
+ }
432
+
433
+ export const buildStreamQueryFamily = <I, A, E>(
434
+ rt: AtomClientRuntime,
435
+ self: {
436
+ readonly id: string
437
+ readonly handler: (i: I) => Stream.Stream<A, E, any>
438
+ readonly options?: ClientForOptions
439
+ readonly queryKeyProjectionHash?: string
440
+ }
441
+ ) => {
442
+ const baseKey = makeQueryKey(self)
443
+
444
+ return Atom.family((input: I) => {
445
+ let atom = rt.runtime.pull(
446
+ self.handler(input).pipe(
447
+ Stream.tapCause((cause) => Cause.hasDies(cause) ? reportRuntimeError(cause) : Effect.void)
448
+ )
449
+ )
450
+ const fullKey = [...baseKey, input]
451
+ const projectedFullKey = self.queryKeyProjectionHash === undefined
452
+ ? fullKey
453
+ : [...baseKey, self.queryKeyProjectionHash, input]
454
+ const reactivityKeys = uniqueKeys([...prefixesOf(fullKey), ...prefixesOf(projectedFullKey)])
455
+ atom = rt.factory.withReactivity(reactivityKeys)(atom)
456
+ atom = trackWritableByKeys(reactivityKeys)(atom)
457
+ atom = Atom.setIdleTTL(atom, defaults.gcTime)
458
+ return setAtomQueryMetadata(Atom.withLabel(`stream-query:${self.id}`)(atom))
459
+ })
460
+ }
461
+
462
+ /** Await the first resolved (non-Waiting) result of an atom. Failing query results fail the Effect. */
463
+ export const awaitAtomResult = <A, E>(
464
+ registry: AtomRegistry.AtomRegistry,
465
+ atom: Atom.Atom<AsyncResult.AsyncResult<A, E>>
466
+ ) => AtomRegistry.getResult(registry, atom, { suspendOnWaiting: true })