@typeonce/effect-machine 0.27.1 → 0.28.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.
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * @since 0.4.0
5
5
  */
6
+ import { dual } from "effect/Function";
6
7
  import * as internal from "../../internal/machine/atom.js";
7
8
  /**
8
9
  * Error returned when a machine command is issued before startup completes.
@@ -45,6 +46,8 @@ export const inspection = internal.inspection;
45
46
  * @since 0.10.0
46
47
  */
47
48
  export const childEmissions = internal.childEmissions;
49
+ const InvalidSelectorPathTypeId = "~effect/reactivity/AtomMachine/InvalidSelectorPath";
50
+ const SelectorProjectionTypeId = "~effect/reactivity/AtomMachine/SelectorProjection";
48
51
  /**
49
52
  * Selects the typed value for an active state path.
50
53
  *
@@ -79,7 +82,7 @@ export const childEmissions = internal.childEmissions;
79
82
  * @category combinators
80
83
  * @since 0.4.0
81
84
  */
82
- export const select = internal.select;
85
+ export const select = dual(2, internal.select);
83
86
  /**
84
87
  * Selects the typed logical snapshot for an active state path.
85
88
  *
@@ -90,7 +93,7 @@ export const select = internal.select;
90
93
  * @category combinators
91
94
  * @since 0.7.0
92
95
  */
93
- export const selectSnapshot = internal.selectSnapshot;
96
+ export const selectSnapshot = dual(2, internal.selectSnapshot);
94
97
  /**
95
98
  * Selects the typed value for an active state path in a directly owned child.
96
99
  *
@@ -108,7 +111,7 @@ export const selectSnapshot = internal.selectSnapshot;
108
111
  * @category combinators
109
112
  * @since 0.4.0
110
113
  */
111
- export const selectChild = internal.selectChild;
114
+ export const selectChild = dual(2, internal.selectChild);
112
115
  /**
113
116
  * Selects the typed logical snapshot for an active state path in an invoked
114
117
  * child.
@@ -120,7 +123,7 @@ export const selectChild = internal.selectChild;
120
123
  * @category combinators
121
124
  * @since 0.7.0
122
125
  */
123
- export const selectSnapshotChild = internal.selectSnapshotChild;
126
+ export const selectSnapshotChild = dual(2, internal.selectSnapshotChild);
124
127
  /**
125
128
  * Returns whether a state path is active.
126
129
  *
@@ -153,7 +156,7 @@ export const selectSnapshotChild = internal.selectSnapshotChild;
153
156
  * @category combinators
154
157
  * @since 0.4.0
155
158
  */
156
- export const matches = internal.matches;
159
+ export const matches = dual(2, internal.matches);
157
160
  /**
158
161
  * Returns whether a state path is active in a directly owned child.
159
162
  *
@@ -164,8 +167,63 @@ export const matches = internal.matches;
164
167
  * @category combinators
165
168
  * @since 0.4.0
166
169
  */
167
- export const matchesChild = internal.matchesChild;
170
+ export const matchesChild = dual(2, internal.matchesChild);
168
171
  const BoundRequirementsTypeId = "~effect/reactivity/AtomMachine/BoundRequirements";
