@playfast/reform 1.0.1 → 1.1.1

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 (97) hide show
  1. package/README.md +18 -1
  2. package/package.json +1 -1
  3. package/src/boundary/boundary.test.ts +1 -33
  4. package/src/boundary/boundary.ts +4 -82
  5. package/src/calc/asyncCalc.invalidate.test.ts +2 -14
  6. package/src/calc/asyncCalc.test.ts +0 -38
  7. package/src/calc/asyncCalc.ts +11 -163
  8. package/src/calc/asyncCalcDefinitions.ts +48 -0
  9. package/src/calc/asyncData.ts +0 -37
  10. package/src/calc/calc.test.ts +4 -28
  11. package/src/calc/calc.ts +6 -46
  12. package/src/calc/calcFamily.test.ts +0 -12
  13. package/src/calc/calcFamily.ts +6 -48
  14. package/src/calc/compose.test.ts +1 -12
  15. package/src/calc/compose.ts +0 -22
  16. package/src/calc/queryState.ts +0 -23
  17. package/src/channel/channel.ts +72 -174
  18. package/src/channel/procedureRegistry.ts +90 -0
  19. package/src/compose/composition.ts +7 -67
  20. package/src/compose/host.ts +0 -6
  21. package/src/compose/props.ts +3 -9
  22. package/src/compose/provide.ts +19 -33
  23. package/src/compose/slot.ts +2 -30
  24. package/src/compose/structure.test.ts +0 -6
  25. package/src/compose/structure.ts +3 -67
  26. package/src/compose/ui.test.ts +0 -4
  27. package/src/compose/ui.ts +17 -111
  28. package/src/compose/ui.typecheck.ts +0 -14
  29. package/src/definition/definition.ts +0 -32
  30. package/src/event/event.test.ts +0 -3
  31. package/src/event/event.ts +5 -14
  32. package/src/event/eventGroup.ts +0 -1
  33. package/src/feature/feature.mount.test.ts +66 -54
  34. package/src/feature/feature.test.ts +67 -46
  35. package/src/feature/feature.ts +185 -214
  36. package/src/feature/feature.typecheck.ts +29 -16
  37. package/src/feature/featureBinding.ts +48 -0
  38. package/src/index.ts +245 -229
  39. package/src/internal/capture.ts +0 -20
  40. package/src/internal/ctx.ts +2 -9
  41. package/src/internal/env.ts +9 -0
  42. package/src/internal/errors.test.ts +0 -6
  43. package/src/internal/errors.ts +14 -48
  44. package/src/internal/inspect.test.ts +0 -6
  45. package/src/internal/inspect.ts +0 -12
  46. package/src/internal/queryDriver.ts +42 -243
  47. package/src/internal/queryDriverStore.ts +153 -0
  48. package/src/internal/queryDriverTypes.ts +57 -0
  49. package/src/internal/queryEvents.ts +0 -12
  50. package/src/internal/queryStore.ts +0 -14
  51. package/src/internal/reuse.test.ts +0 -11
  52. package/src/internal/reuse.ts +0 -28
  53. package/src/internal/scheduler.ts +0 -43
  54. package/src/internal/seeds.ts +0 -14
  55. package/src/internal/sources.ts +4 -41
  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 +0 -18
  60. package/src/internal/variance.ts +5 -0
  61. package/src/namespace/namespace.test.ts +46 -0
  62. package/src/namespace/namespace.ts +85 -0
  63. package/src/procedure/procedure.ts +2 -24
  64. package/src/reducer/reducer.ts +3 -21
  65. package/src/remote/remoteState.test.ts +92 -75
  66. package/src/remote/remoteState.ts +99 -486
  67. package/src/remote/remoteState.typecheck.ts +0 -33
  68. package/src/remote/remoteStateDefinition.ts +135 -0
  69. package/src/remote/remoteStateLayers.ts +118 -0
  70. package/src/remote/remoteStateLiveTypes.ts +59 -0
  71. package/src/remote/remoteStateSend.ts +64 -0
  72. package/src/runtime/appRuntime.test.ts +197 -0
  73. package/src/runtime/appRuntime.ts +40 -83
  74. package/src/runtime/bus.ts +2 -15
  75. package/src/runtime/eventBudget.test.ts +155 -0
  76. package/src/runtime/eventBudget.ts +119 -0
  77. package/src/runtime/hardening.test.ts +74 -9
  78. package/src/runtime/instrumentation.test.ts +213 -192
  79. package/src/runtime/instrumentation.ts +0 -49
  80. package/src/runtime/loop.test.ts +0 -17
  81. package/src/runtime/loop.ts +25 -55
  82. package/src/runtime/queries.ts +0 -17
  83. package/src/scene/featureScene.test.ts +59 -0
  84. package/src/scene/scene.ts +32 -59
  85. package/src/scene/seedScene.test.ts +96 -116
  86. package/src/state/state.ts +7 -26
  87. package/src/state/stateFamily.test.ts +2 -11
  88. package/src/state/stateFamily.ts +9 -52
  89. package/src/state/stateGroup.ts +2 -49
  90. package/src/state/token.ts +4 -17
  91. package/src/synced/syncedStore.ts +7 -42
  92. package/src/ui/node.ts +0 -6
  93. package/src/ui/trigger.ts +0 -5
  94. package/src/wire/tree.test.ts +0 -3
  95. package/src/wire/tree.ts +0 -39
  96. package/src/wire/triggers.test.ts +0 -4
  97. package/src/wire/triggers.ts +5 -27
