@playfast/reform 0.0.7 → 0.0.8

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 (237) hide show
  1. package/package.json +2 -2
  2. package/src/boundary/boundary.test.ts +301 -0
  3. package/src/boundary/boundary.ts +192 -0
  4. package/src/calc/asyncCalc.test.ts +556 -0
  5. package/src/calc/asyncCalc.ts +267 -0
  6. package/src/calc/asyncData.ts +96 -0
  7. package/src/calc/calc.test.ts +287 -0
  8. package/src/calc/calc.ts +120 -0
  9. package/src/calc/calcFamily.test.ts +206 -0
  10. package/src/calc/calcFamily.ts +194 -0
  11. package/src/calc/compose.test.ts +68 -0
  12. package/src/calc/compose.ts +69 -0
  13. package/src/channel/channel.ts +269 -0
  14. package/src/compose/composition.ts +112 -0
  15. package/{dist/compose/host.d.ts → src/compose/host.ts} +9 -8
  16. package/{dist/compose/props.js → src/compose/props.ts} +7 -5
  17. package/src/compose/provide.ts +70 -0
  18. package/{dist/compose/slot.d.ts → src/compose/slot.ts} +36 -19
  19. package/src/compose/ui.test.ts +62 -0
  20. package/src/compose/ui.ts +226 -0
  21. package/src/compose/ui.typecheck.ts +56 -0
  22. package/src/definition/definition.ts +76 -0
  23. package/src/event/event.test.ts +23 -0
  24. package/src/event/event.ts +65 -0
  25. package/src/event/eventGroup.ts +14 -0
  26. package/src/feature/feature.mount.test.ts +82 -0
  27. package/src/feature/feature.test.ts +60 -0
  28. package/{dist/feature/feature.d.ts → src/feature/feature.ts} +178 -92
  29. package/src/feature/feature.typecheck.ts +108 -0
  30. package/src/index.ts +176 -0
  31. package/{dist/internal/capture.d.ts → src/internal/capture.ts} +13 -11
  32. package/{dist/internal/ctx.d.ts → src/internal/ctx.ts} +8 -4
  33. package/src/internal/errors.test.ts +33 -0
  34. package/src/internal/errors.ts +145 -0
  35. package/src/internal/inspect.test.ts +28 -0
  36. package/src/internal/inspect.ts +34 -0
  37. package/src/internal/queryDriver.ts +247 -0
  38. package/src/internal/reuse.test.ts +116 -0
  39. package/src/internal/reuse.ts +73 -0
  40. package/src/internal/scheduler.ts +93 -0
  41. package/{dist/internal/seeds.d.ts → src/internal/seeds.ts} +6 -3
  42. package/src/internal/sources.ts +104 -0
  43. package/{dist/internal/stateRegistry.js → src/internal/stateRegistry.ts} +18 -14
  44. package/src/internal/store.test.ts +80 -0
  45. package/src/internal/store.ts +105 -0
  46. package/{dist/internal/track.d.ts → src/internal/track.ts} +17 -11
  47. package/src/procedure/procedure.ts +89 -0
  48. package/src/reducer/reducer.ts +137 -0
  49. package/src/remote/remoteState.test.ts +695 -0
  50. package/src/remote/remoteState.ts +571 -0
  51. package/src/remote/remoteState.typecheck.ts +195 -0
  52. package/src/runtime/bus.ts +40 -0
  53. package/src/runtime/hardening.test.ts +69 -0
  54. package/src/runtime/loop.test.ts +178 -0
  55. package/src/runtime/loop.ts +171 -0
  56. package/src/scene/scene.ts +76 -0
  57. package/src/scene/seedScene.test.ts +169 -0
  58. package/src/state/state.ts +94 -0
  59. package/src/state/stateFamily.test.ts +138 -0
  60. package/src/state/stateFamily.ts +174 -0
  61. package/src/state/stateGroup.ts +105 -0
  62. package/{dist/state/token.d.ts → src/state/token.ts} +22 -13
  63. package/src/synced/syncedStore.ts +99 -0
  64. package/{dist/ui/node.d.ts → src/ui/node.ts} +3 -3
  65. package/{dist/ui/trigger.d.ts → src/ui/trigger.ts} +1 -2
  66. package/src/wire/tree.test.ts +81 -0
  67. package/src/wire/tree.ts +129 -0
  68. package/src/wire/triggers.test.ts +76 -0
  69. package/src/wire/triggers.ts +98 -0
  70. package/dist/boundary/boundary.d.ts +0 -72
  71. package/dist/boundary/boundary.d.ts.map +0 -1
  72. package/dist/boundary/boundary.js +0 -83
  73. package/dist/boundary/boundary.js.map +0 -1
  74. package/dist/calc/asyncCalc.d.ts +0 -91
  75. package/dist/calc/asyncCalc.d.ts.map +0 -1
  76. package/dist/calc/asyncCalc.js +0 -95
  77. package/dist/calc/asyncCalc.js.map +0 -1
  78. package/dist/calc/asyncData.d.ts +0 -57
  79. package/dist/calc/asyncData.d.ts.map +0 -1
  80. package/dist/calc/asyncData.js +0 -34
  81. package/dist/calc/asyncData.js.map +0 -1
  82. package/dist/calc/calc.d.ts +0 -57
  83. package/dist/calc/calc.d.ts.map +0 -1
  84. package/dist/calc/calc.js +0 -58
  85. package/dist/calc/calc.js.map +0 -1
  86. package/dist/calc/calcFamily.d.ts +0 -57
  87. package/dist/calc/calcFamily.d.ts.map +0 -1
  88. package/dist/calc/calcFamily.js +0 -124
  89. package/dist/calc/calcFamily.js.map +0 -1
  90. package/dist/calc/compose.d.ts +0 -16
  91. package/dist/calc/compose.d.ts.map +0 -1
  92. package/dist/calc/compose.js +0 -7
  93. package/dist/calc/compose.js.map +0 -1
  94. package/dist/channel/channel.d.ts +0 -115
  95. package/dist/channel/channel.d.ts.map +0 -1
  96. package/dist/channel/channel.js +0 -138
  97. package/dist/channel/channel.js.map +0 -1
  98. package/dist/compose/composition.d.ts +0 -72
  99. package/dist/compose/composition.d.ts.map +0 -1
  100. package/dist/compose/composition.js +0 -46
  101. package/dist/compose/composition.js.map +0 -1
  102. package/dist/compose/host.d.ts.map +0 -1
  103. package/dist/compose/host.js +0 -6
  104. package/dist/compose/host.js.map +0 -1
  105. package/dist/compose/props.d.ts +0 -13
  106. package/dist/compose/props.d.ts.map +0 -1
  107. package/dist/compose/props.js.map +0 -1
  108. package/dist/compose/provide.d.ts +0 -22
  109. package/dist/compose/provide.d.ts.map +0 -1
  110. package/dist/compose/provide.js +0 -28
  111. package/dist/compose/provide.js.map +0 -1
  112. package/dist/compose/slot.d.ts.map +0 -1
  113. package/dist/compose/slot.js +0 -23
  114. package/dist/compose/slot.js.map +0 -1
  115. package/dist/compose/ui.d.ts +0 -135
  116. package/dist/compose/ui.d.ts.map +0 -1
  117. package/dist/compose/ui.js +0 -63
  118. package/dist/compose/ui.js.map +0 -1
  119. package/dist/definition/definition.d.ts +0 -33
  120. package/dist/definition/definition.d.ts.map +0 -1
  121. package/dist/definition/definition.js +0 -42
  122. package/dist/definition/definition.js.map +0 -1
  123. package/dist/event/event.d.ts +0 -33
  124. package/dist/event/event.d.ts.map +0 -1
  125. package/dist/event/event.js +0 -30
  126. package/dist/event/event.js.map +0 -1
  127. package/dist/event/eventGroup.d.ts +0 -9
  128. package/dist/event/eventGroup.d.ts.map +0 -1
  129. package/dist/event/eventGroup.js +0 -4
  130. package/dist/event/eventGroup.js.map +0 -1
  131. package/dist/feature/feature.d.ts.map +0 -1
  132. package/dist/feature/feature.js +0 -98
  133. package/dist/feature/feature.js.map +0 -1
  134. package/dist/index.d.ts +0 -53
  135. package/dist/index.d.ts.map +0 -1
  136. package/dist/index.js +0 -62
  137. package/dist/index.js.map +0 -1
  138. package/dist/internal/capture.d.ts.map +0 -1
  139. package/dist/internal/capture.js +0 -12
  140. package/dist/internal/capture.js.map +0 -1
  141. package/dist/internal/ctx.d.ts.map +0 -1
  142. package/dist/internal/ctx.js +0 -2
  143. package/dist/internal/ctx.js.map +0 -1
  144. package/dist/internal/errors.d.ts +0 -89
  145. package/dist/internal/errors.d.ts.map +0 -1
  146. package/dist/internal/errors.js +0 -102
  147. package/dist/internal/errors.js.map +0 -1
  148. package/dist/internal/inspect.d.ts +0 -17
  149. package/dist/internal/inspect.d.ts.map +0 -1
  150. package/dist/internal/inspect.js +0 -32
  151. package/dist/internal/inspect.js.map +0 -1
  152. package/dist/internal/queryDriver.d.ts +0 -65
  153. package/dist/internal/queryDriver.d.ts.map +0 -1
  154. package/dist/internal/queryDriver.js +0 -134
  155. package/dist/internal/queryDriver.js.map +0 -1
  156. package/dist/internal/reuse.d.ts +0 -10
  157. package/dist/internal/reuse.d.ts.map +0 -1
  158. package/dist/internal/reuse.js +0 -68
  159. package/dist/internal/reuse.js.map +0 -1
  160. package/dist/internal/scheduler.d.ts +0 -47
  161. package/dist/internal/scheduler.d.ts.map +0 -1
  162. package/dist/internal/scheduler.js +0 -70
  163. package/dist/internal/scheduler.js.map +0 -1
  164. package/dist/internal/seeds.d.ts.map +0 -1
  165. package/dist/internal/seeds.js +0 -17
  166. package/dist/internal/seeds.js.map +0 -1
  167. package/dist/internal/sources.d.ts +0 -39
  168. package/dist/internal/sources.d.ts.map +0 -1
  169. package/dist/internal/sources.js +0 -59
  170. package/dist/internal/sources.js.map +0 -1
  171. package/dist/internal/stateRegistry.d.ts +0 -2
  172. package/dist/internal/stateRegistry.d.ts.map +0 -1
  173. package/dist/internal/stateRegistry.js.map +0 -1
  174. package/dist/internal/store.d.ts +0 -47
  175. package/dist/internal/store.d.ts.map +0 -1
  176. package/dist/internal/store.js +0 -73
  177. package/dist/internal/store.js.map +0 -1
  178. package/dist/internal/track.d.ts.map +0 -1
  179. package/dist/internal/track.js +0 -19
  180. package/dist/internal/track.js.map +0 -1
  181. package/dist/procedure/procedure.d.ts +0 -40
  182. package/dist/procedure/procedure.d.ts.map +0 -1
  183. package/dist/procedure/procedure.js +0 -50
  184. package/dist/procedure/procedure.js.map +0 -1
  185. package/dist/reducer/reducer.d.ts +0 -44
  186. package/dist/reducer/reducer.d.ts.map +0 -1
  187. package/dist/reducer/reducer.js +0 -63
  188. package/dist/reducer/reducer.js.map +0 -1
  189. package/dist/remote/remoteState.d.ts +0 -119
  190. package/dist/remote/remoteState.d.ts.map +0 -1
  191. package/dist/remote/remoteState.js +0 -270
  192. package/dist/remote/remoteState.js.map +0 -1
  193. package/dist/runtime/bus.d.ts +0 -27
  194. package/dist/runtime/bus.d.ts.map +0 -1
  195. package/dist/runtime/bus.js +0 -21
  196. package/dist/runtime/bus.js.map +0 -1
  197. package/dist/runtime/loop.d.ts +0 -45
  198. package/dist/runtime/loop.d.ts.map +0 -1
  199. package/dist/runtime/loop.js +0 -117
  200. package/dist/runtime/loop.js.map +0 -1
  201. package/dist/scene/scene.d.ts +0 -44
  202. package/dist/scene/scene.d.ts.map +0 -1
  203. package/dist/scene/scene.js +0 -31
  204. package/dist/scene/scene.js.map +0 -1
  205. package/dist/state/state.d.ts +0 -37
  206. package/dist/state/state.d.ts.map +0 -1
  207. package/dist/state/state.js +0 -46
  208. package/dist/state/state.js.map +0 -1
  209. package/dist/state/stateFamily.d.ts +0 -79
  210. package/dist/state/stateFamily.d.ts.map +0 -1
  211. package/dist/state/stateFamily.js +0 -99
  212. package/dist/state/stateFamily.js.map +0 -1
  213. package/dist/state/stateGroup.d.ts +0 -50
  214. package/dist/state/stateGroup.d.ts.map +0 -1
  215. package/dist/state/stateGroup.js +0 -54
  216. package/dist/state/stateGroup.js.map +0 -1
  217. package/dist/state/token.d.ts.map +0 -1
  218. package/dist/state/token.js +0 -20
  219. package/dist/state/token.js.map +0 -1
  220. package/dist/synced/syncedStore.d.ts +0 -51
  221. package/dist/synced/syncedStore.d.ts.map +0 -1
  222. package/dist/synced/syncedStore.js +0 -43
  223. package/dist/synced/syncedStore.js.map +0 -1
  224. package/dist/ui/node.d.ts.map +0 -1
  225. package/dist/ui/node.js +0 -2
  226. package/dist/ui/node.js.map +0 -1
  227. package/dist/ui/trigger.d.ts.map +0 -1
  228. package/dist/ui/trigger.js +0 -2
  229. package/dist/ui/trigger.js.map +0 -1
  230. package/dist/wire/tree.d.ts +0 -66
  231. package/dist/wire/tree.d.ts.map +0 -1
  232. package/dist/wire/tree.js +0 -60
  233. package/dist/wire/tree.js.map +0 -1
  234. package/dist/wire/triggers.d.ts +0 -44
  235. package/dist/wire/triggers.d.ts.map +0 -1
  236. package/dist/wire/triggers.js +0 -31
  237. package/dist/wire/triggers.js.map +0 -1
