@playfast/reform 1.0.1 → 1.2.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.
Files changed (99) hide show
  1. package/README.md +103 -56
  2. package/package.json +17 -17
  3. package/src/boundary/boundary.test.ts +63 -49
  4. package/src/boundary/boundary.ts +144 -113
  5. package/src/calc/asyncCalc.invalidate.test.ts +518 -32
  6. package/src/calc/asyncCalc.test.ts +207 -213
  7. package/src/calc/asyncCalc.ts +131 -154
  8. package/src/calc/asyncData.ts +0 -37
  9. package/src/calc/calc.test.ts +7 -33
  10. package/src/calc/calc.ts +44 -58
  11. package/src/calc/calcFamily.test.ts +80 -34
  12. package/src/calc/calcFamily.ts +54 -65
  13. package/src/calc/compose.test.ts +1 -12
  14. package/src/calc/compose.ts +1 -36
  15. package/src/calc/queryState.ts +0 -23
  16. package/src/channel/channel.ts +126 -111
  17. package/src/compose/composition.test.ts +19 -0
  18. package/src/compose/composition.ts +351 -127
  19. package/src/compose/host.ts +0 -6
  20. package/src/compose/props.ts +13 -12
  21. package/src/compose/provide.ts +189 -62
  22. package/src/compose/slot.ts +158 -49
  23. package/src/compose/structure.test.ts +6 -9
  24. package/src/compose/structure.ts +108 -79
  25. package/src/compose/ui.test.ts +19 -7
  26. package/src/compose/ui.ts +155 -156
  27. package/src/compose/ui.typecheck.ts +40 -18
  28. package/src/definition/definition.ts +22 -41
  29. package/src/event/event.fromSource.test.ts +172 -0
  30. package/src/event/event.test.ts +10 -3
  31. package/src/event/event.ts +102 -27
  32. package/src/event/eventGroup.ts +0 -1
  33. package/src/feature/feature.mount.test.ts +122 -43
  34. package/src/feature/feature.test.ts +46 -21
  35. package/src/feature/feature.ts +1347 -256
  36. package/src/feature/feature.typecheck.ts +273 -68
  37. package/src/graph/closure.ts +403 -0
  38. package/src/index.ts +68 -126
  39. package/src/internal/bucketCache.ts +39 -0
  40. package/src/internal/capture.ts +9 -24
  41. package/src/internal/env.ts +7 -0
  42. package/src/internal/errors.test.ts +0 -6
  43. package/src/internal/errors.ts +37 -60
  44. package/src/internal/inspect.test.ts +0 -6
  45. package/src/internal/inspect.ts +0 -12
  46. package/src/internal/queryDriver.ts +105 -201
  47. package/src/internal/queryDriverStore.ts +156 -0
  48. package/src/internal/queryDriverTypes.ts +59 -0
  49. package/src/internal/queryEvents.ts +0 -12
  50. package/src/internal/queryStore.ts +0 -14
  51. package/src/internal/reuse.test.ts +10 -14
  52. package/src/internal/reuse.ts +5 -30
  53. package/src/internal/scheduler.ts +4 -44
  54. package/src/internal/seeds.ts +0 -14
  55. package/src/internal/sources.ts +26 -49
  56. package/src/internal/stateRegistry.ts +0 -14
  57. package/src/internal/store.test.ts +1 -3
  58. package/src/internal/store.ts +0 -37
  59. package/src/internal/track.ts +3 -20
  60. package/src/internal/variance.ts +5 -0
  61. package/src/internal.ts +222 -0
  62. package/src/namespace/namespace.test.ts +46 -0
  63. package/src/namespace/namespace.ts +106 -0
  64. package/src/procedure/procedure.ts +40 -35
  65. package/src/reducer/reducer.ts +78 -52
  66. package/src/remote/remoteState.test.ts +590 -397
  67. package/src/remote/remoteState.ts +425 -347
  68. package/src/remote/remoteState.typecheck.ts +47 -56
  69. package/src/runtime/appRuntime.activation.test.ts +100 -0
  70. package/src/runtime/appRuntime.test.ts +249 -0
  71. package/src/runtime/appRuntime.ts +415 -97
  72. package/src/runtime/bus.ts +2 -15
  73. package/src/runtime/eventBudget.test.ts +152 -0
  74. package/src/runtime/eventBudget.ts +120 -0
  75. package/src/runtime/hardening.test.ts +73 -13
  76. package/src/runtime/instrumentation.test.ts +55 -52
  77. package/src/runtime/instrumentation.ts +6 -60
  78. package/src/runtime/loop.test.ts +36 -17
  79. package/src/runtime/loop.ts +87 -88
  80. package/src/runtime/queries.ts +0 -17
  81. package/src/scene/featureScene.test.ts +61 -0
  82. package/src/scene/scene.ts +247 -104
  83. package/src/scene/seedScene.test.ts +42 -79
  84. package/src/state/state.nominal.typecheck.ts +68 -0
  85. package/src/state/state.test.ts +17 -0
  86. package/src/state/state.ts +80 -46
  87. package/src/state/stateFamily.test.ts +16 -11
  88. package/src/state/stateFamily.ts +55 -65
  89. package/src/state/stateGroup.ts +53 -64
  90. package/src/state/token.ts +40 -23
  91. package/src/synced/syncedStore.ts +46 -58
  92. package/src/testkit/flight.testkit.ts +75 -0
  93. package/src/ui/node.ts +0 -6
  94. package/src/ui/trigger.ts +0 -5
  95. package/src/wire/tree.test.ts +8 -7
  96. package/src/wire/tree.ts +0 -39
  97. package/src/wire/triggers.test.ts +6 -6
  98. package/src/wire/triggers.ts +14 -32
  99. package/src/internal/ctx.ts +0 -16
