@playfast/reform 0.0.5 → 0.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/package.json +31 -10
  2. package/src/boundary/boundary.test.ts +301 -0
  3. package/src/calc/asyncCalc.test.ts +556 -0
  4. package/src/calc/asyncData.ts +9 -1
  5. package/src/calc/calc.test.ts +287 -0
  6. package/src/calc/calcFamily.test.ts +206 -0
  7. package/src/calc/compose.test.ts +68 -0
  8. package/src/channel/channel.ts +9 -5
  9. package/src/compose/host.ts +4 -1
  10. package/src/compose/props.ts +5 -1
  11. package/src/compose/ui.test.ts +62 -0
  12. package/src/compose/ui.ts +173 -44
  13. package/src/compose/ui.typecheck.ts +56 -0
  14. package/src/definition/definition.ts +2 -0
  15. package/src/event/event.test.ts +23 -0
  16. package/src/feature/feature.mount.test.ts +82 -0
  17. package/src/feature/feature.test.ts +60 -0
  18. package/src/feature/feature.typecheck.ts +108 -0
  19. package/src/index.ts +38 -2
  20. package/src/internal/capture.ts +4 -4
  21. package/src/internal/errors.test.ts +33 -0
  22. package/src/internal/errors.ts +92 -16
  23. package/src/internal/inspect.test.ts +28 -0
  24. package/src/internal/queryDriver.ts +1 -1
  25. package/src/internal/reuse.test.ts +116 -0
  26. package/src/internal/scheduler.ts +3 -1
  27. package/src/internal/sources.ts +2 -2
  28. package/src/internal/stateRegistry.ts +32 -0
  29. package/src/internal/store.test.ts +80 -0
  30. package/src/internal/track.ts +3 -1
  31. package/src/remote/remoteState.test.ts +695 -0
  32. package/src/remote/remoteState.typecheck.ts +195 -0
  33. package/src/runtime/bus.ts +6 -2
  34. package/src/runtime/hardening.test.ts +69 -0
  35. package/src/runtime/loop.test.ts +178 -0
  36. package/src/runtime/loop.ts +4 -2
  37. package/src/scene/seedScene.test.ts +169 -0
  38. package/src/state/state.ts +4 -1
  39. package/src/state/stateFamily.test.ts +138 -0
  40. package/src/state/stateFamily.ts +4 -1
  41. package/src/state/stateGroup.ts +31 -4
  42. package/src/state/token.ts +5 -4
  43. package/src/synced/syncedStore.ts +99 -0
  44. package/src/wire/tree.test.ts +81 -0
  45. package/src/wire/tree.ts +129 -0
  46. package/src/wire/triggers.test.ts +76 -0
  47. package/src/wire/triggers.ts +98 -0
package/package.json CHANGED
@@ -1,24 +1,45 @@
1
1
  {
2
2
  "name": "@playfast/reform",
3
- "version": "0.0.5",
3
+ "playbook": "./playbook",
4
+ "version": "0.0.8",
4
5
  "type": "module",
5
- "sideEffects": [],
6
+ "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
+ "keywords": [
8
+ "reform",
9
+ "effect",
10
+ "state-management",
11
+ "headless",
12
+ "framework",
13
+ "reactive"
14
+ ],
6
15
  "license": "MIT",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "https://github.com/playfast/reform.git",
19
+ "directory": "packages/reform"
20
+ },
21
+ "bugs": {
22
+ "url": "https://github.com/playfast/reform/issues"
23
+ },
24
+ "sideEffects": false,
7
25
  "exports": {
8
26
  "./package.json": "./package.json",
9
- ".": "./src/index.ts"
27
+ ".": "./src/index.ts",
28
+ "./*": "./src/*.ts"
10
29
  },
11
30
  "files": [
12
31
  "src",
13
- "README.md",
14
- "!src/**/*.test.ts",
15
- "!src/**/*.test.tsx",
16
- "!src/**/*.typecheck.ts",
17
- "!src/**/*.typecheck.tsx"
32
+ "README.md"
18
33
  ],
19
34
  "scripts": {
20
- "typecheck": "tsc --noEmit",
21
- "test": "vitest run"
35
+ "clean": "rm -rf dist .tsbuildinfo",
36
+ "check": "tsc --noEmit",
37
+ "build": "tsc -p tsconfig.build.json",
38
+ "test": "vitest run",
39
+ "test:watch": "vitest",
40
+ "coverage": "vitest run --coverage",
41
+ "lint": "oxlint src",
42
+ "lint:fix": "oxlint --fix src"
22
43
  },
