1/**
2 * Saves and restores serializable atom state.
3 *
4 * `dehydrate` reads atoms marked with `Atom.serializable` from an
5 * `AtomRegistry` and returns encoded entries keyed by their serialization keys.
6 * `hydrate` preloads those entries into another registry before the atoms are
7 * read. Initial `AsyncResult` values can be ignored, encoded as values, or
8 * represented by promises that update the target registry once the result is no
9 * longer initial.
10 *
11 * @stability unstable
12 * @since 4.0.0
13 */
14import * as Schema from "../Schema.ts"
15import * as AsyncResult from "./AsyncResult.ts"
16import * as Atom from "./Atom.ts"
17import type * as AtomRegistry from "./AtomRegistry.ts"
18
19/**
20 * Marker interface for entries in a dehydrated atom registry state.
21 *
22 * @stability unstable
23 * @category models
24 * @since 4.0.0
25 */
26export interface DehydratedAtom {
27 readonly "~effect/reactivity/Hydration/DehydratedAtom": true
28}
29
30/**
31 * A dehydrated serializable atom value.
32 *
33 * **Details**
34 *
35 * It stores the atom serialization key, encoded value, dehydration timestamp, and
36 * an optional promise used when an `AsyncResult.Initial` value is encoded as a
37 * future non-initial value.
38 *
39 * @stability unstable
40 * @category models
41 * @since 4.0.0
42 */
43export interface DehydratedAtomValue extends DehydratedAtom {
44 readonly key: string
45 readonly value: unknown
46 readonly dehydratedAt: number
47 readonly resultPromise?: Promise<unknown> | undefined
48}
49
50const Skipped = Symbol.for("effect/reactivity/Hydration/Skipped")
51
52const encodeOrSkip = (
53 atom: Atom.Atom<any> & Atom.Serializable<any>,
54 value: unknown
55): unknown => {
56 try {
57 return atom[Atom.SerializableTypeId].encode(value)
58 } catch (error) {
59 if (Schema.isSchemaError(error)) return Skipped
60 throw error
61 }
62}
63
64/**
65 * Encodes the serializable atoms currently stored in a registry into dehydrated
66 * state.
67 *
68 * **Details**
69 *
70 * Only atoms marked with `Atom.serializable` whose current values can be encoded
71 * are included. Atoms that fail schema encoding are skipped without throwing.
72 * `encodeInitialAs` controls whether `AsyncResult.Initial` values are ignored,
73 * encoded as values, or represented by promises that resolve when the atom
74 * leaves the initial state.
75 *
76 * @stability unstable
77 * @category dehydration
78 * @since 4.0.0
79 */
80export const dehydrate = (
81 registry: AtomRegistry.AtomRegistry,
82 options?: {
83 /**
84 * How to encode `AsyncResult.Initial` values. Default is "ignore".
85 */
86 readonly encodeInitialAs?: "ignore" | "promise" | "value-only" | undefined
87 }
88): Array<DehydratedAtom> => {
89 const encodeInitialResultMode = options?.encodeInitialAs ?? "ignore"
90 const arr: Array<DehydratedAtomValue> = []
91 const now = Date.now()
92 registry.getNodes().forEach((node, key) => {
93 if (!Atom.isSerializable(node.atom)) return
94 const atom = node.atom
95 const value = node.value()
96 const isInitial = AsyncResult.isAsyncResult(value) && AsyncResult.isInitial(value)
97 if (encodeInitialResultMode === "ignore" && isInitial) return
98 const encodedValue = encodeOrSkip(atom, value)
99 if (encodedValue === Skipped) return
100
101 // Create a promise that resolves when the atom moves out of Initial state
102 let resultPromise: Promise<unknown> | undefined
103 if (encodeInitialResultMode === "promise" && isInitial) {
104 resultPromise = new Promise((resolve) => {
105 const unsubscribe = registry.subscribe(atom, (newValue) => {
106 if (AsyncResult.isAsyncResult(newValue) && !AsyncResult.isInitial(newValue)) {
107 unsubscribe()
108 resolve(encodeOrSkip(atom, newValue))
109 }
110 })
111 })
112 }
113
114 arr.push({
115 "~effect/reactivity/Hydration/DehydratedAtom": true,
116 key: key as string,
117 value: encodedValue,
118 dehydratedAt: now,
119 resultPromise
120 })
121 })
122 return arr as any
123}
124
125/**
126 * Returns dehydrated state entries as `DehydratedAtomValue` records.
127 *
128 * @stability unstable
129 * @category converting
130 * @since 4.0.0
131 */
132export const toValues = (state: ReadonlyArray<DehydratedAtom>): Array<DehydratedAtomValue> => state as any
133
134/**
135 * Applies dehydrated atom state to a registry.
136 *
137 * **When to use**
138 *
139 * Use to preload serialized atom values into a target registry before those
140 * atoms are read.
141 *
142 * **Details**
143 *
144 * Encoded values are preloaded by serialization key. Entries with a
145 * `resultPromise` update the matching registry node, or preload the resolved value,
146 * when the promise resolves.
147 *
148 * @stability unstable
149 * @category hydration
150 * @since 4.0.0
151 */
152export const hydrate = (
153 registry: AtomRegistry.AtomRegistry,
154 dehydratedState: Iterable<DehydratedAtom>
155): void => {
156 for (const datom of (dehydratedState as Iterable<DehydratedAtomValue>)) {
157 registry.setSerializable(datom.key, datom.value)
158
159 // If there's a resultPromise, it means this was in Initial state when dehydrated
160 // and we should wait for it to resolve to a non-Initial state, then update the registry
161 if (!datom.resultPromise) continue
162 datom.resultPromise.then((resolvedValue) => {
163 if (resolvedValue === Skipped) return
164 // Try to update the existing node directly instead of using setSerializable
165 const nodes = registry.getNodes()
166 const node = nodes.get(datom.key)
167 if (node) {
168 // Decode the resolved value using the node's atom serializable decoder
169 const atom = node.atom as any
170 if (atom[Atom.SerializableTypeId]) {
171 const decoded = atom[Atom.SerializableTypeId].decode(resolvedValue)
172 ;(node as any).setValue(decoded)
173 }
174 } else {
175 // Fallback to setSerializable if node doesn't exist yet
176 registry.setSerializable(datom.key, resolvedValue)
177 }
178 })
179 }
180}