172
+ const FamilyInputRequiredTypeId = "~effect/reactivity/AtomMachine/FamilyInputRequired";
173
+ /**
174
+ * Creates retained atom families for independent machine inputs.
175
+ *
176
+ * The machine input is both startup input and family identity. Each property
177
+ * in `atoms` projects one public atom family from a private machine bridge.
178
+ * Retaining any projected atom retains that bridge without keeping its
179
+ * registry runtime mounted. Keys follow Effect `Equal` and `Hash` semantics.
180
+ * The optional `label` function labels each public projected atom.
181
+ *
182
+ * **Example**
183
+ *
184
+ * ```ts
185
+ * const processAtoms = AtomMachine.family(processMachine, {
186
+ * atoms: {
187
+ * ready: AtomMachine.matches("Ready"),
188
+ * state: (machine) => machine.state,
189
+ * send: (machine) => machine.send
190
+ * }
191
+ * })
192
+ *
193
+ * const readyAtom = processAtoms.ready("effect")
194
+ * const sendAtom = processAtoms.send("effect")
195
+ * ```
196
+ *
197
+ * @category constructors
198
+ * @since 0.28.0
199
+ */
200
+ export const family = internal.family;
201
+ /**
202
+ * Creates retained atom families for keyed direct-child lookup.
203
+ *
204
+ * The `child` function maps each family key to one direct child descriptor.
205
+ * Every projected atom retains the resulting child bridge while the projected
206
+ * atom remains reachable. Keys follow Effect `Equal` and `Hash` semantics.
207
+ *
208
+ * **Example**
209
+ *
210
+ * ```ts
211
+ * const Plant = Machine.childFamily(plantMachine)
212
+ *
213
+ * const plantAtoms = AtomMachine.familyChild(parentMachineAtom, {
214
+ * child: (plantId: string) => Plant(plantId),
215
+ * atoms: {
216
+ * broken: AtomMachine.matchesChild("Broken"),
217
+ * state: (child) => child.state,
218
+ * send: (child) => child.send
219
+ * }
220
+ * })
221
+ * ```
222
+ *
223
+ * @category constructors
224
+ * @since 0.28.0
225
+ */
226
+ export const familyChild = internal.familyChild;
169
227
  /**
170
228
  * Creates atoms backed by a running machine.
171
229
  *
@@ -1 +1 @@
1
- {"version":3,"file":"AtomMachine.js","sourceRoot":"","sources":["../../../src/unstable/reactivity/AtomMachine.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAOH,OAAO,KAAK,QAAQ,MAAM,gCAAgC,CAAA;AAK1D;;;;;GAKG;AACH,OAAO,EAAE,aAAa,EAAE,MAAM,gCAAgC,CAAA;AAE9D;;;;;GAKG;AACH,OAAO,EAAE,mBAAmB,EAAE,MAAM,gCAAgC,CAAA;AAMpE,MAAM,0BAA0B,GAAG,qDAAqD,CAAA;AAoIxF;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,SAAS,GAE+C,QAAQ,CAAC,SAAS,CAAA;AAEvF;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,UAAU,GAE+D,QAAQ,CAAC,UAAU,CAAA;AAEzG;;;;;GAKG;AACH,MAAM,CAAC,MAAM,cAAc,GAMvB,QAAQ,CAAC,cAAc,CAAA;AAoJ3B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,MAAM,CAAC,MAAM,MAAM,GAUf,QAAQ,CAAC,MAAM,CAAA;AAEnB;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,cAAc,GAUvB,QAAQ,CAAC,cAAc,CAAA;AAE3B;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,CAAC,MAAM,WAAW,GASpB,QAAQ,CAAC,WAAW,CAAA;AAExB;;;;;;;;;;GAUG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAS5B,QAAQ,CAAC,mBAAmB,CAAA;AAEhC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,MAAM,CAAC,MAAM,OAAO,GAWmD,QAAQ,CAAC,OAAO,CAAA;AAEvF;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,YAAY,GAMrB,QAAQ,CAAC,YAAY,CAAA;AAEzB,MAAM,uBAAuB,GAAG,kDAAkD,CAAA;AAkHlF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,MAAM,CAAC,MAAM,IAAI,GAqDb,QAAQ,CAAC,IAAI,CAAA;AAEjB;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,MAAM,GASf,QAAQ,CAAC,MAAM,CAAA;AAEnB;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,IAAI,GAEoB,QAAQ,CAAC,IAAI,CAAA"}
1
+ {"version":3,"file":"AtomMachine.js","sourceRoot":"","sources":["../../../src/unstable/reactivity/AtomMachine.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,IAAI,EAAE,MAAM,iBAAiB,CAAA;AAMtC,OAAO,KAAK,QAAQ,MAAM,gCAAgC,CAAA;AAK1D;;;;;GAKG;AACH,OAAO,EAAE,aAAa,EAAE,MAAM,gCAAgC,CAAA;AAE9D;;;;;GAKG;AACH,OAAO,EAAE,mBAAmB,EAAE,MAAM,gCAAgC,CAAA;AAMpE,MAAM,0BAA0B,GAAG,qDAAqD,CAAA;AAoIxF;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,SAAS,GAE+C,QAAQ,CAAC,SAAS,CAAA;AAEvF;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,UAAU,GAE+D,QAAQ,CAAC,UAAU,CAAA;AAEzG;;;;;GAKG;AACH,MAAM,CAAC,MAAM,cAAc,GAMvB,QAAQ,CAAC,cAAc,CAAA;AAwJ3B,MAAM,yBAAyB,GAAG,oDAAoD,CAAA;AACtF,MAAM,wBAAwB,GAAG,mDAAmD,CAAA;AA0BpF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,MAAM,CAAC,MAAM,MAAM,GA0Cf,IAAI,CAAC,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,CAAA;AAE5B;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,cAAc,GA0CvB,IAAI,CAAC,CAAC,EAAE,QAAQ,CAAC,cAAc,CAAC,CAAA;AAEpC;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,CAAC,MAAM,WAAW,GAsCpB,IAAI,CAAC,CAAC,EAAE,QAAQ,CAAC,WAAW,CAAC,CAAA;AAEjC;;;;;;;;;;GAUG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAoC5B,IAAI,CAAC,CAAC,EAAE,QAAQ,CAAC,mBAAmB,CAAC,CAAA;AAEzC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,MAAM,CAAC,MAAM,OAAO,GAsChB,IAAI,CAAC,CAAC,EAAE,QAAQ,CAAC,OAAO,CAAC,CAAA;AAE7B;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,YAAY,GA2BrB,IAAI,CAAC,CAAC,EAAE,QAAQ,CAAC,YAAY,CAAC,CAAA;AAElC,MAAM,uBAAuB,GAAG,kDAAkD,CAAA;AAkJlF,MAAM,yBAAyB,GAAG,oDAAoD,CAAA;AAyFtF;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,MAAM,CAAC,MAAM,MAAM,GAkBgE,QAAQ,CAAC,MAAa,CAAA;AAEzG;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,MAAM,CAAC,MAAM,WAAW,GAYqC,QAAQ,CAAC,WAAkB,CAAA;AAExF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,MAAM,CAAC,MAAM,IAAI,GAqDb,QAAQ,CAAC,IAAI,CAAA;AAEjB;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,MAAM,GASf,QAAQ,CAAC,MAAM,CAAA;AAEnB;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,IAAI,GAEoB,QAAQ,CAAC,IAAI,CAAA"}
@@ -1,6 +1,6 @@
1
1
  # Effect Atom and React patterns
2
2
 
3
- This guide records the folder organization and three integration patterns
3
+ This guide records the folder organization and four integration patterns
4
4
  validated in the process app. Use the API reference for individual AtomMachine
5
5
  operations. Read the [Effect Machine agent guide](./agent-guide.md) for
6
6
  statechart modeling, transitions, services, and testing.
@@ -74,35 +74,56 @@ export const counterStateAtom = AtomMachine.select(
74
74
  Consumers read `counterStateAtom` and use `counterMachineAtom.send` directly.
75
75
  Do not add a redundant `counterSendAtom` alias.
76
76
 
77
- ## 2. A machine with startup input selected through a family
77
+ ## 2. A keyed machine with startup input
78
78
 
79
- Use the family key as machine identity. The same value can also be startup
80
- input:
79
+ `AtomMachine.family` uses the machine input as both startup input and family
80
+ key. It returns one direct atom family for each entry in `atoms`:
81
81
 
82
82
  ```ts
83
83
  import { machineAtoms } from "@/lib/atom-runtime"
84
84
  import { AtomMachine } from "@typeonce/effect-machine/reactivity"
85
- import { Atom } from "effect/unstable/reactivity"
86
85
  import { processMachine } from "./machine"
87
86
 
88
- export const processFamily = Atom.family((query: string) => {
89
- const machine = machineAtoms.make(processMachine, { query })
90
-
91
- return {
92
- detailsAtom: AtomMachine.select(machine, "process"),
93
- resultAtom: AtomMachine.select(machine, "process.Ready"),
94
- sendAtom: machine.send
95
- }
87
+ export const processAtoms = machineAtoms.family(processMachine, {
88
+ atoms: {
89
+ details: AtomMachine.select("process"),
90
+ result: AtomMachine.select("process.Ready"),
91
+ send: (machine) => machine.send
92
+ },
93
+ label: (input, name) => `process:${input.query}:${name}`
96
94
  })
97
95
  ```
98
96
 
99
- A consumer calls `processFamily(query)` and uses the returned focused atoms. If
100
- several nested components need the same scope, an optional Context can expose
101
- `ReturnType<typeof processFamily>`. The provider resolves the query once instead
102
- of drilling it through every component.
97
+ React consumes each projected family directly:
98
+
99
+ ```tsx
100
+ const input = { query }
101
+ const details = useAtomValue(processAtoms.details(input))
102
+ const send = useAtomSet(processAtoms.send(input))
103
+ ```
104
+
105
+ Each public atom retains its private machine bridge. Keeping only `details` or
106
+ only `send` is safe. The bridge still starts lazily in the registry and stops
107
+ when that registry releases or disposes it. A writable source remains writable,
108
+ and a projection keeps the source atom's equality function.
109
+
110
+ The family uses Effect `Equal` and `Hash` semantics. Equal records such as
111
+ `{ query: "effect" }` select the same family value even when reconstructed.
112
+ Keep inputs immutable because mutating a hashed key makes later lookup
113
+ unreliable. Different input values select independent machines. If a changing
114
+ value should update one running workflow, model the change as an event instead
115
+ of putting it in the machine input.
103
116
 
104
- Changing `query` selects another family member and therefore another machine.
105
- If a changing value should update the current workflow, model it as an event.
117
+ Service-free machines use the module function directly:
118
+
119
+ ```ts
120
+ export const processAtoms = AtomMachine.family(processMachine, {
121
+ atoms: {
122
+ details: AtomMachine.select("process"),
123
+ send: (machine) => machine.send
124
+ }
125
+ })
126
+ ```
106
127
 
107
128
  ## 3. Reusing one machine definition for multiple instances
108
129
 
@@ -127,8 +148,6 @@ export function makeDialogScope() {
127
148
  export type DialogScope = ReturnType<typeof makeDialogScope>
128
149
  ```
129
150
 
130
- Choose one of the following ownership forms.
131
-
132
151
  ### React-tree-owned instance
133
152
 
134
153
  ```tsx
@@ -150,56 +169,10 @@ Each provider owns one independent dialog. Descendants use a small
150
169
  `DialogScope` through props when Context is unnecessary. Do not add a wrapper
151
170
  component whose only job is forwarding the scope.
152
171
 
153
- ### Stable keyed instances shared across scattered components
154
-
155
- Use one private family to create the scope for an ID. Public selector families
156
- reach that same scope:
157
-
158
- ```ts
159
- import { Atom } from "effect/unstable/reactivity"
160
-
161
- const dialogScopeFamily = Atom.family((dialogId: string) => {
162
- const scope = makeDialogScope()
163
-
164
- return {
165
- isOpenAtom: scope.isOpenAtom.pipe(
166
- Atom.withLabel(`dialog:${dialogId}:isOpen-source`)
167
- ),
168
- openStateAtom: scope.openStateAtom.pipe(
169
- Atom.withLabel(`dialog:${dialogId}:openState-source`)
170
- ),
171
- sendAtom: scope.sendAtom.pipe(
172
- Atom.withLabel(`dialog:${dialogId}:send-source`)
173
- )
174
- }
175
- })
176
-
177
- export const dialogIsOpenFamily = Atom.family((dialogId: string) => {
178
- const scope = dialogScopeFamily(dialogId)
179
-
180
- return Atom.transform(
181
- scope.sendAtom,
182
- (get) => get(scope.isOpenAtom)
183
- ).pipe(Atom.withLabel(`dialog:${dialogId}:isOpen`))
184
- })
185
-
186
- export const dialogOpenStateFamily = Atom.family((dialogId: string) => {
187
- const scope = dialogScopeFamily(dialogId)
188
-
189
- return Atom.transform(
190
- scope.sendAtom,
191
- (get) => get(scope.openStateAtom)
192
- ).pipe(Atom.withLabel(`dialog:${dialogId}:openState`))
193
- })
194
- ```
195
-
196
- Components using the same `dialogId` share one machine. Different IDs create
197
- independent machines. The two public projections let consumers subscribe
198
- independently. Both remain writable through `Atom.transform`, so either can send
199
- the inferred dialog events.
200
-
201
- Use `dialogId` in atom labels for diagnostics. Do not pass it into
202
- `dialogMachine` as unused fake input.
172
+ For a no-input machine, use one module-level bridge or an explicitly owned
173
+ React scope. Do not add a family key that the machine does not consume. When an
174
+ ID is part of startup semantics, declare it in the machine input and use
175
+ `AtomMachine.family`.
203
176
 
204
177
  ## 4. Selecting process-owned child machines
205
178
 
@@ -211,18 +184,23 @@ const Plant = Machine.childFamily(plantMachine)
211
184
 
212
185
  export const centralMachineAtom = machineAtoms.make(centralMachine)
213
186
 
214
- export const plantScopeFamily = Atom.family((plantId: string) => {
215
- const plant = centralMachineAtom.child(Plant(plantId))
216
-
217
- return {
218
- stateAtom: plant.state,
219
- isBrokenAtom: AtomMachine.matchesChild(plant, "Broken"),
220
- sendAtom: plant.send,
221
- stopAtom: plant.stop
187
+ export const plantAtoms = AtomMachine.familyChild(centralMachineAtom, {
188
+ child: (plantId: string) => Plant(plantId),
189
+ atoms: {
190
+ state: (plant) => plant.state,
191
+ isBroken: AtomMachine.matchesChild("Broken"),
192
+ send: (plant) => plant.send,
193
+ stop: (plant) => plant.stop
222
194
  }
223
195
  })
196
+
197
+ const broken = useAtomValue(plantAtoms.isBroken(plantId))
198
+ const send = useAtomSet(plantAtoms.send(plantId))
224
199
  ```
225
200
 
201
+ `familyChild` keeps child lookup separate from root machine startup. Each
202
+ projected atom retains the child bridge returned for its key.
203
+
226
204
  `Plant(plantId)` may be reconstructed wherever the id is available. Child
227
205
  lookup and bridge reuse match by machine identity and id, not descriptor object
228
206
  identity. Before the parent spawns that child, selectors contain `Option.none`
@@ -65,27 +65,16 @@ const handlers = {
65
65
  Review check: search for `.resolve(...)` callbacks that only return an empty
66
66
  `target.from()` and remove the callback.
67
67
 
68
- ## Let `Atom.family` own keyed identity
68
+ ## Use retained families for keyed machine input
69
69
 
70
- Treat `useMemo` around an atom family lookup as a warning sign. `Atom.family`
71
- already returns the same retained object for the same key, including when
72
- separate components perform the lookup.
73
-
74
- ```tsx
75
- // Redundant and local to one component
76
- const scope = useMemo(() => processFamily(processId), [processId])
77
-
78
- // The family owns identity
79
- const scope = processFamily(processId)
80
- ```
81
-
82
- If the component constructs the atoms or machine scope directly, move that
83
- construction into a module-level family:
70
+ Effect Atom keeps a family value for an equal key while that returned value is
71
+ reachable. Current runtimes may hold family values through `WeakRef`. Retaining
72
+ one field from a composite family value does not retain the composite itself:
84
73
 
85
74
  ```ts
86
- export const processFamily = Atom.family((processId: string) => {
87
- const machine = machineAtoms.make(processMachine, { processId })
88
-
75
+ // Unsafe when consumers retain only stateAtom or sendAtom
76
+ const processScope = Atom.family((input: ProcessInput) => {
77
+ const machine = machineAtoms.make(processMachine, input)
89
78
  return {
90
79
  stateAtom: AtomMachine.select(machine, "process"),
91
80
  sendAtom: machine.send
@@ -93,17 +82,33 @@ export const processFamily = Atom.family((processId: string) => {
93
82
  })
94
83
  ```
95
84
 
96
- Use a stable domain key. A new key means a different machine instance. Send an
97
- event when a value should update the current workflow instead.
85
+ Use `AtomMachine.family` for an input-bearing machine. It returns direct atom
86
+ families whose atoms retain the private machine bridge:
87
+
88
+ ```ts
89
+ export const processAtoms = machineAtoms.family(processMachine, {
90
+ atoms: {
91
+ state: AtomMachine.select("process"),
92
+ send: (machine) => machine.send
93
+ }
94
+ })
95
+
96
+ const stateAtom = processAtoms.state(input)
97
+ const sendAtom = processAtoms.send(input)
98
+ ```
99
+
100
+ No component `useMemo` is needed. The registry retains the public atom while a
101
+ hook subscribes to it, and that atom retains the machine owner. Equal inputs
102
+ use Effect `Equal` and `Hash` semantics and select the same family value.
98
103
 
99
- `useMemo` may still be useful for unrelated expensive calculations. It should
100
- not establish atom or machine identity. For one instance owned only by a React
101
- subtree, use a lazy `useState(makeScope)` initializer as described in the React
102
- guide.
104
+ For a no-input machine, use one module-level bridge or a lazy
105
+ `useState(makeScope)` value owned by a React subtree. Do not add an unused key.
103
106
 
104
- Review check: search for `useMemo` around atom creation, family lookup, or
105
- `machineAtoms.make`. Replace component-local identity with `Atom.family`, or
106
- with an intentional component-owned scope.
107
+ Review check: search for composite `Atom.family` values that own a machine,
108
+ `useMemo` around family lookup, and component-local calls to
109
+ `machineAtoms.make`. Replace an input-bearing machine with
110
+ `AtomMachine.family`. Give a no-input instance an explicit module or React-tree
111
+ owner.
107
112
 
108
113
  ## Justify each `RegistryProvider`
109
114
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@typeonce/effect-machine",
3
- "version": "0.27.1",
3
+ "version": "0.28.0",
4
4
  "description": "Schema-first state machines and statecharts for Effect",
5
5
  "author": "Sandro Maglione",
6
6
  "repository": {
@@ -8,6 +8,7 @@ import * as Data from "effect/Data"
8
8
  import * as Effect from "effect/Effect"
9
9
  import * as Equal from "effect/Equal"
10
10
  import * as Fiber from "effect/Fiber"
11
+ import * as MutableHashMap from "effect/MutableHashMap"
11
12
  import * as Option from "effect/Option"
12
13
  import type * as Schema from "effect/Schema"
13
14
  import type * as Scope from "effect/Scope"
@@ -78,6 +79,47 @@ const preparedByMachineAtom = new WeakMap<
78
79
  Atom.Atom<AsyncResult.AsyncResult<Machine.Prepared<any, any, any, any, any, any, any>, any>>
79
80
  >()
80
81
 
82
+ type WeakFamilyEntry<Value extends object> = {
83
+ readonly ref: WeakRef<Value>
84
+ }
85
+
86
+ // This follows Atom.family, but cleanup is generation-aware. A finalizer for a
87
+ // collected value must not remove a newer value installed for the same key.
88
+ const retainedFamily = <Key, Value extends object>(
89
+ makeValue: (key: Key) => Value
90
+ ): (key: Key) => Value => {
91
+ if (typeof WeakRef === "undefined" || typeof FinalizationRegistry === "undefined") {
92
+ return Atom.family(makeValue)
93
+ }
94
+
95
+ const values = MutableHashMap.empty<Key, WeakFamilyEntry<Value>>()
96
+ const registry = new FinalizationRegistry<{
97
+ readonly key: Key
98
+ readonly entry: WeakFamilyEntry<Value>
99
+ }>(({ entry, key }) => {
100
+ const current = MutableHashMap.get(values, key)
101
+ if (Option.isSome(current) && current.value === entry) {
102
+ MutableHashMap.remove(values, key)
103
+ }
104
+ })
105
+
106
+ return (key) => {
107
+ const current = MutableHashMap.get(values, key)
108
+ if (Option.isSome(current)) {
109
+ const value = current.value.ref.deref()
110
+ if (value !== undefined) {
111
+ return value
112
+ }
113
+ }
114
+
115
+ const value = makeValue(key)
116
+ const entry = { ref: new WeakRef(value) }
117
+ MutableHashMap.set(values, key, entry)
118
+ registry.register(value, { key, entry })
119
+ return value
120
+ }
121
+ }
122
+
81
123
  const runMachineAtomEffect = <State, Event, Error, Output, Emitted, StartError, Requirements>(
82
124
  get: Atom.AtomContext,
83
125
  start: Effect.Effect<Machine.MachineRef<State, Event, Error, Output, Emitted>, StartError, Requirements>
@@ -738,6 +780,69 @@ const resumeWithRuntime = (
738
780
  return makeFromRefAtom(ref as any)
739
781
  }
740
782
 
783
+ type FamilyBridge = MachineAtom<any, never, any, any, any, any> | ChildMachineAtom<any, any>
784
+
785
+ type FamilyOptions = {
786
+ readonly atoms: Readonly<Record<string, (bridge: FamilyBridge) => Atom.Atom<any>>>
787
+ readonly label?: (key: any, atomName: string) => string | undefined
788
+ }
789
+
790
+ const retainFamilyOwner = <Source extends Atom.Atom<any>>(
791
+ owner: { readonly bridge: FamilyBridge },
792
+ source: Source,
793
+ label: string | undefined
794
+ ): Atom.WithoutSerializable<Source> => {
795
+ const retained = { owner, source }
796
+ let atom: Atom.Atom<any> = Atom.transform(
797
+ source,
798
+ (get) => get(retained.source),
799
+ { initialValueTarget: source }
800
+ ).pipe(
801
+ Atom.withEquality((value, next) => retained.source.equals(value, next))
802
+ )
803
+ if (label !== undefined) {
804
+ atom = atom.pipe(Atom.withLabel(label))
805
+ }
806
+ return atom as unknown as Atom.WithoutSerializable<Source>
807
+ }
808
+
809
+ const makeFamily = (
810
+ makeBridge: (key: any) => FamilyBridge,
811
+ options: FamilyOptions
812
+ ): Readonly<Record<string, (key: any) => Atom.Atom<any>>> => {
813
+ const owners = retainedFamily((key: any) => ({ bridge: makeBridge(key) }))
814
+ const atoms: Record<string, (key: any) => Atom.Atom<any>> = {}
815
+
816
+ for (const atomName of Object.keys(options.atoms)) {
817
+ const project = options.atoms[atomName]!
818
+ atoms[atomName] = retainedFamily((key: any) => {
819
+ const owner = owners(key)
820
+ const source = project(owner.bridge)
821
+ return retainFamilyOwner(owner, source, options.label?.(key, atomName))
822
+ })
823
+ }
824
+
825
+ return atoms
826
+ }
827
+
828
+ export const family = (
829
+ machine: Machine.Machine.Any,
830
+ options: FamilyOptions
831
+ ): Readonly<Record<string, (key: any) => Atom.Atom<any>>> =>
832
+ makeFamily(
833
+ (input) => (make as any)(machine, input),
834
+ options
835
+ )
836
+
837
+ export const familyChild = (
838
+ parent: MachineAtom<any, never, any, any, any, any> | ChildMachineAtom<any, any>,
839
+ options: FamilyOptions & { readonly child: (key: any) => Machine.ChildMachine.Any }
840
+ ): Readonly<Record<string, (key: any) => Atom.Atom<any>>> =>
841
+ makeFamily(
842
+ (key) => parent.child(options.child(key)),
843
+ options
844
+ )
845
+
741
846
  export const bind = <Services, RuntimeError>(
742
847
  runtime: Atom.AtomRuntime<Services, RuntimeError>
743
848
  ): Bound<Services, RuntimeError> => ({
@@ -749,5 +854,10 @@ export const bind = <Services, RuntimeError>(
749
854
  >["make"],
750
855
  resume:
751
856
  ((machine: Machine.Machine.Any, snapshot: Machine.Machine.Snapshot<any>) =>
752
- resumeWithRuntime(runtime, machine, snapshot)) as Bound<Services, RuntimeError>["resume"]
857
+ resumeWithRuntime(runtime, machine, snapshot)) as Bound<Services, RuntimeError>["resume"],
858
+ family: ((machine: Machine.Machine.Any, options: FamilyOptions) =>
859
+ makeFamily(
860
+ (input) => makeWithRuntime(runtime, machine, [input]),
861
+ options
862
+ )) as Bound<Services, RuntimeError>["family"]
753
863
  })