@@ -1,12 +1,8 @@
1
1
  import { Context, Effect, Effectable } from 'effect'
2
2
  import type { Store } from '../internal/store'
3
+ import type { AnyValue } from '../internal/variance'
3
4
  import { readTracked } from '../internal/track'
4
5
 
5
- /**
6
- * A reference to one slice of state: yieldable to its current value (`yield*`
7
- * reads + — under React — subscribes) and carrying the metadata calcs need
8
- * (its `name` and backing store tag). Returned by `StateGroup.select(group, name)`.
9
- */
10
6
  export class StateToken<out N extends string, in out A> extends Effectable.Class<A, never, Store<A>> {
11
7
  readonly name: N
12
8
  readonly store: Context.Tag<Store<A>, Store<A>>
@@ -20,20 +16,11 @@ export class StateToken<out N extends string, in out A> extends Effectable.Class
20
16
  }
21
17
  }
22
18
 
23
- /**
24
- * Anything a derived value can read and depend on: a named slice backed by a
25
- * subscribable store tag. A `StateToken` (group member), a `Calc`, and an
26
- * `AsyncCalc` all structurally satisfy this — so a `Calc`/`AsyncCalc` can take
27
- * any of them as an input. The `name` becomes the key in the inputs object.
28
- *
29
- * `StateToken` is the one concrete, *yieldable* `Source`; `Calc`/`AsyncCalc`
30
- * satisfy the same shape so the three are interchangeable as calc inputs.
31
- */
32
19
  export interface Source<out N extends string, in out A> {
33
20
  readonly name: N
34
21
  readonly store: Context.Tag<Store<A>, Store<A>>
35
22
  }
36
23
 
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
24
+ export type AnySource = Source<string, AnyValue>
25
+ export type SourceName<S> = S extends Source<infer N, AnyValue> ? N : never
26
+ export type SourceValue<S> = S extends Source<AnyValue, infer A> ? A : never
@@ -1,27 +1,13 @@
1
- import { Context, Effect, Layer, Option, type Schema, type Scope } from 'effect'
1
+ import { Context, Effect, Layer, Option, type Scope } from 'effect'
2
+ import type { Effect as EffectType } from 'effect/Effect'
2
3
  import { type Manifest, yieldableClass } from '../definition/definition'
3
4
  import { resolveScheduler } from '../internal/scheduler'
4
5
  import { makeDerivedStore, type Store } from '../internal/store'
5
6
  import { readTracked } from '../internal/track'
6
7
  import type { Source } from '../state/token'
7
8
  import type { StateOptions } from '../state/state'