@@ -0,0 +1,105 @@
1
+ import { Layer } from 'effect'
2
+ import { definitionClass } from '../definition/definition'
3
+ import { DuplicateRegistration, UnknownGroupState } from '../internal/errors'
4
+ // A group's members each carry a `store` tag, so the shared `StoresOf` (used for
5
+ // calc inputs too) distributes them into the union of distinct stores their
6
+ // `live` layer provides — no group-specific mapped type needed.
7
+ import { type StoresOf } from '../internal/sources'
8
+ import { type AnyState, live as stateLive, type StateClass, type StateName } from './state'
9
+ import { StateToken } from './token'
10
+
11
+ type ValueForName<Members extends ReadonlyArray<AnyState>, N extends string> =
12
+ Extract<Members[number], StateClass<N, any>> extends StateClass<any, infer A> ? A : never
13
+
14
+ export interface StateGroupClass<Members extends ReadonlyArray<AnyState>> {
15
+ new (): {}
16
+ readonly kind: 'StateGroup'
17
+ readonly members: Members
18
+ /** Members indexed by name, so `select` is an O(1) lookup instead of a scan. */
19
+ readonly byName: ReadonlyMap<string, AnyState>
20
+ }
21
+
22
+ export type AnyStateGroup = StateGroupClass<ReadonlyArray<AnyState>>
23
+
24
+ /**
25
+ * Compile-time uniqueness guard for `StateGroup.make`. Member names key the tag,
26
+ * the seed record, `byName`, and `select` — duplicates are unaddressable, so a
27
+ * repeated name is replaced (positionally) by this marker tuple, which a real
28
+ * `StateClass` is not assignable to. The offending argument fails to typecheck
29
+ * with the colliding name spelled out in the message, rather than collapsing
30
+ * silently. Runtime `make` throws the same clash for seeds the root tsconfig
31
+ * never typechecks (test files live outside it).
32
+ */
33
+ type DuplicateStateName<N extends string> = readonly ['reform: duplicate state name in group', N]
34
+
35
+ type NoDuplicateNames<
36
+ Members extends ReadonlyArray<AnyState>,
37
+ Seen extends string = never,
38
+ > = Members extends readonly [infer Head extends AnyState, ...infer Tail extends ReadonlyArray<AnyState>]
39
+ ? readonly [
40
+ StateName<Head> extends Seen ? DuplicateStateName<StateName<Head>> : Head,
41
+ ...NoDuplicateNames<Tail, Seen | StateName<Head>>,
42
+ ]
43
+ : readonly []
44
+
45
+ // The seed record `StateGroup.live` requires: one entry per member, keyed by the
46
+ // member's name and typed to that member's value (so a missing or mistyped seed
47
+ // is a compile error). Written against the group class, so call sites read
48
+ // `GroupSeeds<typeof TodosStates>`.
49
+ export type GroupSeeds<G extends AnyStateGroup> =
50
+ G extends StateGroupClass<infer Members>
51
+ ? { readonly [N in StateName<Members[number]>]: ValueForName<Members, N> }
52
+ : never
53
+
54
+ /** Compose atomic States into a group provided (and addressed) as a unit. */
55
+ export const make = <const Members extends ReadonlyArray<AnyState>>(
56
+ ...members: Members & NoDuplicateNames<Members>
57
+ ): StateGroupClass<Members> => {
58
+ // `byName` already de-dupes, so a smaller map than the member list means two
59
+ // members claimed one name — unaddressable, and a silent footgun on merge.
60
+ const names = members.map((m) => m.manifest.name)
61
+ const duplicate = names.find((name, i) => names.indexOf(name) !== i)
62
+ if (duplicate !== undefined) throw new DuplicateRegistration({ kind: 'state in group', name: duplicate })
63
+ return definitionClass<StateGroupClass<Members>>({
64
+ kind: 'StateGroup' as const,
65
+ members,
66
+ byName: new Map(members.map((m) => [m.manifest.name, m] as const)),
67
+ })
68
+ }
69
+
70
+ /**
71
+ * Address a member by its tag — `StateGroup.select(TodosStates, 'feed')` —
72
+ * yielding a `StateToken` for reads and calc inputs (still `yield*`-able).
73
+ *
74
+ * Data-first only (not `dual`): the member name's type depends on the group, and
75
+ * a group is not `Pipeable`, so a data-last form would be both more verbose and
76
+ * harder to type than the direct call. Effect reserves `dual` for operators on a
77
+ * `Pipeable` self with an independent value arg (e.g. `ref.pipe(Ref.set(v))`);
78
+ * this is not that shape.
79
+ */
80
+ export const select = <Members extends ReadonlyArray<AnyState>, N extends StateName<Members[number]>>(
81
+ group: StateGroupClass<Members>,
82
+ name: N,
83
+ ): StateToken<N, ValueForName<Members, N>> => {
84
+ const member = group.byName.get(name)
85
+ if (member === undefined) throw new UnknownGroupState({ name })
86
+ return new StateToken(name, member.store) as StateToken<N, ValueForName<Members, N>>
87
+ }
88
+
89
+ /**
90
+ * Allocate every member's store as one merged layer, each seeded from `seeds`
91
+ * keyed by member name — `StateGroup.live(BoardStates, { board: { _tag: 'Idle' } })`.
92
+ */
93
+ export const live = <Members extends ReadonlyArray<AnyState>>(
94
+ group: StateGroupClass<Members>,
95
+ seeds: GroupSeeds<StateGroupClass<Members>>,
96
+ ): Layer.Layer<StoresOf<Members>> => {
97
+ // Index the typed seed record by the member's runtime name — a genuine
98
+ // reflection boundary (string key into a mapped type). Each member's store
99
+ // layer is then merged; the union of stores is exactly `StoresOf<Members>`,
100
+ // which `reduce`'s single-layer accumulator can't express, so restate it.
101
+ const seedRecord = seeds as Record<string, unknown>
102
+ return group.members
103
+ .map((m) => stateLive(m, seedRecord[m.manifest.name]))
104
+ .reduce((a, b) => Layer.merge(a, b)) as Layer.Layer<StoresOf<Members>>
105
+ }
@@ -1,16 +1,25 @@
1
- import { Context, Effect, Effectable } from 'effect';
2
- import type { Store } from '../internal/store';
1
+ import { Context, Effect, Effectable } from 'effect'
2
+ import type { Store } from '../internal/store'
3
+ import { readTracked } from '../internal/track'
4
+
3
5
  /**
4
6
  * A reference to one slice of state: yieldable to its current value (`yield*`
5
7
  * reads + — under React — subscribes) and carrying the metadata calcs need
6
8
  * (its `name` and backing store tag). Returned by `StateGroup.select(group, name)`.
7
9
  */
