@playfast/reform 0.0.2 → 0.0.4

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 (230) hide show
  1. package/package.json +15 -13
  2. package/src/boundary/boundary.ts +192 -0
  3. package/src/calc/asyncCalc.ts +267 -0
  4. package/{dist/dts/calc/asyncData.d.ts → src/calc/asyncData.ts} +53 -20
  5. package/src/calc/calc.ts +120 -0
  6. package/src/calc/calcFamily.ts +194 -0
  7. package/src/calc/compose.ts +69 -0
  8. package/src/channel/channel.ts +265 -0
  9. package/src/compose/composition.ts +112 -0
  10. package/{dist/dts/compose/host.d.ts → src/compose/host.ts} +6 -8
  11. package/{dist/esm/compose/props.js → src/compose/props.ts} +3 -4
  12. package/src/compose/provide.ts +70 -0
  13. package/{dist/dts/compose/slot.d.ts → src/compose/slot.ts} +36 -19
  14. package/src/compose/ui.ts +97 -0
  15. package/src/definition/definition.ts +74 -0
  16. package/src/event/event.ts +65 -0
  17. package/src/event/eventGroup.ts +14 -0
  18. package/{dist/dts/feature/feature.d.ts → src/feature/feature.ts} +178 -92
  19. package/src/index.ts +140 -0
  20. package/{dist/dts/internal/capture.d.ts → src/internal/capture.ts} +13 -11
  21. package/{dist/dts/internal/ctx.d.ts → src/internal/ctx.ts} +8 -4
  22. package/{dist/esm/internal/errors.js → src/internal/errors.ts} +41 -26
  23. package/src/internal/inspect.ts +34 -0
  24. package/src/internal/queryDriver.ts +247 -0
  25. package/src/internal/reuse.ts +73 -0
  26. package/src/internal/scheduler.ts +91 -0
  27. package/{dist/dts/internal/seeds.d.ts → src/internal/seeds.ts} +6 -3
  28. package/src/internal/sources.ts +104 -0
  29. package/src/internal/store.ts +105 -0
  30. package/{dist/dts/internal/track.d.ts → src/internal/track.ts} +15 -11
  31. package/src/procedure/procedure.ts +89 -0
  32. package/src/reducer/reducer.ts +137 -0
  33. package/src/remote/remoteState.ts +571 -0
  34. package/{dist/cjs/runtime/bus.js → src/runtime/bus.ts} +23 -12
  35. package/src/runtime/loop.ts +169 -0
  36. package/src/scene/scene.ts +76 -0
  37. package/src/state/state.ts +91 -0
  38. package/src/state/stateFamily.ts +171 -0
  39. package/src/state/stateGroup.ts +78 -0
  40. package/{dist/dts/state/token.d.ts → src/state/token.ts} +21 -13
  41. package/{dist/dts/ui/node.d.ts → src/ui/node.ts} +3 -3
  42. package/{dist/dts/ui/trigger.d.ts → src/ui/trigger.ts} +1 -2
  43. package/dist/cjs/boundary/boundary.js +0 -86
  44. package/dist/cjs/calc/asyncCalc.js +0 -128
  45. package/dist/cjs/calc/asyncData.js +0 -37
  46. package/dist/cjs/calc/calc.js +0 -58
  47. package/dist/cjs/calc/calcFamily.js +0 -127
  48. package/dist/cjs/channel/channel.js +0 -142
  49. package/dist/cjs/compose/composition.js +0 -50
  50. package/dist/cjs/compose/host.js +0 -8
  51. package/dist/cjs/compose/props.js +0 -14
  52. package/dist/cjs/compose/provide.js +0 -30
  53. package/dist/cjs/compose/slot.js +0 -27
  54. package/dist/cjs/compose/ui.js +0 -61
  55. package/dist/cjs/definition/definition.js +0 -46
  56. package/dist/cjs/event/event.js +0 -36
  57. package/dist/cjs/event/eventGroup.js +0 -7
  58. package/dist/cjs/feature/feature.js +0 -102
  59. package/dist/cjs/index.js +0 -116
  60. package/dist/cjs/internal/capture.js +0 -14
  61. package/dist/cjs/internal/ctx.js +0 -2
  62. package/dist/cjs/internal/errors.js +0 -62
  63. package/dist/cjs/internal/inspect.js +0 -36
  64. package/dist/cjs/internal/queryDriver.js +0 -138
  65. package/dist/cjs/internal/reuse.js +0 -71
  66. package/dist/cjs/internal/scheduler.js +0 -73
  67. package/dist/cjs/internal/seeds.js +0 -19
  68. package/dist/cjs/internal/sources.js +0 -61
  69. package/dist/cjs/internal/store.js +0 -77
  70. package/dist/cjs/internal/track.js +0 -22
  71. package/dist/cjs/package.json +0 -4
  72. package/dist/cjs/procedure/procedure.js +0 -52
  73. package/dist/cjs/reducer/reducer.js +0 -64
  74. package/dist/cjs/remote/remoteState.js +0 -307
  75. package/dist/cjs/runtime/loop.js +0 -119
  76. package/dist/cjs/scene/scene.js +0 -36
  77. package/dist/cjs/state/state.js +0 -47
  78. package/dist/cjs/state/stateFamily.js +0 -101
  79. package/dist/cjs/state/stateGroup.js +0 -47
  80. package/dist/cjs/state/token.js +0 -23
  81. package/dist/cjs/ui/node.js +0 -2
  82. package/dist/cjs/ui/trigger.js +0 -2
  83. package/dist/dts/boundary/boundary.d.ts +0 -72
  84. package/dist/dts/boundary/boundary.d.ts.map +0 -1
  85. package/dist/dts/calc/asyncCalc.d.ts +0 -91
  86. package/dist/dts/calc/asyncCalc.d.ts.map +0 -1
  87. package/dist/dts/calc/asyncData.d.ts.map +0 -1
  88. package/dist/dts/calc/calc.d.ts +0 -57
  89. package/dist/dts/calc/calc.d.ts.map +0 -1
  90. package/dist/dts/calc/calcFamily.d.ts +0 -57
  91. package/dist/dts/calc/calcFamily.d.ts.map +0 -1
  92. package/dist/dts/channel/channel.d.ts +0 -115
  93. package/dist/dts/channel/channel.d.ts.map +0 -1
  94. package/dist/dts/compose/composition.d.ts +0 -72
  95. package/dist/dts/compose/composition.d.ts.map +0 -1
  96. package/dist/dts/compose/host.d.ts.map +0 -1
  97. package/dist/dts/compose/props.d.ts +0 -13
  98. package/dist/dts/compose/props.d.ts.map +0 -1
  99. package/dist/dts/compose/provide.d.ts +0 -22
  100. package/dist/dts/compose/provide.d.ts.map +0 -1
  101. package/dist/dts/compose/slot.d.ts.map +0 -1
  102. package/dist/dts/compose/ui.d.ts +0 -50
  103. package/dist/dts/compose/ui.d.ts.map +0 -1
  104. package/dist/dts/definition/definition.d.ts +0 -33
  105. package/dist/dts/definition/definition.d.ts.map +0 -1
  106. package/dist/dts/event/event.d.ts +0 -33
  107. package/dist/dts/event/event.d.ts.map +0 -1
  108. package/dist/dts/event/eventGroup.d.ts +0 -9
  109. package/dist/dts/event/eventGroup.d.ts.map +0 -1
  110. package/dist/dts/feature/feature.d.ts.map +0 -1
  111. package/dist/dts/index.d.ts +0 -43
  112. package/dist/dts/index.d.ts.map +0 -1
  113. package/dist/dts/internal/capture.d.ts.map +0 -1
  114. package/dist/dts/internal/ctx.d.ts.map +0 -1
  115. package/dist/dts/internal/errors.d.ts +0 -69
  116. package/dist/dts/internal/errors.d.ts.map +0 -1
  117. package/dist/dts/internal/inspect.d.ts +0 -17
  118. package/dist/dts/internal/inspect.d.ts.map +0 -1
  119. package/dist/dts/internal/queryDriver.d.ts +0 -65
  120. package/dist/dts/internal/queryDriver.d.ts.map +0 -1
  121. package/dist/dts/internal/reuse.d.ts +0 -10
  122. package/dist/dts/internal/reuse.d.ts.map +0 -1
  123. package/dist/dts/internal/scheduler.d.ts +0 -47
  124. package/dist/dts/internal/scheduler.d.ts.map +0 -1
  125. package/dist/dts/internal/seeds.d.ts.map +0 -1
  126. package/dist/dts/internal/sources.d.ts +0 -39
  127. package/dist/dts/internal/sources.d.ts.map +0 -1
  128. package/dist/dts/internal/store.d.ts +0 -47
  129. package/dist/dts/internal/store.d.ts.map +0 -1
  130. package/dist/dts/internal/track.d.ts.map +0 -1
  131. package/dist/dts/procedure/procedure.d.ts +0 -40
  132. package/dist/dts/procedure/procedure.d.ts.map +0 -1
  133. package/dist/dts/reducer/reducer.d.ts +0 -44
  134. package/dist/dts/reducer/reducer.d.ts.map +0 -1
  135. package/dist/dts/remote/remoteState.d.ts +0 -119
  136. package/dist/dts/remote/remoteState.d.ts.map +0 -1
  137. package/dist/dts/runtime/bus.d.ts +0 -27
  138. package/dist/dts/runtime/bus.d.ts.map +0 -1
  139. package/dist/dts/runtime/loop.d.ts +0 -45
  140. package/dist/dts/runtime/loop.d.ts.map +0 -1
  141. package/dist/dts/scene/scene.d.ts +0 -44
  142. package/dist/dts/scene/scene.d.ts.map +0 -1
  143. package/dist/dts/state/state.d.ts +0 -37
  144. package/dist/dts/state/state.d.ts.map +0 -1
  145. package/dist/dts/state/stateFamily.d.ts +0 -79
  146. package/dist/dts/state/stateFamily.d.ts.map +0 -1
  147. package/dist/dts/state/stateGroup.d.ts +0 -36
  148. package/dist/dts/state/stateGroup.d.ts.map +0 -1
  149. package/dist/dts/state/token.d.ts.map +0 -1
  150. package/dist/dts/ui/node.d.ts.map +0 -1
  151. package/dist/dts/ui/trigger.d.ts.map +0 -1
  152. package/dist/esm/boundary/boundary.js +0 -83
  153. package/dist/esm/boundary/boundary.js.map +0 -1
  154. package/dist/esm/calc/asyncCalc.js +0 -95
  155. package/dist/esm/calc/asyncCalc.js.map +0 -1
  156. package/dist/esm/calc/asyncData.js +0 -34
  157. package/dist/esm/calc/asyncData.js.map +0 -1
  158. package/dist/esm/calc/calc.js +0 -58
  159. package/dist/esm/calc/calc.js.map +0 -1
  160. package/dist/esm/calc/calcFamily.js +0 -124
  161. package/dist/esm/calc/calcFamily.js.map +0 -1
  162. package/dist/esm/channel/channel.js +0 -136
  163. package/dist/esm/channel/channel.js.map +0 -1
  164. package/dist/esm/compose/composition.js +0 -46
  165. package/dist/esm/compose/composition.js.map +0 -1
  166. package/dist/esm/compose/host.js +0 -5
  167. package/dist/esm/compose/host.js.map +0 -1
  168. package/dist/esm/compose/props.js.map +0 -1
  169. package/dist/esm/compose/provide.js +0 -28
  170. package/dist/esm/compose/provide.js.map +0 -1
  171. package/dist/esm/compose/slot.js +0 -23
  172. package/dist/esm/compose/slot.js.map +0 -1
  173. package/dist/esm/compose/ui.js +0 -57
  174. package/dist/esm/compose/ui.js.map +0 -1
  175. package/dist/esm/definition/definition.js +0 -42
  176. package/dist/esm/definition/definition.js.map +0 -1
  177. package/dist/esm/event/event.js +0 -30
  178. package/dist/esm/event/event.js.map +0 -1
  179. package/dist/esm/event/eventGroup.js +0 -4
  180. package/dist/esm/event/eventGroup.js.map +0 -1
  181. package/dist/esm/feature/feature.js +0 -98
  182. package/dist/esm/feature/feature.js.map +0 -1
  183. package/dist/esm/index.js +0 -45
  184. package/dist/esm/index.js.map +0 -1
  185. package/dist/esm/internal/capture.js +0 -11
  186. package/dist/esm/internal/capture.js.map +0 -1
  187. package/dist/esm/internal/ctx.js +0 -2
  188. package/dist/esm/internal/ctx.js.map +0 -1
  189. package/dist/esm/internal/errors.js.map +0 -1
  190. package/dist/esm/internal/inspect.js +0 -32
  191. package/dist/esm/internal/inspect.js.map +0 -1
  192. package/dist/esm/internal/queryDriver.js +0 -134
  193. package/dist/esm/internal/queryDriver.js.map +0 -1
  194. package/dist/esm/internal/reuse.js +0 -68
  195. package/dist/esm/internal/reuse.js.map +0 -1
  196. package/dist/esm/internal/scheduler.js +0 -69
  197. package/dist/esm/internal/scheduler.js.map +0 -1
  198. package/dist/esm/internal/seeds.js +0 -17
  199. package/dist/esm/internal/seeds.js.map +0 -1
  200. package/dist/esm/internal/sources.js +0 -59
  201. package/dist/esm/internal/sources.js.map +0 -1
  202. package/dist/esm/internal/store.js +0 -73
  203. package/dist/esm/internal/store.js.map +0 -1
  204. package/dist/esm/internal/track.js +0 -18
  205. package/dist/esm/internal/track.js.map +0 -1
  206. package/dist/esm/package.json +0 -4
  207. package/dist/esm/procedure/procedure.js +0 -50
  208. package/dist/esm/procedure/procedure.js.map +0 -1
  209. package/dist/esm/reducer/reducer.js +0 -63
  210. package/dist/esm/reducer/reducer.js.map +0 -1
  211. package/dist/esm/remote/remoteState.js +0 -270
  212. package/dist/esm/remote/remoteState.js.map +0 -1
  213. package/dist/esm/runtime/bus.js +0 -20
  214. package/dist/esm/runtime/bus.js.map +0 -1
  215. package/dist/esm/runtime/loop.js +0 -116
  216. package/dist/esm/runtime/loop.js.map +0 -1
  217. package/dist/esm/scene/scene.js +0 -31
  218. package/dist/esm/scene/scene.js.map +0 -1
  219. package/dist/esm/state/state.js +0 -43
  220. package/dist/esm/state/state.js.map +0 -1
  221. package/dist/esm/state/stateFamily.js +0 -96
  222. package/dist/esm/state/stateFamily.js.map +0 -1
  223. package/dist/esm/state/stateGroup.js +0 -46
  224. package/dist/esm/state/stateGroup.js.map +0 -1
  225. package/dist/esm/state/token.js +0 -20
  226. package/dist/esm/state/token.js.map +0 -1
  227. package/dist/esm/ui/node.js +0 -2
  228. package/dist/esm/ui/node.js.map +0 -1
  229. package/dist/esm/ui/trigger.js +0 -2
  230. package/dist/esm/ui/trigger.js.map +0 -1