23
44
  "peerDependencies": {
24
45
  "effect": "*",
@@ -0,0 +1,301 @@
1
+ import { expect, it } from '@effect/vitest'
2
+ import { Data, Duration, Effect, Layer, Schema as S } from 'effect'
3
+ import { AsyncCalc, Boundary, Calc, Engine, Event, State, StateGroup } from '../index'
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
+ const tick = (ms = 10) => Effect.sleep(Duration.millis(ms))
12
+
13
+ const until = <A>(read: () => A, pred: (a: A) => boolean, rounds = 200): Effect.Effect<A> =>
14
+ Effect.suspend(() => {
15
+ const a = read()
16
+ return pred(a) || rounds <= 0
17
+ ? Effect.succeed(a)
18
+ : Effect.flatMap(tick(10), () => until(read, pred, rounds - 1))
19
+ })
20
+
21
+ class Boom extends Data.TaggedError('Boom')<{ readonly message: string }> {}
22
+
23
+ it.live('pending while ANY covered query is on its first load; one Ready when all settle', () => {
24
+ class Seed extends State.make('seed', S.Number) {}
25
+ class Inputs extends StateGroup.make(Seed) {}
26
+ class Fast extends AsyncCalc.make('Fast', {
27
+ inputs: [StateGroup.select(Inputs, 'seed')],
28
+ output: S.Number,
29
+ alwaysOn: true,
30
+ }) {}
31
+ class Slow extends AsyncCalc.make('Slow', {
32
+ inputs: [StateGroup.select(Inputs, 'seed')],
33
+ output: S.Number,
34
+ alwaysOn: true,
35
+ }) {}
36
+ class Gate extends Boundary.make('Gate', { over: [Fast, Slow] }) {}
37
+ const TestLayer = Boundary.live(Gate).pipe(
38
+ Layer.provideMerge(
39
+ 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')) }),
42
+ ),
43
+ ),
44
+ Layer.provideMerge(StateGroup.live(Inputs, { seed: 1 })),
45
+ )
46
+ return Effect.gen(function* () {
47
+ const store = yield* Gate.store
48
+ const observed: Array<string> = []
49
+ store.subscribe(() => {
50
+ observed.push(store.get()._tag)
51
+ })
52
+ expect(store.get()._tag).toBe('Pending')
53
+
54
+ // The fast query settles first — still Pending (the slow one holds it).
55
+ yield* tick(20)
56
+ expect(store.get()._tag).toBe('Pending')
57
+
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.
61
+ expect(observed).toEqual(['Ready'])
62
+ }).pipe(Effect.provide(TestLayer))
63
+ })
64
+
65
+ it.live('a gated query that is OFF counts Ready — the off arm must not hold the gate', () => {
66
+ class Flag extends State.make('flag', S.Boolean) {}
67
+ class Inputs extends StateGroup.make(Flag) {}
68
+ class Always extends AsyncCalc.make('Always', {
69
+ inputs: [StateGroup.select(Inputs, 'flag')],
70
+ output: S.Number,
71
+ alwaysOn: true,
72
+ }) {}
73
+ class Gated extends AsyncCalc.make('Gated', {
74
+ inputs: [StateGroup.select(Inputs, 'flag')],
75
+ output: S.Number,
76
+ }) {}
77
+ class Gate extends Boundary.make('Gate', { over: [Always, Gated] }) {}
78
+ const TestLayer = Boundary.live(Gate).pipe(
79
+ Layer.provideMerge(
80
+ Layer.merge(
81
+ AsyncCalc.live(Always, { query: () => Effect.succeed(1) }),
82
+ AsyncCalc.live(Gated, { query: () => Effect.succeed(2), disabled: ({ flag }) => !flag }),
83
+ ),
84
+ ),
85
+ Layer.provideMerge(StateGroup.live(Inputs, { flag: false })),
86
+ )
87
+ return Effect.gen(function* () {
88
+ 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')
92
+ expect(store.get()._tag).toBe('Ready')
93
+ }).pipe(Effect.provide(TestLayer))
94
+ })
95
+
96
+ it.live('an SWR refetch never re-pends — Success with refetching: true is Ready', () => {
97
+ class Seed extends State.make('seed', S.Number) {}
98
+ class Inputs extends StateGroup.make(Seed) {}
99
+ class Q extends AsyncCalc.make('Q', {
100
+ inputs: [StateGroup.select(Inputs, 'seed')],
101
+ output: S.Number,
102
+ alwaysOn: true,
103
+ }) {}
104
+ class Gate extends Boundary.make('Gate', { over: [Q] }) {}
105
+ const TestLayer = Boundary.live(Gate).pipe(
106
+ Layer.provideMerge(
107
+ AsyncCalc.live(Q, {
108
+ query: ({ seed }) => Effect.succeed(seed * 2).pipe(Effect.delay('15 millis')),
109
+ }),
110
+ ),
111
+ Layer.provideMerge(StateGroup.live(Inputs, { seed: 1 })),
112
+ )
113
+ return Effect.gen(function* () {
114
+ const seed = yield* StateGroup.select(Inputs, 'seed').store
115
+ const qStore = yield* Q.store
116
+ const store = yield* Gate.store
117
+ yield* until(() => store.get()._tag, (t) => t === 'Ready')
118
+
119
+ // Move the input: the query goes Success+refetching (SWR) — Ready holds.
120
+ seed.set(2)
121
+ yield* until(() => qStore.get(), (q) => q._tag === 'Success' && q.refetching)
122
+ expect(store.get()._tag).toBe('Ready')
123
+ yield* until(() => qStore.get(), (q) => q._tag === 'Success' && !q.refetching)
124
+ expect(store.get()._tag).toBe('Ready')
125
+ }).pipe(Effect.provide(TestLayer))
126
+ })
127
+
128
+ it.live('a first-load failure surfaces as Errored with the failure, and a retry recovers', () => {
129
+ class Seed extends State.make('seed', S.Number) {}
130
+ class Inputs extends StateGroup.make(Seed) {}
131
+ class Poke extends Event.make('Poke', S.Struct({})) {}
132
+ const healthy = { now: false }
133
+ class Q extends AsyncCalc.make('Q', {
134
+ inputs: [StateGroup.select(Inputs, 'seed')],
135
+ output: S.Number,
136
+ error: S.instanceOf(Boom),
137
+ alwaysOn: true,
138
+ }) {}
139
+ class Gate extends Boundary.make('Gate', { over: [Q] }) {}
140
+ const TestLayer = Boundary.live(Gate).pipe(
141
+ Layer.provideMerge(
142
+ AsyncCalc.live(Q, {
143
+ query: () =>
144
+ healthy.now ? Effect.succeed(1) : Effect.fail(new Boom({ message: 'server down' })),
145
+ invalidateOn: [Poke],
146
+ }),
147
+ ),
148
+ Layer.provideMerge(StateGroup.live(Inputs, { seed: 1 })),
149
+ Layer.provideMerge(Engine),
150
+ )
151
+ return Effect.gen(function* () {
152
+ const store = yield* Gate.store
153
+ const errored = yield* until(() => store.get(), (s) => s._tag === 'Errored')
154
+ if (errored._tag === 'Errored') {
155
+ expect(errored.errors).toHaveLength(1)
156
+ const failure = errored.errors[0]
157
+ expect(failure instanceof Boom && failure.message).toBe('server down')
158
+ }
159
+
160
+ // The retry path: the server heals, a listed fact refetches, Ready lands.
161
+ healthy.now = true
162
+ yield* Event.dispatch(Poke, {})
163
+ yield* until(() => store.get()._tag, (t) => t === 'Ready')
164
+ expect(store.get()._tag).toBe('Ready')
165
+ }).pipe(Effect.provide(TestLayer))
166
+ })
167
+
168
+ 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
+ class Seed extends State.make('seed', S.Number) {}
176
+ class Inputs extends StateGroup.make(Seed) {}
177
+ class A extends AsyncCalc.make('A', {
178
+ inputs: [StateGroup.select(Inputs, 'seed')],
179
+ output: S.Number,
180
+ alwaysOn: true,
181
+ }) {}
182
+ class B extends AsyncCalc.make('B', {
183
+ inputs: [A],
184
+ output: S.Number,
185
+ }) {}
186
+ class Gate extends Boundary.make('Gate', { over: [A, B] }) {}
187
+ const TestLayer = Boundary.live(Gate).pipe(
188
+ Layer.provideMerge(
189
+ AsyncCalc.live(B, {
190
+ query: () => Effect.succeed(2).pipe(Effect.delay('15 millis')),
191
+ disabled: ({ A: a }) => a._tag !== 'Success',
192
+ }),
193
+ ),
194
+ Layer.provideMerge(
195
+ AsyncCalc.live(A, { query: () => Effect.succeed(1).pipe(Effect.delay('15 millis')) }),
196
+ ),
197
+ Layer.provideMerge(StateGroup.live(Inputs, { seed: 1 })),
198
+ )
199
+ return Effect.gen(function* () {
200
+ const store = yield* Gate.store
201
+ const observed: Array<string> = []
202
+ store.subscribe(() => {
203
+ observed.push(store.get()._tag)
204
+ })
205
+ expect(store.get()._tag).toBe('Pending')
206
+
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.
210
+ expect(observed).toEqual(['Ready'])
211
+ }).pipe(Effect.provide(TestLayer))
212
+ })
213
+
214
+ 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
+ class Seed extends State.make('seed', S.Number) {}
222
+ class Inputs extends StateGroup.make(Seed) {}
223
+ class A extends AsyncCalc.make('A', {
224
+ inputs: [StateGroup.select(Inputs, 'seed')],
225
+ output: S.Number,
226
+ alwaysOn: true,
227
+ }) {}
228
+ class Hop extends Calc.make('Hop', { inputs: [A], output: S.Number }) {}
229
+ class B extends AsyncCalc.make('B', {
230
+ inputs: [Hop],
231
+ output: S.Number,
232
+ }) {}
233
+ class Gate extends Boundary.make('Gate', { over: [A, B] }) {}
234
+ const TestLayer = Boundary.live(Gate, { once: true }).pipe(
235
+ Layer.provideMerge(
236
+ AsyncCalc.live(B, {
237
+ query: () => Effect.succeed(2).pipe(Effect.delay('25 millis')),
238
+ disabled: ({ Hop: hop }) => hop < 0,
239
+ }),
240
+ ),
241
+ Layer.provideMerge(Calc.live(Hop, ({ A: a }) => (a._tag === 'Success' ? a.value : -1))),
242
+ Layer.provideMerge(
243
+ AsyncCalc.live(A, { query: () => Effect.succeed(1).pipe(Effect.delay('15 millis')) }),
244
+ ),
245
+ Layer.provideMerge(StateGroup.live(Inputs, { seed: 1 })),
246
+ )
247
+ return Effect.gen(function* () {
248
+ const bStore = yield* B.store
249
+ 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')
253
+ expect(store.get()._tag).toBe('Pending')
254
+ yield* until(() => bStore.get()._tag, (t) => t === 'Success')
255
+ yield* until(() => store.get()._tag, (t) => t === 'Ready')
256
+ expect(store.get()._tag).toBe('Ready')
257
+ }).pipe(Effect.provide(TestLayer))
258
+ })
259
+
260
+ 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
+ class Route extends State.make('routeFlag', S.Boolean) {}
265
+ class Inputs extends StateGroup.make(Route) {}
266
+ class Always extends AsyncCalc.make('Always', {
267
+ inputs: [StateGroup.select(Inputs, 'routeFlag')],
268
+ output: S.Number,
269
+ alwaysOn: true,
270
+ }) {}
271
+ class Routed extends AsyncCalc.make('Routed', {
272
+ inputs: [StateGroup.select(Inputs, 'routeFlag')],
273
+ output: S.Number,
274
+ }) {}
275
+ class Gate extends Boundary.make('Gate', { over: [Always, Routed] }) {}
276
+ const TestLayer = Boundary.live(Gate, { once: true }).pipe(
277
+ Layer.provideMerge(
278
+ Layer.merge(
279
+ AsyncCalc.live(Always, { query: () => Effect.succeed(1) }),
280
+ AsyncCalc.live(Routed, {
281
+ query: () => Effect.succeed(2).pipe(Effect.delay('20 millis')),
282
+ disabled: ({ routeFlag }) => !routeFlag,
283
+ }),
284
+ ),
285
+ ),
286
+ Layer.provideMerge(StateGroup.live(Inputs, { routeFlag: false })),
287
+ )
288
+ return Effect.gen(function* () {
289
+ const route = yield* StateGroup.select(Inputs, 'routeFlag').store
290
+ const routedStore = yield* Routed.store
291
+ const store = yield* Gate.store
292
+ yield* until(() => store.get()._tag, (t) => t === 'Ready')
293
+
294
+ // "Navigate": the routed query enables and goes Loading — latched Ready holds.
295
+ route.set(true)
296
+ yield* until(() => routedStore.get()._tag, (t) => t === 'Loading')
297
+ expect(store.get()._tag).toBe('Ready')
298
+ yield* until(() => routedStore.get()._tag, (t) => t === 'Success')
299
+ expect(store.get()._tag).toBe('Ready')
300
+ }).pipe(Effect.provide(TestLayer))
301
+ })