8
- export declare class StateToken<out N extends string, in out A> extends Effectable.Class<A, never, Store<A>> {
9
- readonly name: N;
10
- readonly store: Context.Tag<Store<A>, Store<A>>;
11
- constructor(name: N, store: Context.Tag<Store<A>, Store<A>>);
12
- commit(): Effect.Effect<A, never, Store<A>>;
10
+ export class StateToken<out N extends string, in out A> extends Effectable.Class<A, never, Store<A>> {
11
+ readonly name: N
12
+ readonly store: Context.Tag<Store<A>, Store<A>>
13
+ constructor(name: N, store: Context.Tag<Store<A>, Store<A>>) {
14
+ super()
15
+ this.name = name
16
+ this.store = store
17
+ }
18
+ commit(): Effect.Effect<A, never, Store<A>> {
19
+ return Effect.flatMap(this.store, readTracked)
20
+ }
13
21
  }
22
+
14
23
  /**
15
24
  * Anything a derived value can read and depend on: a named slice backed by a
16
25
  * subscribable store tag. A `StateToken` (group member), a `Calc`, and an
@@ -21,10 +30,10 @@ export declare class StateToken<out N extends string, in out A> extends Effectab
21
30
  * satisfy the same shape so the three are interchangeable as calc inputs.
22
31
  */
23
32
  export interface Source<out N extends string, in out A> {
24
- readonly name: N;
25
- readonly store: Context.Tag<Store<A>, Store<A>>;
33
+ readonly name: N
34
+ readonly store: Context.Tag<Store<A>, Store<A>>
26
35
  }
27
- export type AnySource = Source<string, any>;
28
- export type SourceName<S> = S extends Source<infer N, any> ? N : never;
29
- export type SourceValue<S> = S extends Source<any, infer A> ? A : never;
30
- //# sourceMappingURL=token.d.ts.map
36
+
37
+ export type AnySource = Source<string, any>
38
+ export type SourceName<S> = S extends Source<infer N, any> ? N : never
39
+ export type SourceValue<S> = S extends Source<any, infer A> ? A : never
@@ -0,0 +1,99 @@
1
+ import { Context, Effect, Layer, type Schema, type Scope } from 'effect'
2
+ import { type Manifest, yieldableClass } from '../definition/definition'
3
+ import { resolveScheduler } from '../internal/scheduler'
4
+ import { makeDerivedStore, type Store } from '../internal/store'
5
+ import { readTracked } from '../internal/track'
6
+ import type { Source } from '../state/token'
7
+ import type { StateOptions } from '../state/state'
8
+
9
+ // `SyncedStore` is the seam between Reform's reactive `Store` and any *external*
10
+ // reactive source — a PGlite live query, a TanStack DB collection, a socket feed.
11
+ // It is the synchronous, useSyncExternalStore-shaped bridge: a source exposes a
12
+ // `snapshot()` (the current value, synchronously) and a `subscribe(onChange)`,
13
+ // which is exactly what `makeDerivedStore` consumes. Like `RemoteState`'s `truth`
14
+ // store, a `SyncedStore` is written ONLY by its upstream subscription — never by a
15
+ // user reducer — so the "state changes only in the loop (or its driver)" invariant
16
+ // holds. `@playfast/reform-db` is the first consumer: it feeds a `SyncedStore` from
17
+ // a SQL live query whose value is an `AsyncData<Rows>`.
18
+
19
+ /**
20
+ * The contract an external reactive source implements. `snapshot` must return a
21
+ * stable reference until the value actually changes (the `makeDerivedStore`
22
+ * memoization contract `useSyncExternalStore` requires); `subscribe` returns an
23
+ * unsubscribe thunk, fired on every upstream change.
24
+ */
25
+ export interface SyncedSource<A> {
26
+ readonly snapshot: () => A
27
+ readonly subscribe: (onChange: () => void) => () => void
28
+ }
29
+
30
+ export interface SyncedStoreManifest<N extends string, A> extends Manifest {
31
+ readonly kind: 'SyncedStore'
32
+ readonly name: N
33
+ readonly schema: Schema.Schema<A, any>
34
+ readonly title?: string
35
+ readonly description?: string
36
+ }
37
+
38
+ export interface SyncedStoreClass<out N extends string, in out A>
39
+ extends Effect.Effect<A, never, Store<A>>,
40
+ Source<N, A> {
41
+ new (): {}
42
+ readonly manifest: SyncedStoreManifest<N, A>
43
+ /** Internal DI tag holding the live store. `SyncedStore.live` allocates it. */
44
+ readonly store: Context.Tag<Store<A>, Store<A>>
45
+ /** The name, so the class doubles as a `Source` input to a calc/composition. */
46
+ readonly name: N
47
+ }
48
+
49
+ export type AnySyncedStore = SyncedStoreClass<string, any>
50
+ export type SyncedValue<S> = S extends SyncedStoreClass<string, infer A> ? A : never
51
+
52
+ /**
53
+ * Define an externally-synced slice: a reflectable manifest + an internal store
54
+ * tag, yieldable to its current value and usable as a calc/composition `Source`.
55
+ * Carries no source — that is supplied at wiring time by `SyncedStore.live`,
56
+ * exactly as `State.make` defers its seed to `State.live`.
57
+ */
58
+ export const make = <const N extends string, A>(
59
+ name: N,
60
+ schema: Schema.Schema<A, any>,
61
+ options: StateOptions = {},
62
+ ): SyncedStoreClass<N, A> => {
63
+ const store = Context.GenericTag<Store<A>, Store<A>>(`reform/syncedStore/${name}`)
64
+ const manifest: SyncedStoreManifest<N, A> = {
65
+ kind: 'SyncedStore',
66
+ name,
67
+ schema,
68
+ ...(options.title !== undefined ? { title: options.title } : {}),
69
+ ...(options.description !== undefined ? { description: options.description } : {}),
70
+ }
71
+ const read = Effect.flatMap(store, readTracked)
72
+ return yieldableClass(read, { manifest, store, name })
73
+ }
74
+
75
+ /**
76
+ * Allocate a synced store's backing cell and bind it to an external source.
77
+ * `acquire` yields the source within the layer's scope (so it can open a live
78
+ * query / subscription whose `R` — e.g. a `Db` driver — is captured here); the
79
+ * store mirrors `snapshot()` and re-notifies on every `subscribe` change through
80
+ * the runtime's coalescing scheduler. The upstream subscription is released with
81
+ * the layer's scope.
82
+ */
83
+ export const live = <A, R>(
84
+ // A minimal store-carrier — satisfied by a `SyncedStoreClass` and by any
85
+ // companion primitive (e.g. `@playfast/reform-db`'s `DbQuery`) that allocates
86
+ // its own `Store` tag, so the seam is reusable beyond `SyncedStore` itself.
87
+ def: { readonly store: Context.Tag<Store<A>, Store<A>> },
88
+ acquire: Effect.Effect<SyncedSource<A>, never, R>,
89
+ ): Layer.Layer<Store<A>, never, Exclude<R, Scope.Scope>> =>
90
+ Layer.scoped(
91
+ def.store,
92
+ Effect.gen(function* () {
93
+ const scheduler = yield* resolveScheduler
94
+ const source = yield* acquire
95
+ const { store, unsubscribe } = makeDerivedStore(source.snapshot, source.subscribe, scheduler)
96
+ yield* Effect.addFinalizer(() => Effect.sync(unsubscribe))
97
+ return store
98
+ }),
99
+ )
@@ -1,9 +1,9 @@
1
- import type { ReactNode } from 'react';
1
+ import type { ReactNode } from 'react'
2
+
2
3
  /**
3
4
  * What a view renders. Reform targets React, so a node is a `ReactNode` — this
4
5
  * is a *type-only* dependency: core never imports the React runtime nor renders
5
6
  * anything itself (that is `@reform/react`). Typing it precisely lets the `.ui`
6
7
  * presentations author real JSX and lets slots be valid components.
7
8
  */
8
- export type Node = ReactNode;
9
- //# sourceMappingURL=node.d.ts.map
9
+ export type Node = ReactNode
@@ -3,5 +3,4 @@
3
3
  * (High priority) through the runtime. The UI never sees Effect — it just calls
4
4
  * `events.submit({ text })`.
5
5
  */
6
- export type Trigger<P> = (payload: P) => void;
7
- //# sourceMappingURL=trigger.d.ts.map
6
+ export type Trigger<P> = (payload: P) => void
@@ -0,0 +1,81 @@
1
+ import { expect, test } from 'vitest'
2
+ import { Wire } from '../index'
3
+ import type { WireNode, WireTree } from '../index'
4
+
5
+ const node = (id: string, over: Partial<WireNode> = {}): WireNode => ({
6
+ id,
7
+ name: 'View',
8
+ parentId: null,
9
+ childIndex: 0,
10
+ slot: null,
11
+ key: null,
12
+ props: [],
13
+ ...over,
14
+ })
15
+
16
+ // `diff` / `apply` are a round trip: folding `diff(prev, next)` into `prev`
17
+ // reconstructs `next` exactly, for any pair of trees.
18
+
19
+ test('a fresh tree diffs to one upsert per node', () => {
20
+ const next: WireTree = [node('a'), node('b', { parentId: 'a', slot: 'body' })]
21
+ const patches = Wire.diff([], next)
22
+ expect(patches).toHaveLength(2)
23
+ expect(patches.every((patch) => patch._tag === 'Upsert')).toBe(true)
24
+ expect(Wire.apply([], patches)).toEqual(next)
25
+ })
26
+
27
+ test('unchanged nodes produce no patches', () => {
28
+ const tree: WireTree = [node('a'), node('b', { parentId: 'a' })]
29
+ expect(Wire.diff(tree, tree)).toHaveLength(0)
30
+ })
31
+
32
+ test('a changed prop upserts only that node', () => {
33
+ const prev: WireTree = [node('a', { props: [{ _tag: 'Data', name: 'count', value: 1 }] })]
34
+ const next: WireTree = [node('a', { props: [{ _tag: 'Data', name: 'count', value: 2 }] })]
35
+ const patches = Wire.diff(prev, next)
36
+ expect(patches).toEqual([{ _tag: 'Upsert', node: next[0] }])
37
+ expect(Wire.apply(prev, patches)).toEqual(next)
38
+ })
39
+
40
+ test('a removed node diffs to a delete', () => {
41
+ const prev: WireTree = [node('a'), node('b', { parentId: 'a' })]
42
+ const next: WireTree = [node('a')]
43
+ const patches = Wire.diff(prev, next)
44
+ expect(patches).toEqual([{ _tag: 'Delete', id: 'b' }])
45
+ expect(Wire.apply(prev, patches)).toEqual(next)
46
+ })
47
+
48
+ test('event props compare by handle, not by identity', () => {
49
+ const prev: WireTree = [node('a', { props: [{ _tag: 'Event', name: 'bump', handle: 'h1' }] })]
50
+ const same: WireTree = [node('a', { props: [{ _tag: 'Event', name: 'bump', handle: 'h1' }] })]
51
+ const changed: WireTree = [node('a', { props: [{ _tag: 'Event', name: 'bump', handle: 'h2' }] })]
52
+ expect(Wire.diff(prev, same)).toHaveLength(0)
53
+ expect(Wire.diff(prev, changed)).toHaveLength(1)
54
+ })
55
+
56
+ test('deletes precede upserts within a frame', () => {
57
+ const prev: WireTree = [node('old', { parentId: 'r', slot: 'body' }), node('r')]
58
+ const next: WireTree = [node('r'), node('new', { parentId: 'r', slot: 'body' })]
59
+ const patches = Wire.diff(prev, next)
60
+ const firstUpsert = patches.findIndex((patch) => patch._tag === 'Upsert')
61
+ const lastDelete = patches.map((patch) => patch._tag).lastIndexOf('Delete')
62
+ expect(lastDelete).toBeLessThan(firstUpsert)
63
+ })
64
+
65
+ test('roots and childrenOf order siblings by childIndex', () => {
66
+ const tree: WireTree = [
67
+ node('r1', { childIndex: 1 }),
68
+ node('r0', { childIndex: 0 }),
69
+ node('c1', { parentId: 'r0', childIndex: 1 }),
70
+ node('c0', { parentId: 'r0', childIndex: 0 }),
71
+ ]
72
+ expect(Wire.roots(tree).map((n) => n.id)).toEqual(['r0', 'r1'])
73
+ expect(Wire.childrenOf(tree, 'r0').map((n) => n.id)).toEqual(['c0', 'c1'])
74
+ })
75
+
76
+ test('nested deep prop changes are detected', () => {
77
+ const prev: WireTree = [node('a', { props: [{ _tag: 'Data', name: 'rows', value: [{ id: 1 }, { id: 2 }] }] })]
78
+ const next: WireTree = [node('a', { props: [{ _tag: 'Data', name: 'rows', value: [{ id: 1 }, { id: 3 }] }] })]
79
+ expect(Wire.diff(prev, next)).toHaveLength(1)
80
+ expect(Wire.diff(prev, prev)).toHaveLength(0)
81
+ })
@@ -0,0 +1,129 @@
1
+ import { Match } from 'effect'
2
+
3
+ /**
4
+ * The serializable model of a rendered UI tree, and the pure diff/apply over it.
5
+ *
6
+ * This is the renderer-neutral heart of the remote transport (REMOTE_UI.md §3):
7
+ * the server renders a scene to a `WireTree`, sends `diff(prev, next)` as
8
+ * `WirePatch`es, and the client folds them back with `apply`. No React, no
9
+ * Effect — pure data, so both ends and the proofs share one source of truth.
10
+ */
11
+
12
+ /**
13
+ * A prop on a wire node: either an already-encoded data value, or a handle the
14
+ * client invokes to fire the server-side trigger (resolved by the trigger
15
+ * registry, REMOTE_UI.md §4). Streams, when added, become a third arm.
16
+ */
17
+ export type WireProp =
18
+ | { readonly _tag: 'Data'; readonly name: string; readonly value: unknown }
19
+ | { readonly _tag: 'Event'; readonly name: string; readonly handle: string }
20
+
21
+ /** One rendered UI contract instance, identified stably across frames. */
22
+ export interface WireNode {
23
+ /** Stable identity across renders — the unit of diffing. */
24
+ readonly id: string
25
+ /** The UI contract name (`UiCapture.name`) the client looks up a presentation by. */
26
+ readonly name: string
27
+ /** Parent node id, or `null` for a root. */
28
+ readonly parentId: string | null
29
+ /** Order among siblings under the same parent. */
30
+ readonly childIndex: number
31
+ /** The slot name this node fills in its parent, or `null` for a root / direct child. */
32
+ readonly slot: string | null
33
+ /**
34
+ * The React `key` the parent gave this slot child (`createElement(slots.Row, { key })`),
35
+ * or `null` when none was set. It is the per-child identity a KEYED slot selects on: the
36
+ * client renders `<slots.Row slotKey={id} />` and the slot thunk picks the one wire child
37
+ * whose `key` matches, instead of rendering every child of that slot at every call site.
38
+ * Without it the client slot is render-all-children (correct for singleton slots, wrong for
39
+ * a list slot invoked once per item — it duplicates the whole list under each call).
40
+ */
41
+ readonly key: string | null
42
+ readonly props: ReadonlyArray<WireProp>
43
+ }
44
+
45
+ export type WireTree = ReadonlyArray<WireNode>
46
+
47
+ /** A change to apply to a client's tree: upsert a node, or drop one by id. */
48
+ export type WirePatch =
49
+ | { readonly _tag: 'Upsert'; readonly node: WireNode }
50
+ | { readonly _tag: 'Delete'; readonly id: string }
51
+
52
+ const arraysEqual = (a: ReadonlyArray<unknown>, b: ReadonlyArray<unknown>): boolean =>
53
+ a.length === b.length && a.every((item, index) => deepEqual(item, b[index]))
54
+
55
+ const recordsEqual = (a: Record<string, unknown>, b: Record<string, unknown>): boolean => {
56
+ const aKeys = Object.keys(a)
57
+ const bKeys = Object.keys(b)
58
+ return aKeys.length === bKeys.length && aKeys.every((key) => deepEqual(a[key], b[key]))
59
+ }
60
+
61
+ /** Structural equality over serializable wire values (and the tagged props that carry them). */
62
+ const deepEqual = (a: unknown, b: unknown): boolean => {
63
+ if (a === b) return true
64
+ if (a === null || b === null) return false
65
+ if (Array.isArray(a)) return Array.isArray(b) && arraysEqual(a, b)
66
+ if (Array.isArray(b)) return false
67
+ if (typeof a === 'object' && typeof b === 'object') {
68
+ return recordsEqual(a as Record<string, unknown>, b as Record<string, unknown>)
69
+ }
70
+ return false
71
+ }
72
+
73
+ const nodesEqual = (a: WireNode, b: WireNode): boolean =>
74
+ a.name === b.name &&
75
+ a.parentId === b.parentId &&
76
+ a.childIndex === b.childIndex &&
77
+ a.slot === b.slot &&
78
+ a.key === b.key &&
79
+ deepEqual(a.props, b.props)
80
+
81
+ const isUnchanged = (node: WireNode, previousById: ReadonlyMap<string, WireNode>): boolean => {
82
+ const previous = previousById.get(node.id)
83
+ return previous !== undefined && nodesEqual(previous, node)
84
+ }
85
+
86
+ /**
87
+ * The patches that turn `previous` into `next`. Deletes precede upserts so a
88
+ * client never holds a child whose reparented slot was freed in the same frame.
89
+ */
90
+ export const diff = (previous: WireTree, next: WireTree): ReadonlyArray<WirePatch> => {
91
+ const previousById = new Map(previous.map((node) => [node.id, node]))
92
+ const nextById = new Map(next.map((node) => [node.id, node]))
93
+
94
+ const deletes = previous
95
+ .filter((node) => !nextById.has(node.id))
96
+ .map((node): WirePatch => ({ _tag: 'Delete', id: node.id }))
97
+
98
+ const upserts = next
99
+ .filter((node) => !isUnchanged(node, previousById))
100
+ .map((node): WirePatch => ({ _tag: 'Upsert', node }))
101
+
102
+ return [...deletes, ...upserts]
103
+ }
104
+
105
+ const upsertNode = (state: WireTree, node: WireNode): WireTree => {
106
+ const index = state.findIndex((existing) => existing.id === node.id)
107
+ if (index < 0) return [...state, node]
108
+ return [...state.slice(0, index), node, ...state.slice(index + 1)]
109
+ }
110
+
111
+ /** Fold patches into a client's tree (the receiving side's reducer). */
112
+ export const apply = (state: WireTree, patches: ReadonlyArray<WirePatch>): WireTree =>
113
+ patches.reduce(
114
+ (current, patch) =>
115
+ Match.value(patch).pipe(
116
+ Match.tag('Delete', ({ id }) => current.filter((node) => node.id !== id)),
117
+ Match.tag('Upsert', ({ node }) => upsertNode(current, node)),
118
+ Match.exhaustive,
119
+ ),
120
+ state,
121
+ )
122
+
123
+ /** The root nodes of a tree, in sibling order. */
124
+ export const roots = (tree: WireTree): WireTree =>
125
+ tree.filter((node) => node.parentId === null).sort((a, b) => a.childIndex - b.childIndex)
126
+
127
+ /** The children of a node, in sibling order. */
128
+ export const childrenOf = (tree: WireTree, parentId: string): WireTree =>
129
+ tree.filter((node) => node.parentId === parentId).sort((a, b) => a.childIndex - b.childIndex)
@@ -0,0 +1,76 @@
1
+ import { expect, test } from 'vitest'
2
+ import { Effect, Exit, Schema as S } from 'effect'
3
+ import { Triggers } from '../index'
4
+ import type { Trigger } from '../index'
5
+
6
+ const ByPayload = S.Struct({ by: S.Number })
7
+
8
+ // The registry holds a live trigger behind a caller-provided handle, decodes the
9
+ // wire payload through the contract's event schema, and fires — never shipping a
10
+ // function, and validating at the seam.
11
+
12
+ test('re-registering a handle overwrites it with the fresh trigger', () => {
13
+ const seen = Effect.runSync(
14
+ Effect.gen(function* () {
15
+ const registry = yield* Triggers.make
16
+ const calls: string[] = []
17
+ yield* registry.register('0:bump', () => void calls.push('stale'), ByPayload)
18
+ yield* registry.register('0:bump', () => void calls.push('fresh'), ByPayload)
19
+ yield* registry.invoke('0:bump', { by: 1 })
20
+ return calls
21
+ }),
22
+ )
23
+ expect(seen).toEqual(['fresh'])
24
+ })
25
+
26
+ test('invoke decodes the payload and fires the trigger with the decoded value', () => {
27
+ const seen = Effect.runSync(
28
+ Effect.gen(function* () {
29
+ const registry = yield* Triggers.make
30
+ const received: Array<{ by: number }> = []
31
+ const bump: Trigger<{ by: number }> = (payload) => void received.push(payload)
32
+ yield* registry.register('0:bump', bump, ByPayload)
33
+ yield* registry.invoke('0:bump', { by: 5 })
34
+ return received
35
+ }),
36
+ )
37
+ expect(seen).toEqual([{ by: 5 }])
38
+ })
39
+
40
+ test('invoke fails with ParseError when the wire payload violates the schema', () => {
41
+ const exit = Effect.runSyncExit(
42
+ Effect.gen(function* () {
43
+ const registry = yield* Triggers.make
44
+ yield* registry.register('0:bump', (() => {}) as Trigger<{ by: number }>, ByPayload)
45
+ yield* registry.invoke('0:bump', { by: 'not-a-number' })
46
+ }),
47
+ )
48
+ expect(Exit.isFailure(exit)).toBe(true)
49
+ })
50
+
51
+ test('invoke on an unknown handle fails with UnknownTrigger', () => {
52
+ const exit = Effect.runSyncExit(
53
+ Effect.gen(function* () {
54
+ const registry = yield* Triggers.make
55
+ yield* registry.invoke('t999', { by: 1 })
56
+ }),
57
+ )
58
+ expect(Exit.isFailure(exit)).toBe(true)
59
+ const error = Exit.isFailure(exit) ? exit.cause : undefined
60
+ expect(JSON.stringify(error)).toContain('UnknownTrigger')
61
+ })
62
+
63
+ test('a revoked handle no longer fires', () => {
64
+ const fired = Effect.runSync(
65
+ Effect.gen(function* () {
66
+ const registry = yield* Triggers.make
67
+ const calls: number[] = []
68
+ yield* registry.register('0:bump', ((p: { by: number }) => void calls.push(p.by)) as Trigger<{ by: number }>, ByPayload)
69
+ yield* registry.revoke('0:bump')
70
+ const exit = yield* Effect.exit(registry.invoke('0:bump', { by: 1 }))
71
+ return { calls, failed: Exit.isFailure(exit) }
72
+ }),
73
+ )
74
+ expect(fired.calls).toEqual([])
75
+ expect(fired.failed).toBe(true)
76
+ })
@@ -0,0 +1,98 @@
1
+ import { type Cause, Context, Data, Effect, Layer, type ParseResult, Ref, Schema } from 'effect'
2
+ import type { Trigger } from '../ui/trigger'
3
+
4
+ /**
5
+ * The server-side bridge for trigger handles (REMOTE_UI.md §4). A rendered
6
+ * `WireProp` of `_tag: 'Event'` carries a `handle`; the client invokes it by
7
+ * sending `(handle, encodedPayload)`. Here we hold the live `Trigger` and its
8
+ * payload schema, decode the wire payload, and fire — so the UI never ships a
9
+ * function, only a handle, and the payload is validated at the seam.
10
+ *
11
+ * Handles are caller-provided keys, by convention `${nodeId}:${eventName}`: a
12
+ * re-render re-`register`s the same handle with that frame's fresh trigger, so the
13
+ * handle is stable across frames (the client's in-flight handle stays valid) and
14
+ * the handle space never grows. The schema is the contract's own per-event wire
15
+ * schema (`WiredUiManifest.events`, Phase 1) — the single source of truth for the
16
+ * UI boundary, decoupled from which `Event` the composition binds behind it.
17
+ */
18
+
19
+ export type TriggerHandle = string
20
+
21
+ // `any` in the schema/trigger slots is the documented Schema-variance escape
22
+ // (same reason as `CompositionConfig.props`); only the registrar's call site,
23
+ // where the trigger and its schema agree, sees the precise type.
24
+ interface TriggerEntry {
25
+ readonly trigger: Trigger<any>
26
+ readonly schema: Schema.Schema<any, any>
27
+ }
28
+
29
+ const UnknownTriggerBase: new (args: { readonly handle: TriggerHandle }) => Cause.YieldableError & {
30
+ readonly _tag: 'reform/UnknownTrigger'
31
+ } & Readonly<{ readonly handle: TriggerHandle }> = Data.TaggedError('reform/UnknownTrigger')<{
32
+ readonly handle: TriggerHandle
33
+ }>
34
+
35
+ /** A wire message named a trigger handle the server no longer holds (a stale client). */
36
+ export class UnknownTrigger extends UnknownTriggerBase {
37
+ override get message(): string {
38
+ return `reform: no trigger registered under handle '${this.handle}'`
39
+ }
40
+ }
41
+
42
+ export interface TriggerRegistryApi {
43
+ /** Register (or overwrite) a trigger + its payload schema under a handle key. */
44
+ readonly register: <P>(
45
+ handle: TriggerHandle,
46
+ trigger: Trigger<P>,
47
+ schema: Schema.Schema<P, any>,
48
+ ) => Effect.Effect<void>
49
+ /** Decode an encoded payload via the handle's schema and fire the trigger. */
50
+ readonly invoke: (
51
+ handle: TriggerHandle,
52
+ encodedPayload: unknown,
53
+ ) => Effect.Effect<void, UnknownTrigger | ParseResult.ParseError>
54
+ /** Drop a handle once its node unmounts (the Phase 2 tree diff signals it). */
55
+ readonly revoke: (handle: TriggerHandle) => Effect.Effect<void>
56
+ }
57
+
58
+ const TriggerRegistryBase: Context.TagClass<
59
+ TriggerRegistry,
60
+ 'reform/wire/TriggerRegistry',
61
+ TriggerRegistryApi
62
+ > = Context.Tag('reform/wire/TriggerRegistry')<TriggerRegistry, TriggerRegistryApi>()
63
+
64
+ export class TriggerRegistry extends TriggerRegistryBase {}
65
+
66
+ export const make: Effect.Effect<TriggerRegistryApi> = Effect.gen(function* () {
67
+ const entries = yield* Ref.make(new Map<TriggerHandle, TriggerEntry>())
68
+
69
+ const register = <P>(
70
+ handle: TriggerHandle,
71
+ trigger: Trigger<P>,
72
+ schema: Schema.Schema<P, any>,
73
+ ): Effect.Effect<void> =>
74
+ Ref.update(entries, (map) => new Map(map).set(handle, { trigger, schema }))
75
+
76
+ const invoke = (
77
+ handle: TriggerHandle,
78
+ encodedPayload: unknown,
79
+ ): Effect.Effect<void, UnknownTrigger | ParseResult.ParseError> =>
80
+ Effect.gen(function* () {
81
+ const entry = (yield* Ref.get(entries)).get(handle)
82
+ if (entry === undefined) return yield* Effect.fail(new UnknownTrigger({ handle }))
83
+ const payload = yield* Schema.decodeUnknown(entry.schema)(encodedPayload)
84
+ entry.trigger(payload)
85
+ })
86
+
87
+ const revoke = (handle: TriggerHandle): Effect.Effect<void> =>
88
+ Ref.update(entries, (map) => {
89
+ const next = new Map(map)
90
+ next.delete(handle)
91
+ return next
92
+ })
93
+
94
+ return { register, invoke, revoke }
95
+ })
96
+
97
+ /** Provide a fresh registry — one per remote connection (each client gets its own handle space). */
98
+ export const layer: Layer.Layer<TriggerRegistry> = Layer.effect(TriggerRegistry, make)