9
+ import type { AnySchema, AnyValue } from '../internal/variance'
8
10
 
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
11
  export interface SyncedSource<A> {
26
12
  readonly snapshot: () => A
27
13
  readonly subscribe: (onChange: () => void) => () => void
@@ -30,43 +16,30 @@ export interface SyncedSource<A> {
30
16
  export interface SyncedStoreManifest<N extends string, A> extends Manifest {
31
17
  readonly kind: 'SyncedStore'
32
18
  readonly name: N
33
- readonly schema: Schema.Schema<A, any>
19
+ readonly schema: AnySchema<A>
34
20
  readonly title: Option.Option<string>
35
21
  readonly description: Option.Option<string>
36
22
  }
37
23
 
38
24
  export interface SyncedStoreClass<out N extends string, in out A>
39
- extends Effect.Effect<A, never, Store<A>>,
25
+ extends EffectType<A, never, Store<A>>,
40
26
  Source<N, A> {
41
27
  new (): {}
42
28
  readonly manifest: SyncedStoreManifest<N, A>
43
- /** Internal DI tag holding the live store. `SyncedStore.live` allocates it. */
44
29
  readonly store: Context.Tag<Store<A>, Store<A>>
45
- /** The name, so the class doubles as a `Source` input to a calc/composition. */
46
30
  readonly name: N
47
31
  }
48
32
 
49
- /**
50
- * A minimal store-carrier — satisfied by a `SyncedStoreClass` and by any
51
- * companion primitive (e.g. `@playfast/reform-db`'s `DbQuery`) that allocates
52
- * its own `Store` tag, so `SyncedStore.live` is reusable beyond `SyncedStore`.
53
- */
54
33
  export interface StoreCarrier<A> {
55
34
  readonly store: Context.Tag<Store<A>, Store<A>>
56
35
  }
57
36
 
58
- export type AnySyncedStore = SyncedStoreClass<string, any>
37
+ export type AnySyncedStore = SyncedStoreClass<string, AnyValue>
59
38
  export type SyncedValue<S> = S extends SyncedStoreClass<string, infer A> ? A : never
60
39
 
61
- /**
62
- * Define an externally-synced slice: a reflectable manifest + an internal store
63
- * tag, yieldable to its current value and usable as a calc/composition `Source`.
64
- * Carries no source — that is supplied at wiring time by `SyncedStore.live`,
65
- * exactly as `State.make` defers its seed to `State.live`.
66
- */
67
40
  export const make = <const N extends string, A>(
68
41
  name: N,
69
- schema: Schema.Schema<A, any>,
42
+ schema: AnySchema<A>,
70
43
  options: StateOptions = {},
71
44
  ): SyncedStoreClass<N, A> => {
72
45
  const store = Context.GenericTag<Store<A>, Store<A>>(`reform/syncedStore/${name}`)
@@ -81,14 +54,6 @@ export const make = <const N extends string, A>(
81
54
  return yieldableClass(read, { manifest, store, name })
82
55
  }
83
56
 
84
- /**
85
- * Allocate a synced store's backing cell and bind it to an external source.
86
- * `acquire` yields the source within the layer's scope (so it can open a live
87
- * query / subscription whose `R` — e.g. a `Db` driver — is captured here); the
88
- * store mirrors `snapshot()` and re-notifies on every `subscribe` change through
89
- * the runtime's coalescing scheduler. The upstream subscription is released with
90
- * the layer's scope.
91
- */
92
57
  export const live = <A, R>(
93
58
  def: StoreCarrier<A>,
94
59
  acquire: Effect.Effect<SyncedSource<A>, never, R>,
package/src/ui/node.ts CHANGED
@@ -1,9 +1,3 @@
1
1
  import type { ReactNode } from 'react'
2
2
 
3
- /**
4
- * What a view renders. Reform targets React, so a node is a `ReactNode` — this
5
- * is a *type-only* dependency: core never imports the React runtime nor renders
6
- * anything itself (that is `@reform/react`). Typing it precisely lets the `.ui`
7
- * presentations author real JSX and lets slots be valid components.
8
- */
9
3
  export type Node = ReactNode
package/src/ui/trigger.ts CHANGED
@@ -1,6 +1 @@
1
- /**
2
- * A handler handed to the UI: a plain callback. Calling it dispatches the event
3
- * (High priority) through the runtime. The UI never sees Effect — it just calls
4
- * `events.submit({ text })`.
5
- */
6
1
  export type Trigger<P> = (payload: P) => void
@@ -13,9 +13,6 @@ const node = (id: string, over: Partial<WireNode> = {}): WireNode => ({
13
13
  ...over,
14
14
  })
15
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
16
  test('a fresh tree diffs to one upsert per node', () => {
20
17
  const next: WireTree = [node('a'), node('b', { parentId: 'a', slot: 'body' })]
21
18
  const patches = Wire.diff([], next)
package/src/wire/tree.ts CHANGED
@@ -1,55 +1,24 @@
1
1
  import { Array, Match, Option, Order, Record as Rec } from 'effect'
2
2
  import { sort as sortArray } from 'effect/Array'
3
3
 
4
- /**
5
- * The serializable model of a rendered UI tree, and the pure diff/apply over it.
6
- *
7
- * This is the renderer-neutral heart of the remote transport (REMOTE_UI.md §3):
8
- * the server renders a scene to a `WireTree`, sends `diff(prev, next)` as
9
- * `WirePatch`es, and the client folds them back with `apply`. No React, no
10
- * Effect — pure data, so both ends and the proofs share one source of truth.
11
- */
12
-
13
- /**
14
- * A prop on a wire node: either an already-encoded data value, or a handle the
15
- * client invokes to fire the server-side trigger (resolved by the trigger
16
- * registry, REMOTE_UI.md §4). Streams, when added, become a third arm.
17
- */
18
4
  export type WireProp =
19
5
  | { readonly _tag: 'Data'; readonly name: string; readonly value: unknown }
20
6
  | { readonly _tag: 'Event'; readonly name: string; readonly handle: string }
21
7
 
22
- /** One rendered UI contract instance, identified stably across frames. The serializable
23
- * wire-boundary shape (hence the `ExternalApi` postfix; `null` is part of the JSON contract). */
24
8
  export interface WireNodeExternalApi {
25
- /** Stable identity across renders — the unit of diffing. */
26
9
  readonly id: string
27
- /** The UI contract name (`UiCapture.name`) the client looks up a presentation by. */
28
10
  readonly name: string
29
- /** Parent node id, or `null` for a root. */
30
11
  readonly parentId: string | null
31
- /** Order among siblings under the same parent. */
32
12
  readonly childIndex: number
33
- /** The slot name this node fills in its parent, or `null` for a root / direct child. */
34
13
  readonly slot: string | null
35
- /**
36
- * The React `key` the parent gave this slot child (`createElement(slots.Row, { key })`),
37
- * or `null` when none was set. It is the per-child identity a KEYED slot selects on: the
38
- * client renders `<slots.Row slotKey={id} />` and the slot thunk picks the one wire child
39
- * whose `key` matches, instead of rendering every child of that slot at every call site.
40
- * Without it the client slot is render-all-children (correct for singleton slots, wrong for
41
- * a list slot invoked once per item — it duplicates the whole list under each call).
42
- */
43
14
  readonly key: string | null
44
15
  readonly props: ReadonlyArray<WireProp>
45
16
  }
46
17
 
47
- /** Public alias preserving the established name across the codebase. */
48
18
  export type WireNode = WireNodeExternalApi
49
19
 
50
20
  export type WireTree = ReadonlyArray<WireNode>
51
21
 
52
- /** A change to apply to a client's tree: upsert a node, or drop one by id. */
53
22
  export type WirePatch =
54
23
  | { readonly _tag: 'Upsert'; readonly node: WireNode }
55
24
  | { readonly _tag: 'Delete'; readonly id: string }
@@ -75,7 +44,6 @@ const recordsEqual = (left: Record<string, unknown>, right: Record<string, unkno
75
44
  )
76
45
  }
77
46
 
78
- /** Structural equality over serializable wire values (and the tagged props that carry them). */
79
47
  const deepEqual = ({ left, right }: EqualPair): boolean => {
80
48
  if (left === right) {
81
49
  return true
@@ -108,10 +76,6 @@ const isUnchanged = (node: WireNode, previousById: ReadonlyMap<string, WireNode>
108
76
  return previous !== undefined && nodesEqual(previous, node)
109
77
  }
110
78
 
111
- /**
112
- * The patches that turn `previous` into `next`. Deletes precede upserts so a
113
- * client never holds a child whose reparented slot was freed in the same frame.
114
- */
115
79
  export const diff = (previous: WireTree, next: WireTree): ReadonlyArray<WirePatch> => {
116
80
  const previousById = new Map(previous.map((node) => [node.id, node]))
117
81
  const nextById = new Map(next.map((node) => [node.id, node]))
@@ -136,7 +100,6 @@ const upsertNode = (state: WireTree, node: WireNode): WireTree =>
136
100
  },
137
101
  )
138
102
 
139
- /** Fold patches into a client's tree (the receiving side's reducer). */
140
103
  export const apply = (state: WireTree, patches: ReadonlyArray<WirePatch>): WireTree =>
141
104
  patches.reduce(
142
105
  (current, patch) =>
@@ -150,14 +113,12 @@ export const apply = (state: WireTree, patches: ReadonlyArray<WirePatch>): WireT
150
113
 
151
114
  const bySiblingOrder = Order.mapInput(Order.number, (node: WireNode) => node.childIndex)
152
115
 
153
- /** The root nodes of a tree, in sibling order. */
154
116
  export const roots = (tree: WireTree): WireTree =>
155
117
  sortArray(
156
118
  tree.filter((node) => node.parentId === null),
157
119
  bySiblingOrder,
158
120
  )
159
121
 
160
- /** The children of a node, in sibling order. */
161
122
  export const childrenOf = (tree: WireTree, parentId: string): WireTree =>
162
123
  sortArray(
163
124
  tree.filter((node) => node.parentId === parentId),
@@ -5,10 +5,6 @@ import type { Trigger } from '../index'
5
5
 
6
6
  const ByPayload = S.Struct({ by: S.Number })
7
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
8
  test('re-registering a handle overwrites it with the fresh trigger', () => {
13
9
  const seen = Effect.runSync(
14
10
  Effect.gen(function* () {
@@ -1,29 +1,12 @@
1
1
  import { type Cause, Context, Data, Effect, Layer, type ParseResult, Ref, Schema } from 'effect'
2
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
- */
3
+ import type { AnySchema, AnyValue } from '../internal/variance'
18
4
 
19
5
  export type TriggerHandle = string
20
6
 
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
7
  interface TriggerEntry {
25
- readonly trigger: Trigger<any>
26
- readonly schema: Schema.Schema<any, any>
8
+ readonly trigger: Trigger<AnyValue>
9
+ readonly schema: Schema.Schema.AnyNoContext
27
10
  }
28
11
 
29
12
  const UnknownTriggerBase: new (args: { readonly handle: TriggerHandle }) => Cause.YieldableError & {
@@ -32,7 +15,6 @@ const UnknownTriggerBase: new (args: { readonly handle: TriggerHandle }) => Caus
32
15
  readonly handle: TriggerHandle
33
16
  }>
34
17
 
35
- /** A wire message named a trigger handle the server no longer holds (a stale client). */
36
18
  export class UnknownTrigger extends UnknownTriggerBase {
37
19
  override get message(): string {
38
20
  return `reform: no trigger registered under handle '${this.handle}'`
@@ -40,18 +22,15 @@ export class UnknownTrigger extends UnknownTriggerBase {
40
22
  }
41
23
 
42
24
  export interface TriggerRegistryApi {
43
- /** Register (or overwrite) a trigger + its payload schema under a handle key. */
44
25
  readonly register: <P>(
45
26
  handle: TriggerHandle,
46
27
  trigger: Trigger<P>,
47
- schema: Schema.Schema<P, any>,
28
+ schema: AnySchema<P>,
48
29
  ) => Effect.Effect<void>
49
- /** Decode an encoded payload via the handle's schema and fire the trigger. */
50
30
  readonly invoke: (
51
31
  handle: TriggerHandle,
52
32
  encodedPayload: unknown,
53
33
  ) => Effect.Effect<void, UnknownTrigger | ParseResult.ParseError>
54
- /** Drop a handle once its node unmounts (the Phase 2 tree diff signals it). */
55
34
  readonly revoke: (handle: TriggerHandle) => Effect.Effect<void>
56
35
  }
57
36
 
@@ -69,7 +48,7 @@ export const make: Effect.Effect<TriggerRegistryApi> = Effect.gen(function* () {
69
48
  const register = <P>(
70
49
  handle: TriggerHandle,
71
50
  trigger: Trigger<P>,
72
- schema: Schema.Schema<P, any>,
51
+ schema: AnySchema<P>,
73
52
  ): Effect.Effect<void> =>
74
53
  Ref.update(entries, (map) => new Map(map).set(handle, { trigger, schema }))
75
54
 
@@ -95,5 +74,4 @@ export const make: Effect.Effect<TriggerRegistryApi> = Effect.gen(function* () {
95
74
  return { register, invoke, revoke }
96
75
  })
97
76
 
98
- /** Provide a fresh registry — one per remote connection (each client gets its own handle space). */
99
77
  export const layer: Layer.Layer<TriggerRegistry> = Layer.effect(TriggerRegistry, make)