package/README.md CHANGED
@@ -13,7 +13,7 @@ State, events, reducers, derived values, async & remote data, and compositions
13
13
 
14
14
  ---
15
15
 
16
- reform is an application framework built on [Effect](https://effect.website). You describe your app as **definitions** — state, events, reducers, calculations, compositions — and provide their behavior **separately** as Effect layers. The core renders nothing: a host package ([`@playfast/reform-react`](https://www.npmjs.com/package/@playfast/reform-react), [`@playfast/reform-react-native`](https://www.npmjs.com/package/@playfast/reform-react-native)) turns a closed *scene* into a live tree, and [`@playfast/reform-proof`](https://www.npmjs.com/package/@playfast/reform-proof) drives that same scene headlessly in tests. One model, three consumers, no seam between them.
16
+ reform is an application framework built on [Effect](https://effect.website). You describe your app as **definitions** — state, events, reducers, calculations, compositions — and provide their behavior **separately** as Effect layers. The core renders nothing: a host package ([`@playfast/reform-react`](https://www.npmjs.com/package/@playfast/reform-react), [`@playfast/reform-react-native`](https://www.npmjs.com/package/@playfast/reform-react-native)) turns a closed _scene_ into a live tree, and [`@playfast/reform-proof`](https://www.npmjs.com/package/@playfast/reform-proof) drives that same scene headlessly in tests. One model, three consumers, no seam between them.
17
17
 
18
18
  ## Install
19
19
 
@@ -62,24 +62,24 @@ class Bump extends Reducer.make('Bump', { states: [Count], events: [Bumped] }) {
62
62
  const BumpLive = Reducer.live(Bump, (n, e) => n + e.by)
63
63
  ```
64
64
 
65
- A `State` lives in a plain reactive store; reducers only *return* values, so by construction they are the **only** writers. Reads return the current snapshot through Effect.
65
+ A `State` lives in a plain reactive store; reducers only _return_ values, so by construction they are the **only** writers. Reads return the current snapshot through Effect.
66
66
 
67
67
  ### The primitives at a glance
68
68
 
69
- | primitive | definition | implementation |
70
- | --- | --- | --- |
71
- | [`State` / `StateGroup` / `StateFamily`](#state) | `State.make(name, schema, opts?)` | `.live(initial)` |
72
- | [`Event` / `EventGroup`](#event) | `Event.make(name, schema)` | — (pure data) |
73
- | [`Reducer`](#reducer) | `Reducer.make(name, { states / family, events })` | `.live(fold)` |
74
- | [`Calc` / `CalcFamily`](#calc) | `Calc.make(name, { inputs, output })` | `.live(fn)` |
75
- | [`AsyncCalc`](#asynccalc) | `AsyncCalc.make(name, { inputs, output, error?, alwaysOn? })` | `.live({ query, … })` |
76
- | [`RemoteState`](#remotestate) | `RemoteState.make(name, { inputs, output, error?, alwaysOn?, intents })` | `.live({ query, send, apply, … })` |
77
- | [`Boundary`](#boundary) | `Boundary.make(name, { over })` | `.live({ once? })` |
78
- | [`Procedure`](#procedure) | `Procedure.make(name, { events, channel })` | `.live(fn*)` |
79
- | [`Channel`](#procedure) | `Channel.make(name, { policy })` | `.live()` |
80
- | [`Composition`](#composition) | `Composition.make(name, manifest)` | `.live(fn*)` |
81
- | [`ui` / `slot`](#composition) | `ui(name)<C>()` / `slot(name)<Comp>()` | `Ui.make` / `provide` |
82
- | [`Feature`](#feature) | `Feature.make(name, config)` | (eager `module` / lazy `load`) |
69
+ | primitive | definition | implementation |
70
+ | ------------------------------------------------ | ------------------------------------------------------------------------ | ---------------------------------- |
71
+ | [`State` / `StateGroup` / `StateFamily`](#state) | `State.make(name, schema, opts?)` | `.live(initial)` |
72
+ | [`Event` / `EventGroup`](#event) | `Event.make(name, schema)` | — (pure data) |
73
+ | [`Reducer`](#reducer) | `Reducer.make(name, { states / family, events })` | `.live(fold)` |
74
+ | [`Calc` / `CalcFamily`](#calc) | `Calc.make(name, { inputs, output })` | `.live(fn)` |
75
+ | [`AsyncCalc`](#asynccalc) | `AsyncCalc.make(name, { inputs, output, error?, alwaysOn? })` | `.live({ query, … })` |
76
+ | [`RemoteState`](#remotestate) | `RemoteState.make(name, { inputs, output, error?, alwaysOn?, intents })` | `.live({ query, send, apply, … })` |
77
+ | [`Boundary`](#boundary) | `Boundary.make(name, { over })` | `.live({ once? })` |
78
+ | [`Procedure`](#procedure) | `Procedure.make(name, { events, channel })` | `.live(fn*)` |
79
+ | [`Channel`](#procedure) | `Channel.make(name, { policy })` | `.live()` |
80
+ | [`Composition`](#composition) | `Composition.make(name, manifest)` | `.live(fn*)` |
81
+ | [`ui` / `slot`](#composition) | `ui(name)<C>()` / `slot(name, identity)<Comp>()` | `Ui.make` / `provide` |
82
+ | [`Feature`](#feature) | `Feature.make(name, config)` | (eager `module` / lazy `load`) |
83
83
 
84
84
  ## The model
85
85
 
@@ -98,6 +98,7 @@ procedure ──► Bus (Normal)┘ │
98
98
  ---
99
99
 
100
100
  <a name="state"></a>
101
+
101
102
  ## State · StateGroup · StateFamily
102
103
 
103
104
  Reactive stores held outside Effect. Reducers are the sole writers; everything else reads.
@@ -119,13 +120,13 @@ A bundle of related states provided together. `StateGroup.select(Group, 'name')`
119
120
  import { State, StateGroup } from '@playfast/reform'
120
121
 
121
122
  class Count extends State.make('count', S.Number) {}
122
- class Step extends State.make('step', S.Number) {}
123
+ class Step extends State.make('step', S.Number) {}
123
124
  class Counter extends StateGroup.make(Count, Step) {}
124
125
 
125
126
  const CounterLive = StateGroup.live(Counter, { count: 0, step: 1 }) // all members required
126
127
 
127
128
  // read inside any Effect / composition:
128
- const count = yield* StateGroup.select(Counter, 'count')
129
+ const count = yield * StateGroup.select(Counter, 'count')
129
130
  ```
130
131
 
131
132
  ### `StateFamily`
@@ -149,13 +150,14 @@ const item = yield* StateFamily.read(Items, id)
149
150
  ---
150
151
 
151
152
  <a name="event"></a>
153
+
152
154
  ## Event · EventGroup
153
155
 
154
156
  Events are pure tagged data — definitions only, no `.live`.
155
157
 
156
158
  ```ts
157
- Event.make(name, schema) // → EventClass; EventOf<N, P> = { _tag: N } & P
158
- EventGroup.make(...events) // → bundle, used in Reducer/Composition manifests
159
+ Event.make(name, schema) // → EventClass; EventOf<N, P> = { _tag: N } & P
160
+ EventGroup.make(...events) // → bundle, used in Reducer/Composition manifests
159
161
 
160
162
  class LoadedTodos extends Event.make('LoadedTodos', S.Struct({ todos: S.Array(Todo) })) {}
161
163
  ```
@@ -164,11 +166,11 @@ class LoadedTodos extends Event.make('LoadedTodos', S.Struct({ todos: S.Array(To
164
166
 
165
167
  ```ts
166
168
  // From a UI view — High priority, synchronous:
167
- const toggle = yield* Event.trigger(ToggledTodo) // toggle: (payload) => void
169
+ const toggle = yield * Event.trigger(ToggledTodo) // toggle: (payload) => void
168
170
  // ...later: <input onChange={() => toggle({ id })} />
169
171
 
170
172
  // From a procedure — Normal priority:
171
- yield* Event.dispatch(TodoUpserted, { todo })
173
+ yield * Event.dispatch(TodoUpserted, { todo })
172
174
  ```
173
175
 
174
176
  `Event.trigger` returns a plain callback (`Trigger<P>`) you hand to the view; `Event.dispatch` returns an `Effect` you yield inside logic.
@@ -176,6 +178,7 @@ yield* Event.dispatch(TodoUpserted, { todo })
176
178
  ---
177
179
 
178
180
  <a name="reducer"></a>
181
+
179
182
  ## Reducer
180
183
 
181
184
  The only writers of state. A fold is a **pure, synchronous** `(value, event) => value` (async values throw at startup).
@@ -195,9 +198,13 @@ const FeedReducerLive = Reducer.live(FeedReducer, (feed, event) =>
195
198
  Match.value(event).pipe(
196
199
  Match.tags({
197
200
  StartedLoading: (): Feed => ({ _tag: 'Loading' }),
198
- LoadedTodos: ({ todos }): Feed => ({ _tag: 'Ok', todos }),
199
- FailedTodos: ({ message }): Feed => ({ _tag: 'Error', message }),
200
- TodoRemoved: ({ id }) => onOk(feed, Array.filter((t) => t.id !== id)),
201
+ LoadedTodos: ({ todos }): Feed => ({ _tag: 'Ok', todos }),
202
+ FailedTodos: ({ message }): Feed => ({ _tag: 'Error', message }),
203
+ TodoRemoved: ({ id }) =>
204
+ onOk(
205
+ feed,
206
+ Array.filter((t) => t.id !== id),
207
+ ),
201
208
  }),
202
209
  Match.exhaustive,
203
210
  ),
@@ -209,6 +216,7 @@ An event outside the reducer's declared `events` never reaches the fold; the dra
209
216
  ---
210
217
 
211
218
  <a name="calc"></a>
219
+
212
220
  ## Calc · CalcFamily
213
221
 
214
222
  Synchronous derived values — memoized projections over one or more sources.
@@ -231,15 +239,26 @@ const positive = yield* IsPositive // read the memoized value
231
239
  **`CalcFamily`** parameterizes a calc by key, one memoized store per key over shared inputs — each member notifies only its own subscribers:
232
240
 
233
241
  ```ts
234
- class GroupView extends CalcFamily.make('GroupView', { key: GroupId, inputs: [Board], output: GroupSchema }) {}
235
- const GroupViewLive = CalcFamily.live(GroupView, (id) => ({ Board }) => project(Board, id), { evictWhenUnused: true })
242
+ class GroupView extends CalcFamily.make('GroupView', {
243
+ key: GroupId,
244
+ inputs: [Board],
245
+ output: GroupSchema,
246
+ }) {}
247
+ const GroupViewLive = CalcFamily.live(
248
+ GroupView,
249
+ (id) =>
250
+ ({ Board }) =>
251
+ project(Board, id),
252
+ { evictWhenUnused: true },
253
+ )
236
254
 
237
- const view = yield* CalcFamily.read(GroupView, groupId)
255
+ const view = yield * CalcFamily.read(GroupView, groupId)
238
256
  ```
239
257
 
240
258
  ---
241
259
 
242
260
  <a name="asynccalc"></a>
261
+
243
262
  ## AsyncCalc & AsyncData
244
263
 
245
264
  Server reads with a stale-while-revalidate lifecycle. The query re-runs reactively from its inputs.
@@ -259,12 +278,12 @@ AsyncCalc.live(AsyncCalc, {
259
278
  - `error` omitted ⇒ the query is infallible (no `Error` arm). `alwaysOn: true` ⇒ no `Idle` arm and `disabled` is rejected.
260
279
  - Reading `yield* MyAsyncCalc` yields an **`AsyncData<A, E, Gated>`**:
261
280
 
262
- | arm | `_tag` | fields | when |
263
- | --- | --- | --- | --- |
264
- | `AsyncIdle` | `'Idle'` | — | gated query is `disabled` |
265
- | `AsyncLoading` | `'Loading'` | — | first fetch, no value yet |
281
+ | arm | `_tag` | fields | when |
282
+ | -------------- | ----------- | --------------------- | ------------------------------------------------ |
283
+ | `AsyncIdle` | `'Idle'` | — | gated query is `disabled` |
284
+ | `AsyncLoading` | `'Loading'` | — | first fetch, no value yet |
266
285
  | `AsyncSuccess` | `'Success'` | `value`, `refetching` | succeeded (`refetching: true` while re-fetching) |
267
- | `AsyncError` | `'Error'` | `error`, `refetching` | failed |
286
+ | `AsyncError` | `'Error'` | `error`, `refetching` | failed |
268
287
 
269
288
  ```ts
270
289
  class Doubled extends AsyncCalc.make('Doubled', {
@@ -276,7 +295,7 @@ const DoubledLive = AsyncCalc.live(Doubled, {
276
295
  query: ({ count }) => Effect.succeed(count * 2),
277
296
  })
278
297
 
279
- const data = yield* Doubled
298
+ const data = yield * Doubled
280
299
  Match.value(data).pipe(
281
300
  Match.tag('Loading', () => spinner),
282
301
  Match.tag('Success', ({ value }) => render(value)),
@@ -287,9 +306,10 @@ Match.value(data).pipe(
287
306
  ---
288
307
 
289
308
  <a name="remotestate"></a>
309
+
290
310
  ## RemoteState
291
311
 
292
- Server-owned state with optimistic mutations, fused into one primitive. **Remote state is derived-only:** the only writer is the server, the only write surface is dispatching a declared *intent*, and the visible value is `pending.reduce(apply, serverTruth)`.
312
+ Server-owned state with optimistic mutations, fused into one primitive. **Remote state is derived-only:** the only writer is the server, the only write surface is dispatching a declared _intent_, and the visible value is `pending.reduce(apply, serverTruth)`.
293
313
 
294
314
  ```ts
295
315
  RemoteState.make(name, { inputs, output, error?, alwaysOn?, intents }) // intents: Event definitions
@@ -318,23 +338,25 @@ class Board extends RemoteState.make('Board', {
318
338
  }) {}
319
339
 
320
340
  const BoardLive = RemoteState.live(Board, {
321
- query: ({ route }) => loadBoard(route.boardId),
322
- send: (intent) => Match.value(intent).pipe(
323
- Match.tag('ItemAddIntent', ({ groupId, name }) => client.AddItem({ groupId, name })),
324
- Match.tag('ItemRenameIntent', ({ itemId, name }) => client.RenameItem({ id: itemId, name })),
325
- Match.exhaustive,
326
- ),
327
- apply: (snapshot, intent) => applyIntent(snapshot, intent), // idempotent
341
+ query: ({ route }) => loadBoard(route.boardId),
342
+ send: (intent) =>
343
+ Match.value(intent).pipe(
344
+ Match.tag('ItemAddIntent', ({ groupId, name }) => client.AddItem({ groupId, name })),
345
+ Match.tag('ItemRenameIntent', ({ itemId, name }) => client.RenameItem({ id: itemId, name })),
346
+ Match.exhaustive,
347
+ ),
348
+ apply: (snapshot, intent) => applyIntent(snapshot, intent), // idempotent
328
349
  invalidateOn: [BoardChanged],
329
350
  coalesce: 'trailing',
330
351
  })
331
352
  ```
332
353
 
333
- **Semantics.** A dispatched intent appends to the queue and appears in the overlay in the same flush. On send failure the intent settles immediately and `Board.Failed` fires — there is **no rollback machinery**, the derivation just converges. The **generation rule** guarantees an optimistic change never flickers out between the RPC confirming and the refetch landing: an intent acked at query generation *g* is settled only by a later-generation `Success` run.
354
+ **Semantics.** A dispatched intent appends to the queue and appears in the overlay in the same flush. On send failure the intent settles immediately and `Board.Failed` fires — there is **no rollback machinery**, the derivation just converges. The **generation rule** guarantees an optimistic change never flickers out between the RPC confirming and the refetch landing: an intent acked at query generation _g_ is settled only by a later-generation `Success` run.
334
355
 
335
356
  ---
336
357
 
337
358
  <a name="boundary"></a>
359
+
338
360
  ## Boundary
339
361
 
340
362
  Merges the lifecycle arms of several async sources into one first-load signal, so a subtree shows a single fallback instead of per-query spinners.
@@ -360,6 +382,7 @@ const boot = yield* BootBoundary // BoundaryState
360
382
  ---
361
383
 
362
384
  <a name="procedure"></a>
385
+
363
386
  ## Procedure & Channel
364
387
 
365
388
  Procedures are side-effecting reactions to events, running on forked fibers. A **channel** is the named concurrency lane they run on.
@@ -377,14 +400,17 @@ The generator body receives the matched event, can read services from context, a
377
400
  ```ts
378
401
  class ActionsChannel extends Channel.make('Actions', { policy: { _tag: 'merge' } }) {}
379
402
 
380
- class CreateTodo extends Procedure.make('CreateTodo', { events: [SubmittedNewTodo], channel: ActionsChannel }) {}
403
+ class CreateTodo extends Procedure.make('CreateTodo', {
404
+ events: [SubmittedNewTodo],
405
+ channel: ActionsChannel,
406
+ }) {}
381
407
  const CreateTodoLive = Procedure.live(CreateTodo, function* (event) {
382
408
  const client = yield* TodosClient
383
409
  const result = yield* Effect.either(client.AddTodo({ text: event.text }))
384
410
  yield* Match.value(result).pipe(
385
411
  Match.tags({
386
412
  Right: ({ right }) => Event.dispatch(TodoUpserted, { todo: right }),
387
- Left: ({ left }) => Event.dispatch(FailedTodos, { message: String(left) }),
413
+ Left: ({ left }) => Event.dispatch(FailedTodos, { message: String(left) }),
388
414
  }),
389
415
  Match.exhaustive,
390
416
  )
@@ -396,6 +422,7 @@ Policies: `merge` (unbounded concurrency), `latest` (new event cancels the in-fl
396
422
  ---
397
423
 
398
424
  <a name="composition"></a>
425
+
399
426
  ## Composition · ui · slot · provide
400
427
 
401
428
  A **composition** is a unit of logic that reads state and renders a typed UI contract. The contract (`ui`) and its presentation are authored separately, so logic stays renderer-neutral.
@@ -426,12 +453,13 @@ const CounterView = provide(CounterUi, Ui.make(CounterUi, ({ count }, _slots, {
426
453
 
427
454
  - **`Composition.make(name, manifest)`** — manifest declares `ui` plus the `states` / `calcs` / `events` / `slots` it touches (all reflectable).
428
455
  - **`Composition.live(comp, fn*)`** — the generator reads sources and returns a `Node` (the view applied to props/events).
429
- - **`slot(name)<Comp>()`** — a hole a parent composition declares (`slots: { body: BodySlot }`) and fills with `provide(BodySlot, ChildComposition)` (or a `Feature`). Children render through the host.
456
+ - **`slot(name, identity)<Comp>()`** — a hole a parent composition declares (`slots: { body: BodySlot }`) and fills with `provide(BodySlot, ChildComposition)` (or a `Feature`). Declare `identity` as a named module-level unique symbol (`const BodySlotIdentity: unique symbol = Symbol('BodySlot')`); `Symbol.for(...)` and inline `Symbol(...)` arguments are forbidden. Reform generates a separate runtime key for every slot definition, while `name` remains a display label, so independently authored same-name slots remain distinct. Children render through the host.
430
457
  - **`provide(...)`** — binds a UI presentation to a contract, or fills a slot with a composition / feature. Returns a `Layer`.
431
458
 
432
459
  ---
433
460
 
434
461
  <a name="feature"></a>
462
+
435
463
  ## Feature
436
464
 
437
465
  The lazy/eager code-split unit. Definitions stay eager and reflectable; the heavy implementation can load on demand as an **Effect** (never a bare Promise), so code-splitting composes with the rest of the layer graph.
@@ -449,22 +477,36 @@ class Counter extends Feature.make('counter', {
449
477
  // lazy: defer the module behind an import
450
478
  class Reports extends Feature.make('reports', {
451
479
  loadingStrategy: 'lazy',
480
+ composition: ReportsComp,
452
481
  load: lazyImport(() => import('./reports.module')),
482
+ eager: featureModule([], PlaceholderLive),
453
483
  placeholder: { loading: SpinnerComp, failed: RetryComp },
454
484
  }) {}
455
485
  ```
456
486
 
457
- A feature shares the app's engine but gets its own scope — `mountFeature(binding, engineContext)` loads it, builds its layer, dispatches its boot events, and disposes everything when the scope closes. Fill a slot with one via `provide(SomeSlot, Reports)`; the host paints the placeholders while it loads.
487
+ A feature's `requires` tuple accepts shared State/Calc carriers, StateGroups, and
488
+ ordinary Effect `Context.Tag`s. A lazy feature shares the app's engine but gets
489
+ its own scope — `mountFeature(binding, parentContext)` loads it, builds its layer
490
+ against the immediate parent, dispatches its boot events, and disposes everything
491
+ when the scope closes. Fill a slot with one via `provide(SomeSlot, Reports)`;
492
+ `provide` merges its optional eager placeholder module automatically while the
493
+ host paints the placeholders and loads the main module.
494
+
495
+ Default Features retain their binding identity too. Their module is merged by
496
+ `provide`, their composition renders synchronously from the parent runtime, and
497
+ their boot events run once for each keyed activation.
458
498
 
459
499
  ---
460
500
 
461
501
  <a name="scene"></a>
502
+
462
503
  ## Scene
463
504
 
464
505
  A scene bundles a root composition with the closed wiring that runs it — the single handle the React host, React Native host, and proofs all consume.
465
506
 
466
507
  ```ts
467
508
  scene(composition, { provide: [...layers], boot?: [...events] }) // → Scene
509
+ featureScene(eagerFeature, { provide: closedEnvironment }) // → Scene
468
510
  seedScene(base, seeds) // tooling: override seed values
469
511
  isScene(value) // reflection guard
470
512
 
@@ -476,9 +518,14 @@ const AppScene = scene(AppRoot, {
476
518
 
477
519
  `provide` is the list of layers that close the app (they must resolve to `MountedServices` — every composition's render service plus the `Bus`). Hand the scene to [`@playfast/reform-react`](https://www.npmjs.com/package/@playfast/reform-react) to mount or to [`@playfast/reform-proof`](https://www.npmjs.com/package/@playfast/reform-proof) to assert. `seedScene` overlays seed values onto already-closed layers (used by the dev tool to preview alternative initial state).
478
520
 
521
+ `featureScene` is the typed root helper for a default/eager Feature. Its `provide`
522
+ layer closes the engine plus the Feature's declared requirements; lazy Features
523
+ are rejected because an application root must be immediately available.
524
+
479
525
  ---
480
526
 
481
527
  <a name="engine"></a>
528
+
482
529
  ## Engine & runtime surface
483
530
 
484
531
  `Engine` is the layer that ties a runtime together: it provides the `Bus` (one `PubSub`), the `Reducers` / `Channels` / `Procedures` registries, and forks the single drain loop. Merge it at the root of your app's layers:
@@ -547,15 +594,15 @@ Mount it with [`@playfast/reform-react`](https://www.npmjs.com/package/@playfast
547
594
 
548
595
  ## The reform family
549
596
 
550
- | Package | Role |
551
- | --- | --- |
552
- | **`@playfast/reform`** | Renderer-neutral core (this package) |
553
- | [`@playfast/reform-react`](https://www.npmjs.com/package/@playfast/reform-react) | React / DOM host |
554
- | [`@playfast/reform-react-native`](https://www.npmjs.com/package/@playfast/reform-react-native) | React Native host |
555
- | [`@playfast/reform-forms`](https://www.npmjs.com/package/@playfast/reform-forms) | Headless form state |
556
- | [`@playfast/reform-forms-react`](https://www.npmjs.com/package/@playfast/reform-forms-react) | Typed JSX mapping for forms |
557
- | [`@playfast/reform-proof`](https://www.npmjs.com/package/@playfast/reform-proof) | Headless testing toolkit |
558
- | [`@playfast/reform-eslint-plugin`](https://www.npmjs.com/package/@playfast/reform-eslint-plugin) | Lint rules for reform conventions |
597
+ | Package | Role |
598
+ | ------------------------------------------------------------------------------------------------ | ------------------------------------ |
599
+ | **`@playfast/reform`** | Renderer-neutral core (this package) |
600
+ | [`@playfast/reform-react`](https://www.npmjs.com/package/@playfast/reform-react) | React / DOM host |
601
+ | [`@playfast/reform-react-native`](https://www.npmjs.com/package/@playfast/reform-react-native) | React Native host |
602
+ | [`@playfast/reform-forms`](https://www.npmjs.com/package/@playfast/reform-forms) | Headless form state |
603
+ | [`@playfast/reform-forms-react`](https://www.npmjs.com/package/@playfast/reform-forms-react) | Typed JSX mapping for forms |
604
+ | [`@playfast/reform-proof`](https://www.npmjs.com/package/@playfast/reform-proof) | Headless testing toolkit |
605
+ | [`@playfast/reform-eslint-plugin`](https://www.npmjs.com/package/@playfast/reform-eslint-plugin) | Lint rules for reform conventions |
559
606
 
560
607
  ## License
561
608
 
package/package.json CHANGED
@@ -1,36 +1,38 @@
1
1
  {
2
2
  "name": "@playfast/reform",
3
- "playbook": "./playbook",
4
- "version": "1.0.1",
5
- "type": "module",
3
+ "version": "1.2.0",
6
4
  "description": "The renderer-neutral core of the reform framework — typed, headless state, events, reducers, derived values, async/remote data, and compositions built on Effect.",
7
5
  "keywords": [
8
- "reform",
9
6
  "effect",
10
- "state-management",
11
- "headless",
12
7
  "framework",
13
- "reactive"
8
+ "headless",
9
+ "reactive",
10
+ "reform",
11
+ "state-management"
14
12
  ],
13
+ "bugs": {
14
+ "url": "https://github.com/playfast/reform/issues"
15
+ },
15
16
  "license": "MIT",
16
17
  "repository": {
17
18
  "type": "git",
18
19
  "url": "https://github.com/playfast/reform.git",
19
20
  "directory": "packages/reform"
20
21
  },
21
- "bugs": {
22
- "url": "https://github.com/playfast/reform/issues"
23
- },
22
+ "files": [
23
+ "src",
24
+ "README.md"
25
+ ],
26
+ "type": "module",
24
27
  "sideEffects": false,
25
28
  "exports": {
26
29
  "./package.json": "./package.json",
27
30
  ".": "./src/index.ts",
28
31
  "./*": "./src/*.ts"
29
32
  },
30
- "files": [
31
- "src",
32
- "README.md"
33
- ],
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
34
36
  "scripts": {
35
37
  "clean": "rm -rf dist .tsbuildinfo",
36
38
  "check": "tsc --noEmit",
@@ -50,7 +52,5 @@
50
52
  "optional": true
51
53
  }
52
54
  },
53
- "publishConfig": {
54
- "access": "public"
55
- }
55
+ "playbook": "./playbook"
56
56
  }
@@ -2,12 +2,6 @@ import { expect, it } from '@effect/vitest'
2
2
  import { Data, Duration, Effect, Layer, Schema as S } from 'effect'
3
3
  import { AsyncCalc, Boundary, Calc, Engine, Event, State, StateGroup } from '../index'
4
4
 
5
- // Boundary merges several async lifecycles into one Pending/Errored/Ready value
6
- // — the Suspense-boundary analog, value-level. The behaviours under test: the
7
- // arm mapping (Loading pends, Idle and SWR-refetching count Ready, Error
8
- // surfaces), the chained-gate settle-gap pin, the `once` latch, and the
9
- // notification economy (arm-stable churn wakes nobody).
10
-
11
5
  const tick = (ms = 10) => Effect.sleep(Duration.millis(ms))
12
6
 
13
7
  const until = <A>(read: () => A, pred: (a: A) => boolean, rounds = 200): Effect.Effect<A> =>
@@ -37,8 +31,12 @@ it.live('pending while ANY covered query is on its first load; one Ready when al
37
31
  const TestLayer = Boundary.live(Gate).pipe(
38
32
  Layer.provideMerge(
39
33
  Layer.merge(
40
- AsyncCalc.live(Fast, { query: ({ seed }) => Effect.succeed(seed).pipe(Effect.delay('10 millis')) }),
41
- AsyncCalc.live(Slow, { query: ({ seed }) => Effect.succeed(seed).pipe(Effect.delay('40 millis')) }),
34
+ AsyncCalc.live(Fast, {
35
+ query: ({ seed }) => Effect.succeed(seed).pipe(Effect.delay('10 millis')),
36
+ }),
37
+ AsyncCalc.live(Slow, {
38
+ query: ({ seed }) => Effect.succeed(seed).pipe(Effect.delay('40 millis')),
39
+ }),
42
40
  ),
43
41
  ),
44
42
  Layer.provideMerge(StateGroup.live(Inputs, { seed: 1 })),
@@ -51,13 +49,13 @@ it.live('pending while ANY covered query is on its first load; one Ready when al
51
49
  })
52
50
  expect(store.get()._tag).toBe('Pending')
53
51
 
54
- // The fast query settles first — still Pending (the slow one holds it).
55
52
  yield* tick(20)
56
53
  expect(store.get()._tag).toBe('Pending')
57
54
 
58
- yield* until(() => store.get()._tag, (t) => t === 'Ready')
59
- // Exactly ONE notification fired across the whole boot: Pending→Pending
60
- // churn (Fast settling) was absorbed by the stable arm reference.
55
+ yield* until(
56
+ () => store.get()._tag,
57
+ (t) => t === 'Ready',
58
+ )
61
59
  expect(observed).toEqual(['Ready'])
62
60
  }).pipe(Effect.provide(TestLayer))
63
61
  })
@@ -86,9 +84,10 @@ it.live('a gated query that is OFF counts Ready — the off arm must not hold th
86
84
  )
87
85
  return Effect.gen(function* () {
88
86
  const store = yield* Gate.store
89
- // The gated query is Idle (deliberately off): only the always-on first
90
- // load pends, and Ready arrives without ever waiting on the Idle arm.
91
- yield* until(() => store.get()._tag, (t) => t === 'Ready')
87
+ yield* until(
88
+ () => store.get()._tag,
89
+ (t) => t === 'Ready',
90
+ )
92
91
  expect(store.get()._tag).toBe('Ready')
93
92
  }).pipe(Effect.provide(TestLayer))
94
93
  })
@@ -114,13 +113,21 @@ it.live('an SWR refetch never re-pends — Success with refetching: true is Read
114
113
  const seed = yield* StateGroup.select(Inputs, 'seed').store
115
114
  const qStore = yield* Q.store
116
115
  const store = yield* Gate.store
117
- yield* until(() => store.get()._tag, (t) => t === 'Ready')
116
+ yield* until(
117
+ () => store.get()._tag,
118
+ (t) => t === 'Ready',
119
+ )
118
120
 
119
- // Move the input: the query goes Success+refetching (SWR) — Ready holds.
120
121
  seed.set(2)
121
- yield* until(() => qStore.get(), (q) => q._tag === 'Success' && q.refetching)
122
+ yield* until(
123
+ () => qStore.get(),
124
+ (q) => q._tag === 'Success' && q.refetching,
125
+ )
122
126
  expect(store.get()._tag).toBe('Ready')
123
- yield* until(() => qStore.get(), (q) => q._tag === 'Success' && !q.refetching)
127
+ yield* until(
128
+ () => qStore.get(),
129
+ (q) => q._tag === 'Success' && !q.refetching,
130
+ )
124
131
  expect(store.get()._tag).toBe('Ready')
125
132
  }).pipe(Effect.provide(TestLayer))
126
133
  })
@@ -150,28 +157,27 @@ it.live('a first-load failure surfaces as Errored with the failure, and a retry
150
157
  )
151
158
  return Effect.gen(function* () {
152
159
  const store = yield* Gate.store
153
- const errored = yield* until(() => store.get(), (s) => s._tag === 'Errored')
160
+ const errored = yield* until(
161
+ () => store.get(),
162
+ (s) => s._tag === 'Errored',
163
+ )
154
164
  if (errored._tag === 'Errored') {
155
165
  expect(errored.errors).toHaveLength(1)
156
166
  const failure = errored.errors[0]
157
167
  expect(failure instanceof Boom && failure.message).toBe('server down')
158
168
  }
159
169
 
160
- // The retry path: the server heals, a listed fact refetches, Ready lands.
161
170
  healthy.now = true
162
171
  yield* Event.dispatch(Poke, {})
163
- yield* until(() => store.get()._tag, (t) => t === 'Ready')
172
+ yield* until(
173
+ () => store.get()._tag,
174
+ (t) => t === 'Ready',
175
+ )
164
176
  expect(store.get()._tag).toBe('Ready')
165
177
  }).pipe(Effect.provide(TestLayer))
166
178
  })
167
179
 
168
180
  it.live('settle-gap pin: a chained gate never leaks Ready between hops', () => {
169
- // B enables only when A has succeeded — the app's Session → Bootstrap shape.
170
- // The dangerous instant: A flips Success while B is still Idle (Idle counts
171
- // Ready), so a naive merge could notify Ready before B's driver flips it to
172
- // Loading. Layer dependency order (B's driver subscribes to A's store before
173
- // the boundary does) plus the one-microtask fixpoint flush close the gap;
174
- // this records every notification to prove no intermediate Ready escapes.
175
181
  class Seed extends State.make('seed', S.Number) {}
176
182
  class Inputs extends StateGroup.make(Seed) {}
177
183
  class A extends AsyncCalc.make('A', {
@@ -204,20 +210,16 @@ it.live('settle-gap pin: a chained gate never leaks Ready between hops', () => {
204
210
  })
205
211
  expect(store.get()._tag).toBe('Pending')
206
212
 
207
- yield* until(() => store.get()._tag, (t) => t === 'Ready')
208
- yield* tick(30) // let any straggler notification land before judging
209
- // ONE Ready, at the end — never a Ready while B's hop was still pending.
213
+ yield* until(
214
+ () => store.get()._tag,
215
+ (t) => t === 'Ready',
216
+ )
217
+ yield* tick(30)
210
218
  expect(observed).toEqual(['Ready'])
211
219
  }).pipe(Effect.provide(TestLayer))
212
220
  })
213
221
 
214
222
  it.live('once + a Calc hop in the chain: the latch never freezes a mid-flush Ready', () => {
215
- // The app's boot shape: AsyncCalc A → plain Calc projecting it → AsyncCalc B
216
- // gated on the projection. Propagation crosses TWO scheduler-flush rounds,
217
- // so when A settles there is a mid-flush frame where B has not yet flipped
218
- // to Loading — a recompute-time latch would freeze that intermediate Ready
219
- // and the boundary would abandon B's whole first load (caught live by the
220
- // boot-frames walk: nav in, boards card still spinning).
221
223
  class Seed extends State.make('seed', S.Number) {}
222
224
  class Inputs extends StateGroup.make(Seed) {}
223
225
  class A extends AsyncCalc.make('A', {
@@ -247,20 +249,24 @@ it.live('once + a Calc hop in the chain: the latch never freezes a mid-flush Rea
247
249
  return Effect.gen(function* () {
248
250
  const bStore = yield* B.store
249
251
  const store = yield* Gate.store
250
- // While B's first load is in flight the boundary MUST still be Pending —
251
- // a poisoned latch would already report Ready here.
252
- yield* until(() => bStore.get()._tag, (t) => t === 'Loading')
252
+ yield* until(
253
+ () => bStore.get()._tag,
254
+ (t) => t === 'Loading',
255
+ )
253
256
  expect(store.get()._tag).toBe('Pending')
254
- yield* until(() => bStore.get()._tag, (t) => t === 'Success')
255
- yield* until(() => store.get()._tag, (t) => t === 'Ready')
257
+ yield* until(
258
+ () => bStore.get()._tag,
259
+ (t) => t === 'Success',
260
+ )
261
+ yield* until(
262
+ () => store.get()._tag,
263
+ (t) => t === 'Ready',
264
+ )
256
265
  expect(store.get()._tag).toBe('Ready')
257
266
  }).pipe(Effect.provide(TestLayer))
258
267
  })
259
268
 
260
269
  it.live('once: a covered gate enabling AFTER the latch never re-pends the boundary', () => {
261
- // The boot-boundary semantics: a route-gated query (the board) first loads
262
- // on a later navigation — its own screen owns that loading state, the shell
263
- // splash must not come back.
264
270
  class Route extends State.make('routeFlag', S.Boolean) {}
265
271
  class Inputs extends StateGroup.make(Route) {}
266
272
  class Always extends AsyncCalc.make('Always', {
@@ -289,13 +295,21 @@ it.live('once: a covered gate enabling AFTER the latch never re-pends the bounda
289
295
  const route = yield* StateGroup.select(Inputs, 'routeFlag').store
290
296
  const routedStore = yield* Routed.store
291
297
  const store = yield* Gate.store
292
- yield* until(() => store.get()._tag, (t) => t === 'Ready')
298
+ yield* until(
299
+ () => store.get()._tag,
300
+ (t) => t === 'Ready',
301
+ )
293
302
 
294
- // "Navigate": the routed query enables and goes Loading — latched Ready holds.
295
303
  route.set(true)
296
- yield* until(() => routedStore.get()._tag, (t) => t === 'Loading')
304
+ yield* until(
305
+ () => routedStore.get()._tag,
306
+ (t) => t === 'Loading',
307
+ )
297
308
  expect(store.get()._tag).toBe('Ready')
298
- yield* until(() => routedStore.get()._tag, (t) => t === 'Success')
309
+ yield* until(
310
+ () => routedStore.get()._tag,
311
+ (t) => t === 'Success',
312
+ )
299
313
  expect(store.get()._tag).toBe('Ready')
300
314
  }).pipe(Effect.provide(TestLayer))
301
315
  })