@@ -1,54 +1,69 @@
1
- import { Data } from 'effect';
1
+ import { Data } from 'effect'
2
+
2
3
  /**
3
4
  * Tagged errors for the framework's synchronous boundaries. Even when thrown
4
5
  * (React render, layer build, reflection), an error carries a `_tag` and its
5
6
  * structured fields — never a bare `Error` — so callers and tooling can match on
6
7
  * it. `Data.TaggedError` subclasses are real `Error`s, so `throw` still works.
7
8
  */
9
+
8
10
  /** A `<slots.X/>` was rendered without a render-target host to realize it. */
9
- export class SlotRenderingUnavailable extends Data.TaggedError('reform/SlotRenderingUnavailable') {
10
- get message() {
11
- return `reform: rendering slot '${this.slot}' requires @reform/react`;
12
- }
11
+ export class SlotRenderingUnavailable extends Data.TaggedError('reform/SlotRenderingUnavailable')<{
12
+ readonly slot: string
13
+ }> {
14
+ override get message(): string {
15
+ return `reform: rendering slot '${this.slot}' requires @reform/react`
16
+ }
13
17
  }
18
+
14
19
  /** `provide(target, …)` was handed something that is neither a ui contract nor a slot. */
15
- export class InvalidProvideTarget extends Data.TaggedError('reform/InvalidProvideTarget') {
16
- get message() {
17
- return 'reform: provide target is neither a ui contract nor a slot';
18
- }
20
+ export class InvalidProvideTarget extends Data.TaggedError('reform/InvalidProvideTarget')<{}> {
21
+ override get message(): string {
22
+ return 'reform: provide target is neither a ui contract nor a slot'
23
+ }
19
24
  }
25
+
20
26
  /** `StateGroup.select(group, name)` named a state the group does not contain. */
21
- export class UnknownGroupState extends Data.TaggedError('reform/UnknownGroupState') {
22
- get message() {
23
- return `reform: unknown state '${this.name}' in group`;
24
- }
27
+ export class UnknownGroupState extends Data.TaggedError('reform/UnknownGroupState')<{
28
+ readonly name: string
29
+ }> {
30
+ override get message(): string {
31
+ return `reform: unknown state '${this.name}' in group`
32
+ }
25
33
  }
34
+
26
35
  /**
27
36
  * A lazy `Feature`'s `load` Effect failed — the dynamic `import()` rejected (a
28
37
  * missing chunk, a network failure, a module-eval throw). Surfaced as a typed
29
38
  * failure the host hands to the feature's `placeholder.failed`, never a bare
30
39
  * rejection. `cause` is the original rejection value.
31
40
  */
32
- export class FeatureLoadFailed extends Data.TaggedError('reform/FeatureLoadFailed') {
33
- get message() {
34
- return `reform: a feature failed to load (${String(this.cause)})`;
35
- }
41
+ export class FeatureLoadFailed extends Data.TaggedError('reform/FeatureLoadFailed')<{
42
+ readonly cause: unknown
43
+ }> {
44
+ override get message(): string {
45
+ return `reform: a feature failed to load (${String(this.cause)})`
46
+ }
36
47
  }
48
+
37
49
  /** A reducer fold returned a thenable — folds must be pure synchronous writes. */
38
- export class AsyncReducer extends Data.TaggedError('reform/AsyncReducer') {
39
- get message() {
40
- return 'reform: a reducer fold must be synchronous (it returned a Promise)';
41
- }
50
+ export class AsyncReducer extends Data.TaggedError('reform/AsyncReducer')<{}> {
51
+ override get message(): string {
52
+ return 'reform: a reducer fold must be synchronous (it returned a Promise)'
53
+ }
42
54
  }
55
+
43
56
  /**
44
57
  * Two distinct primitives were registered under one name into the same runtime —
45
58
  * a silent footgun, since names key DI tags and channel lanes. Thrown for a
46
59
  * channel name reused with a different policy (the second would otherwise be
47
60
  * dropped). Reducer/procedure name clashes are warned (logged), not thrown.
48
61
  */
49
- export class DuplicateRegistration extends Data.TaggedError('reform/DuplicateRegistration') {
50
- get message() {
51
- return `reform: duplicate ${this.kind} registered under name '${this.name}'`;
52
- }
62
+ export class DuplicateRegistration extends Data.TaggedError('reform/DuplicateRegistration')<{
63
+ readonly kind: string
64
+ readonly name: string
65
+ }> {
66
+ override get message(): string {
67
+ return `reform: duplicate ${this.kind} registered under name '${this.name}'`
68
+ }
53
69
  }
54
- //# sourceMappingURL=errors.js.map
@@ -0,0 +1,34 @@
1
+ import { format, type Inspectable, NodeInspectSymbol } from 'effect/Inspectable'
2
+
3
+ export { type Inspectable, NodeInspectSymbol }
4
+
5
+ /**
6
+ * The `Inspectable` trio built around a `toJSON`, so every reform value prints
7
+ * like an Effect value — `_id`-tagged JSON under `console.log`, Node's
8
+ * inspector, `JSON.stringify`, and `String(...)` — instead of `[object Object]`.
9
+ * Mirrors Effect's `BaseProto` / `Inspectable.Class` (`Inspectable.ts:170`).
10
+ */
11
+ export const inspectable = (toJSON: () => unknown): Inspectable => ({
12
+ toJSON,
13
+ toString: () => format(toJSON()),
14
+ [NodeInspectSymbol]: () => toJSON(),
15
+ })
16
+
17
+ /**
18
+ * Attach the `Inspectable` trio to an existing object as *non-enumerable* own
19
+ * properties (so they don't show up in `Object.keys`/`for…in` over a definition
20
+ * class). Used where the value is built by other means — the definition classes,
21
+ * whose statics are merged in separately.
22
+ */
23
+ export const attachInspectable = <T extends object>(target: T, toJSON: () => unknown): T => {
24
+ const trio = inspectable(toJSON) as unknown as Record<PropertyKey, unknown>
25
+ for (const key of Reflect.ownKeys(trio)) {
26
+ Object.defineProperty(target, key, {
27
+ value: trio[key],
28
+ enumerable: false,
29
+ writable: true,
30
+ configurable: true,
31
+ })
32
+ }
33
+ return target
34
+ }
@@ -0,0 +1,247 @@
1
+ import {
2
+ Brand,
3
+ Cause,
4
+ Chunk,
5
+ Effect,
6
+ MutableRef,
7
+ Option,
8
+ Queue,
9
+ Schema,
10
+ type Scope,
11
+ Stream,
12
+ } from 'effect'
13
+ import { type AnyAsyncData, AsyncData } from '../calc/asyncData'
14
+ import { reuse } from './reuse'
15
+ import { resolveScheduler } from './scheduler'
16
+ import {
17
+ type InputsObject,
18
+ type InputStores,
19
+ type InvalidateBy,
20
+ sameKey,
21
+ wireSources,
22
+ } from './sources'
23
+ import { makeStore, type Store } from './store'
24
+ import { type AnySource } from '../state/token'
25
+
26
+ // The query-lifecycle driver shared by `AsyncCalc.live` and `RemoteState.live`:
27
+ // sense the inputs, run the query latest-wins (or trailing-conflated), fold the
28
+ // result into an `AnyAsyncData` store with stale-while-revalidate semantics.
29
+ // Extracted so both primitives run the exact same machinery — the asyncCalc
30
+ // test suite is the driver's regression suite.
31
+
32
+ /** `Gated` (whether the `Idle` arm exists) is the inverse of `alwaysOn`. */
33
+ export type GatedOf<AlwaysOn extends boolean> = AlwaysOn extends true ? false : true
34
+
35
+ /** The runtime gated flag for an `alwaysOn` config, typed as its `Gated` literal. */
36
+ export const gatedFlag = <AlwaysOn extends boolean>(alwaysOn: AlwaysOn | undefined): GatedOf<AlwaysOn> =>
37
+ (alwaysOn !== true) as GatedOf<AlwaysOn>
38
+
39
+ // The hidden revision behind `invalidateOn` — a module-local *branded* number.
40
+ // Reform store services are type-identified (`Context.GenericTag<Store<A>>`), so
41
+ // the hidden state's value type must be un-collidable: `Layer.provide` erases
42
+ // exactly `Store<Revision>` from the returned layer and can never type-level
43
+ // erase a user input that happens to be a number state. Never exported from the
44
+ // package index, so the editor catalog (which only indexes module exports)
45
+ // never sees it.
46
+ export type Revision = number & Brand.Brand<'reform/Revision'>
47
+ export const Revision = Brand.nominal<Revision>()
48
+ export const RevisionSchema: Schema.Schema<Revision, number> = Schema.Number.pipe(
49
+ Schema.brand('reform/Revision'),
50
+ )
51
+ export const revisionZero: Revision = Revision(0)
52
+ export const bumpRevision = (r: Revision): Revision => Revision(r + 1)
53
+
54
+ export interface QueryDriverOptions<Inputs extends ReadonlyArray<AnySource>, A, E, R> {
55
+ /** The owning definition's name — only for the defect log line. */
56
+ readonly name: string
57
+ /** The owning primitive's kind label — only for the defect log line. */
58
+ readonly label: string
59
+ /** Whether `disabled` is honored / the `Idle` arm can occur. */
60
+ readonly gated: boolean
61
+ readonly inputs: Inputs
62
+ readonly query: (inputs: InputsObject<Inputs>) => Effect.Effect<A, E, R>
63
+ readonly invalidateBy?: InvalidateBy<Inputs> | undefined
64
+ readonly disabled?: ((inputs: InputsObject<Inputs>) => boolean) | undefined
65
+ readonly coalesce?: 'switch' | 'trailing' | undefined
66
+ readonly reuse?: boolean | undefined
67
+ /**
68
+ * An extra key segment + wake source beyond the declared inputs — the hidden
69
+ * `invalidateOn` revision. Kept out of `wireSources` so the user's `query`
70
+ * and `invalidateBy` see exactly the declared inputs.
71
+ */
72
+ readonly extraKey?:
73
+ | {
74
+ readonly read: () => unknown
75
+ readonly subscribe: (listener: () => void) => () => void
76
+ }
77
+ | undefined
78
+ /**
79
+ * Fired when a run's `Success` value has landed in the store (same scheduler
80
+ * flush — never before the converged value is readable), with the run's
81
+ * generation. Not fired on `Error`, interrupt, or while disabled. The seam
82
+ * `RemoteState` settles pending intents through.
83
+ */
84
+ readonly onSettled?: ((generation: number) => void) | undefined
85
+ }
86
+
87
+ export interface QueryDriver<A, E> {
88
+ /** The lifecycle store (the full union — callers narrow to their arms). */
89
+ readonly store: Store<AnyAsyncData<A, E>>
90
+ /**
91
+ * The highest generation requested so far (0 before the first kick).
92
+ * Generations are assigned when a run is REQUESTED (enqueued), so a waiter
93
+ * registered as `requested() + 1` is satisfied only by a run that began
94
+ * after registration — the settle-ordering rule `RemoteState` builds on.
95
+ */
96
+ readonly requested: () => number
97
+ }
98
+
99
+ /**
100
+ * Build the driver: store + subscription + request queue + run fiber, all owned
101
+ * by the ambient scope (callers run this under `Layer.scoped`). A change to an
102
+ * input re-runs the query latest-wins (a new run cancels the in-flight one; or,
103
+ * with `coalesce: 'trailing'`, lets it finish and runs one trailing refetch);
104
+ * while a re-run is in flight the last `Success`/`Error` is kept with
105
+ * `refetching: true`.
106
+ */
107
+ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R>(
108
+ options: QueryDriverOptions<Inputs, A, E, R>,
109
+ ): Effect.Effect<QueryDriver<A, E>, never, InputStores<Inputs> | R | Scope.Scope> =>
110
+ Effect.gen(function* () {
111
+ const scheduler = yield* resolveScheduler
112
+ const sources = yield* wireSources(options.inputs, options.invalidateBy)
113
+ const extraKey = options.extraKey
114
+
115
+ const keyOf = (args: InputsObject<Inputs>): ReadonlyArray<unknown> =>
116
+ extraKey === undefined ? sources.keyOf(args) : [...sources.keyOf(args), extraKey.read()]
117
+ const subscribeAll = (listener: () => void): (() => void) => {
118
+ const offSources = sources.subscribe(listener)
119
+ const offExtra = extraKey?.subscribe(listener)
120
+ return () => {
121
+ offSources()
122
+ offExtra?.()
123
+ }
124
+ }
125
+ const disabledNow = (args: InputsObject<Inputs> = sources.snapshot()): boolean =>
126
+ options.gated && options.disabled !== undefined ? options.disabled(args) : false
127
+
128
+ const store = makeStore<AnyAsyncData<A, E>>(
129
+ disabledNow() ? AsyncData.idle : AsyncData.loading,
130
+ scheduler,
131
+ )
132
+ // The last requested key, so a change that doesn't move it (or only churns
133
+ // an input `invalidateBy` ignores) doesn't re-fetch.
134
+ const lastKey = MutableRef.make<ReadonlyArray<unknown> | undefined>(undefined)
135
+ // The run-generation counter: bumped when a run is REQUESTED (enqueued), so
136
+ // `requested()` names the newest run that could possibly be in flight.
137
+ const generation = MutableRef.make(0)
138
+ const nextGeneration = (): number => {
139
+ MutableRef.update(generation, (n) => n + 1)
140
+ return MutableRef.get(generation)
141
+ }
142
+
143
+ // Mark a re-fetch in flight without dropping the visible value (SWR).
144
+ const markRefetching = () => {
145
+ const prev = store.get()
146
+ if (prev._tag === 'Success') store.set(AsyncData.success(prev.value, true))
147
+ else if (prev._tag === 'Error') store.set(AsyncData.error(prev.error, true))
148
+ else store.set(AsyncData.loading)
149
+ }
150
+
151
+ // One run of the query, folded into the store. A failure becomes `Error`; an
152
+ // interrupt (latest-wins cancel) leaves the state untouched; a defect (a bug
153
+ // in the body) is logged and isolated — the driver keeps running. A `Success`
154
+ // additionally reports its generation through `onSettled`, after the write,
155
+ // so settle-driven consequences observe the converged value.
156
+ const runQuery = (request: Request): Effect.Effect<void, never, R> =>
157
+ options.query(request.args).pipe(
158
+ Effect.tapDefect((defect) =>
159
+ Effect.logError(`reform: ${options.label} '${options.name}' query defect`, defect),
160
+ ),
161
+ Effect.matchCause({
162
+ onSuccess: (value) => {
163
+ if (!disabledNow()) {
164
+ // `reuse`: share unchanged subtrees with the previous Success
165
+ // value, so a refetch that barely moved keeps identities stable.
166
+ const prev = store.get()
167
+ const shared =
168
+ options.reuse === true && prev._tag === 'Success'
169
+ ? reuse(prev.value, value)
170
+ : value
171
+ store.set(AsyncData.success(shared, false))
172
+ options.onSettled?.(request.generation)
173
+ }
174
+ },
175
+ onFailure: (cause) => {
176
+ const failure = Cause.failureOption(cause)
177
+ if (Option.isSome(failure) && !disabledNow()) {
178
+ store.set(AsyncData.error(failure.value, false))
179
+ }
180
+ },
181
+ }),
182
+ )
183
+
184
+ interface Request {
185
+ readonly args: InputsObject<Inputs>
186
+ readonly generation: number
187
+ }
188
+
189
+ // The driver. `'switch'` (default): each new request cancels the in-flight
190
+ // run — the same latest-wins semantics a `latest` channel gives procedures.
191
+ // `'trailing'`: a strictly sequential consumer that, on wake, drains every
192
+ // request that piled up during the flight and runs the LATEST one —
193
+ // "exactly one trailing run after settle" by construction. The trailing
194
+ // run's generation is the max drained (the latest request's), so it
195
+ // vouches for every request it conflated.
196
+ const trailing = options.coalesce === 'trailing'
197
+ const requests = yield* Queue.unbounded<Request>()
198
+ yield* Effect.forkScoped(
199
+ trailing
200
+ ? Effect.forever(
201
+ Effect.gen(function* () {
202
+ const first = yield* Queue.take(requests)
203
+ const queued = yield* Queue.takeAll(requests)
204
+ const request = Option.getOrElse(Chunk.last(queued), () => first)
205
+ // A disable that landed while the request waited: skip the run.
206
+ if (!disabledNow()) yield* runQuery(request)
207
+ // The settle may have written a stale-key result with
208
+ // `refetching: false`; if a newer request is already waiting,
209
+ // restore the syncing flag before the next take — both writes
210
+ // coalesce into one scheduler flush for subscribers.
211
+ const pending = yield* Queue.size(requests)
212
+ if (pending > 0 && !disabledNow()) yield* Effect.sync(markRefetching)
213
+ }),
214
+ )
215
+ : Stream.fromQueue(requests).pipe(
216
+ Stream.flatMap((request) => Stream.fromEffect(runQuery(request)), { switch: true }),
217
+ Stream.runDrain,
218
+ ),
219
+ )
220
+
221
+ const trigger = () => {
222
+ const args = sources.snapshot()
223
+ if (disabledNow(args)) {
224
+ // Switched off: show Idle and forget the key so re-enabling always re-runs.
225
+ MutableRef.set(lastKey, undefined)
226
+ store.set(AsyncData.idle)
227
+ return
228
+ }
229
+ const key = keyOf(args)
230
+ const previous = MutableRef.get(lastKey)
231
+ if (previous !== undefined && sameKey(key, previous)) return
232
+ MutableRef.set(lastKey, key)
233
+ markRefetching()
234
+ Queue.unsafeOffer(requests, { args, generation: nextGeneration() })
235
+ }
236
+ const unsubscribe = subscribeAll(trigger)
237
+ yield* Effect.addFinalizer(() => Effect.sync(unsubscribe))
238
+
239
+ // Kick off the first fetch unless the query starts disabled.
240
+ const initial = sources.snapshot()
241
+ if (!disabledNow(initial)) {
242
+ MutableRef.set(lastKey, keyOf(initial))
243
+ Queue.unsafeOffer(requests, { args: initial, generation: nextGeneration() })
244
+ }
245
+
246
+ return { store, requested: () => MutableRef.get(generation) }
247
+ })
@@ -0,0 +1,73 @@
1
+ import { Equal } from 'effect'
2
+
3
+ // Structural sharing for recomputed calc outputs: reconcile a fresh output
4
+ // against the previous one, substituting previous nodes wherever value equality
5
+ // holds, so identities only move where values moved. Downstream memo boundaries
6
+ // (React subtrees keyed on object identity) then skip everything that didn't
7
+ // change. Referentially transparent: the result is value-equal to `next` — only
8
+ // identities shift toward `previous` — and neither argument is mutated, so the
9
+ // pass composes with the framework's Equal-based invalidation untouched.
10
+
11
+ // Bounds the walk; calc outputs are shallow plain data by framework convention.
12
+ const maxDepth = 16
13
+
14
+ const isPlainRecord = (v: unknown): v is Record<string, unknown> =>
15
+ typeof v === 'object' &&
16
+ v !== null &&
17
+ (Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null)
18
+
19
+ const go = (prev: unknown, next: unknown, depth: number): unknown => {
20
+ if (Object.is(prev, next)) return prev
21
+ // Data/Schema classes implement Equal+Hash: substitute wholesale on value
22
+ // equality. Plain objects/arrays fall through (their Equal is referential).
23
+ if (Equal.equals(prev, next)) return prev
24
+ if (depth <= 0) return next
25
+ if (Array.isArray(prev) && Array.isArray(next)) {
26
+ const out = next.map((item, i) => (i < prev.length ? go(prev[i], item, depth - 1) : item))
27
+ return prev.length === next.length && out.every((v, i) => Object.is(v, prev[i]))
28
+ ? prev
29
+ : out
30
+ }
31
+ if (isPlainRecord(prev) && isPlainRecord(next)) {
32
+ const keys = Object.keys(next)
33
+ const out: Record<string, unknown> = {}
34
+ for (const key of keys) out[key] = key in prev ? go(prev[key], next[key], depth - 1) : next[key]
35
+ const allPrev =
36
+ keys.length === Object.keys(prev).length &&
37
+ keys.every((key) => key in prev && Object.is(out[key], prev[key]))
38
+ return allPrev ? prev : out
39
+ }
40
+ // Data/Schema class instances that DIFFER still get walked: their Equal is
41
+ // fieldwise over own enumerable fields, so a reconstruction over reconciled
42
+ // fields (same prototype; the constructor is bypassed, but every leaf is a
43
+ // validated value out of `prev` or `next`) stays value-equal to `next` while
44
+ // unchanged children — e.g. one untouched element inside a class-typed
45
+ // container — keep their `prev` identity.
46
+ if (
47
+ Equal.isEqual(prev) &&
48
+ Equal.isEqual(next) &&
49
+ Object.getPrototypeOf(prev) === Object.getPrototypeOf(next)
50
+ ) {
51
+ const prevFields = Object.fromEntries(Object.entries(prev))
52
+ const nextFields = Object.entries(next)
53
+ const out: Record<string, unknown> = {}
54
+ for (const [key, value] of nextFields)
55
+ out[key] = key in prevFields ? go(prevFields[key], value, depth - 1) : value
56
+ const allPrev =
57
+ nextFields.length === Object.keys(prevFields).length &&
58
+ nextFields.every(([key]) => key in prevFields && Object.is(out[key], prevFields[key]))
59
+ return allPrev ? prev : Object.assign(Object.create(Object.getPrototypeOf(next)), out)
60
+ }
61
+ // Class instances without Equal (Date, Map, …) are opaque leaves.
62
+ return next
63
+ }
64
+
65
+ /**
66
+ * Substitute `previous` nodes into `next` wherever they are value-equal. The
67
+ * walker only ever returns `previous`, `next`, or a key/index-wise
68
+ * reconstruction of `next` whose every leaf came from one of them, so the
69
+ * result is value-equal to `next` and the type is preserved by construction —
70
+ * the single cast below is that argument, in the style of `wireSources` /
71
+ * `narrowStore`.
72
+ */
73
+ export const reuse = <A>(previous: A, next: A): A => go(previous, next, maxDepth) as A
@@ -0,0 +1,91 @@
1
+ import { Context, Effect, Layer, MutableRef, Option } from 'effect'
2
+
3
+ /**
4
+ * Coalesces store notifications into a single microtask flush so a batch of
5
+ * writes wakes each subscriber at most once (RESEARCH #3). React owns frame
6
+ * timing; we only collapse the notifications.
7
+ *
8
+ * One scheduler is shared by every store in a runtime, so writes across
9
+ * different stores in the same tick batch into one flush. The instance is
10
+ * resolved per store at creation: `Notifications` when a runtime provides it
11
+ * (per-runtime isolation — see `resolveScheduler`), else the process-wide
12
+ * `defaultScheduler`.
13
+ */
14
+ export interface Scheduler {
15
+ readonly schedule: (listeners: Iterable<() => void>) => void
16
+ }
17
+
18
+ export const makeScheduler = (): Scheduler => {
19
+ const pending = new Set<() => void>()
20
+ const armed = MutableRef.make(false)
21
+
22
+ const flush = () => {
23
+ MutableRef.set(armed, false)
24
+ // Drain to a fixpoint within ONE microtask. A derived store's `onChange`
25
+ // re-schedules during the pass (it is itself a listener of its upstream), so
26
+ // resolving the whole dependency graph here — instead of re-arming a fresh
27
+ // microtask per layer — collapses a depth-N propagation into a single flush
28
+ // and wakes each leaf subscriber once at its final value. Converges because
29
+ // a derived store schedules only when its output actually moves (Equal).
30
+ while (pending.size > 0) {
31
+ const due = [...pending]
32
+ pending.clear()
33
+ for (const listener of due) {
34
+ // One listener's throw (a defect in a user calc body) must not abandon
35
+ // the rest of the flush; surface it to the host's global handler on a
36
+ // fresh microtask instead of unwinding this loop.
37
+ try {
38
+ listener()
39
+ } catch (error) {
40
+ queueMicrotask(() => {
41
+ throw error
42
+ })
43
+ }
44
+ }
45
+ }
46
+ }
47
+
48
+ const schedule = (listeners: Iterable<() => void>) => {
49
+ for (const listener of listeners) pending.add(listener)
50
+ if (pending.size > 0 && !MutableRef.get(armed)) {
51
+ MutableRef.set(armed, true)
52
+ queueMicrotask(flush)
53
+ }
54
+ }
55
+
56
+ return { schedule }
57
+ }
58
+
59
+ /**
60
+ * The process-wide scheduler, used by any store created outside a runtime that
61
+ * provides its own `Notifications`. Fine for a single client app (microtask
62
+ * ordering is global anyway); a runtime that needs isolation — concurrent SSR,
63
+ * multiple mounted roots — provides `Notifications` upstream of its stores.
64
+ */
65
+ export const defaultScheduler: Scheduler = makeScheduler()
66
+
67
+ /**
68
+ * Optional per-runtime scheduler. When a runtime provides it upstream of its
69
+ * state/calc layers, those stores coalesce on it instead of the global default,
70
+ * isolating their notification timing from other runtimes in the same process.
71
+ */
72
+ export class Notifications extends Context.Tag('reform/Notifications')<Notifications, Scheduler>() {}
73
+
74
+ /**
75
+ * A fresh per-runtime scheduler. Merged into `Engine`, so a runtime that wires
76
+ * its state/calc layers *downstream* of `Engine` gets isolated notification
77
+ * timing; the common sibling wiring falls back to `defaultScheduler` (still
78
+ * correct, just process-shared).
79
+ */
80
+ export const notificationsLayer: Layer.Layer<Notifications> = Layer.sync(Notifications, makeScheduler)
81
+
82
+ /**
83
+ * Resolve the scheduler a store should use: the runtime's `Notifications` if one
84
+ * is in context, else the global default. Uses `serviceOption` so it imposes no
85
+ * hard requirement — a store layer wired as a sibling of the runtime (the common
86
+ * pattern) simply falls back to the default.
87
+ */
88
+ export const resolveScheduler: Effect.Effect<Scheduler> = Effect.map(
89
+ Effect.serviceOption(Notifications),
90
+ Option.getOrElse(() => defaultScheduler),
91
+ )
@@ -1,4 +1,5 @@
1
- import { FiberRef } from 'effect';
1
+ import { FiberRef, GlobalValue } from 'effect'
2
+
2
3
  /**
3
4
  * The sanctioned tooling/test seam for seeding CLOSED scenes. A scene's layers
4
5
  * are pre-composed (each state group seeds its own live store), so an outer
@@ -13,5 +14,7 @@ import { FiberRef } from 'effect';
13
14
  * keeps a single ref instance even if the module is loaded twice (duplicated
14
15
  * bundles, HMR), so the writer and the reader always agree.
15
16
  */
16
- export declare const CurrentSeedOverrides: FiberRef.FiberRef<Readonly<Record<string, unknown>>>;
17
- //# sourceMappingURL=seeds.d.ts.map
17
+ export const CurrentSeedOverrides: FiberRef.FiberRef<Readonly<Record<string, unknown>>> =
18
+ GlobalValue.globalValue(Symbol.for('reform/CurrentSeedOverrides'), () =>
19
+ FiberRef.unsafeMake<Readonly<Record<string, unknown>>>({}),
20
+ )
@@ -0,0 +1,104 @@
1
+ import { type Context, Effect, Equal, Predicate } from 'effect'
2
+ import { type AnySource, type SourceName, type SourceValue } from '../state/token'
3
+ import { type Store } from './store'
4
+
5
+ // The reactive plumbing shared by `Calc` and `AsyncCalc`: read a list of input
6
+ // `Source`s, snapshot them by name, project a comparable invalidation key, and
7
+ // subscribe to changes. Both derived primitives sense their inputs the same way
8
+ // and differ only in what they do with a change (recompute vs. re-run a query).
9
+
10
+ /** The decoded inputs handed to a calc body, keyed by each source's name. */
11
+ export type InputsObject<Inputs extends ReadonlyArray<AnySource>> = {
12
+ readonly [S in Inputs[number] as SourceName<S>]: SourceValue<S>
13
+ }
14
+
15
+ /**
16
+ * The store services a list of store-carriers requires, as a *union of distinct*
17
+ * stores (each carrier has its own backing tag), not a single store of a union.
18
+ * Structural over the `store` tag, so it serves both calc inputs (`Source`s) and
19
+ * state-group members (`StateClass`es) — anything carrying a `store` tag.
20
+ */
21
+ export type StoresOf<Items extends ReadonlyArray<unknown>> = {
22
+ [I in keyof Items]: Items[I] extends { readonly store: Context.Tag<infer Svc, any> } ? Svc : never
23
+ }[number]
24
+
25
+ /** The store requirement a calc's inputs impose. */
26
+ export type InputStores<Inputs extends ReadonlyArray<AnySource>> = StoresOf<Inputs>
27
+
28
+ /** Project inputs into a comparable key; the calc reacts only when it moves. */
29
+ export type InvalidateBy<Inputs extends ReadonlyArray<AnySource>> = (
30
+ inputs: InputsObject<Inputs>,
31
+ ) => ReadonlyArray<unknown>
32
+
33
+ /**
34
+ * The runtime key an input contributes to the inputs object. Prefer the
35
+ * explicit `manifest.name` — the string handed to `make` — over the structural
36
+ * `name`: a `Calc`/`AsyncCalc` *class* satisfies `Source` through its static
37
+ * `name`, and a user subclass declaration (`class Feed extends Calc.make(…)`)
38
+ * defines its OWN `name` from the class binding, shadowing the explicit one.
39
+ * That binding is what minifiers rename, so keying the snapshot by `input.name`
40
+ * made every subclassed calc input `undefined` in production builds (and would
41
+ * silently mis-key in dev whenever the binding differs from the `make` name —
42
+ * the type-level key, `SourceName<S>`, is always the `make` string). The
43
+ * manifest is inherited through the static prototype chain and never shadowed.
44
+ * `StateToken`s carry no manifest; their `name` is an instance field holding
45
+ * the explicit member key, so the fallback is always the declared string.
46
+ */
47
+ const inputKey = (input: AnySource): string =>
48
+ Predicate.hasProperty(input, 'manifest') &&
49
+ Predicate.isRecord(input.manifest) &&
50
+ Predicate.isString(input.manifest.name)
51
+ ? input.manifest.name
52
+ : input.name
53
+
54
+ /** Element-wise value equality of two invalidation keys. */
55
+ export const sameKey = (a: ReadonlyArray<unknown>, b: ReadonlyArray<unknown>): boolean =>
56
+ a.length === b.length && a.every((value, i) => Equal.equals(value, b[i]))
57
+
58
+ /** The sensing surface both `Calc.live` and `AsyncCalc.live` build on. */
59
+ export interface WiredSources<Inputs extends ReadonlyArray<AnySource>> {
60
+ /** Snapshot every input by name — the decoded inputs object a calc body sees. */
61
+ readonly snapshot: () => InputsObject<Inputs>
62
+ /** The invalidation key for an inputs object: the projection, or all input values. */
63
+ readonly keyOf: (inputs: InputsObject<Inputs>) => ReadonlyArray<unknown>
64
+ /** Subscribe to every source at once; the returned thunk unsubscribes from all. */
65
+ readonly subscribe: (listener: () => void) => () => void
66
+ }
67
+
68
+ /**
69
+ * Read each input's backing store and return the shared sensing surface. The
70
+ * caller owns lifecycle: register the `subscribe` result's unsubscribe as a
71
+ * scope finalizer (both live bodies run under `Layer.scoped`).
72
+ */
73
+ export const wireSources = <Inputs extends ReadonlyArray<AnySource>>(
74
+ inputs: Inputs,
75
+ invalidateBy?: InvalidateBy<Inputs>,
76
+ ): Effect.Effect<WiredSources<Inputs>, never, InputStores<Inputs>> =>
77
+ Effect.gen(function* () {
78
+ const sources: Array<Store<unknown>> = []
79
+ for (const input of inputs) sources.push(yield* input.store)
80
+
81
+ const snapshot = (): InputsObject<Inputs> => {
82
+ const args: Record<string, unknown> = {}
83
+ inputs.forEach((input, i) => {
84
+ // `noUncheckedIndexedAccess`: `sources` is built 1:1 with `inputs`, but
85
+ // guard the index so the read is honestly total.
86
+ const source = sources[i]
87
+ if (source !== undefined) args[inputKey(input)] = source.getSnapshot()
88
+ })
89
+ return args as InputsObject<Inputs>
90
+ }
91
+ const keyOf = (args: InputsObject<Inputs>): ReadonlyArray<unknown> =>
92
+ invalidateBy ? invalidateBy(args) : Object.values(args)
93
+ const subscribe = (listener: () => void): (() => void) => {
94
+ const unsubscribes = sources.map((source) => source.subscribe(listener))
95
+ return () => {
96
+ for (const unsubscribe of unsubscribes) unsubscribe()
97
+ }
98
+ }
99
+ return { snapshot, keyOf, subscribe }
100
+ // Yielding each `input.store` (a `Context.Tag<Store<unknown>>`) widens the
101
+ // inferred requirement to `Store<unknown>`; restate the precise per-input
102
+ // `InputStores<Inputs>` union the signature promises. This is the single
103
+ // place that cast lives — `Calc.live`/`AsyncCalc.live` inherit it.
104
+ }) as Effect.Effect<WiredSources<Inputs>, never, InputStores<Inputs>>