@playfast/reform 1.1.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 (85) hide show
  1. package/README.md +85 -55
  2. package/package.json +17 -17
  3. package/src/boundary/boundary.test.ts +62 -16
  4. package/src/boundary/boundary.ts +141 -32
  5. package/src/calc/asyncCalc.invalidate.test.ts +516 -18
  6. package/src/calc/asyncCalc.test.ts +207 -175
  7. package/src/calc/asyncCalc.ts +168 -39
  8. package/src/calc/calc.test.ts +3 -5
  9. package/src/calc/calc.ts +44 -18
  10. package/src/calc/calcFamily.test.ts +80 -22
  11. package/src/calc/calcFamily.ts +56 -25
  12. package/src/calc/compose.ts +1 -14
  13. package/src/channel/channel.ts +128 -11
  14. package/src/compose/composition.test.ts +19 -0
  15. package/src/compose/composition.ts +346 -62
  16. package/src/compose/props.ts +13 -6
  17. package/src/compose/provide.ts +187 -46
  18. package/src/compose/slot.ts +156 -19
  19. package/src/compose/structure.test.ts +6 -3
  20. package/src/compose/structure.ts +106 -13
  21. package/src/compose/ui.test.ts +19 -3
  22. package/src/compose/ui.ts +147 -54
  23. package/src/compose/ui.typecheck.ts +40 -4
  24. package/src/definition/definition.ts +22 -9
  25. package/src/event/event.fromSource.test.ts +172 -0
  26. package/src/event/event.test.ts +10 -0
  27. package/src/event/event.ts +102 -18
  28. package/src/feature/feature.mount.test.ts +123 -56
  29. package/src/feature/feature.test.ts +67 -63
  30. package/src/feature/feature.ts +1317 -197
  31. package/src/feature/feature.typecheck.ts +253 -61
  32. package/src/graph/closure.ts +403 -0
  33. package/src/index.ts +171 -245
  34. package/src/internal/bucketCache.ts +39 -0
  35. package/src/internal/capture.ts +9 -4
  36. package/src/internal/env.ts +1 -3
  37. package/src/internal/errors.ts +21 -10
  38. package/src/internal/queryDriver.ts +120 -15
  39. package/src/internal/queryDriverStore.ts +8 -5
  40. package/src/internal/queryDriverTypes.ts +3 -1
  41. package/src/internal/reuse.test.ts +10 -3
  42. package/src/internal/reuse.ts +5 -2
  43. package/src/internal/scheduler.ts +4 -1
  44. package/src/internal/sources.ts +25 -11
  45. package/src/internal/track.ts +3 -2
  46. package/src/internal.ts +222 -0
  47. package/src/namespace/namespace.ts +46 -25
  48. package/src/procedure/procedure.ts +38 -11
  49. package/src/reducer/reducer.ts +78 -34
  50. package/src/remote/remoteState.test.ts +515 -339
  51. package/src/remote/remoteState.ts +572 -107
  52. package/src/remote/remoteState.typecheck.ts +47 -23
  53. package/src/runtime/appRuntime.activation.test.ts +100 -0
  54. package/src/runtime/appRuntime.test.ts +157 -105
  55. package/src/runtime/appRuntime.ts +394 -33
  56. package/src/runtime/eventBudget.test.ts +1 -4
  57. package/src/runtime/eventBudget.ts +9 -8
  58. package/src/runtime/hardening.test.ts +4 -9
  59. package/src/runtime/instrumentation.test.ts +174 -192
  60. package/src/runtime/instrumentation.ts +6 -11
  61. package/src/runtime/loop.test.ts +36 -0
  62. package/src/runtime/loop.ts +69 -40
  63. package/src/scene/featureScene.test.ts +4 -2
  64. package/src/scene/scene.ts +234 -64
  65. package/src/scene/seedScene.test.ts +69 -86
  66. package/src/state/state.nominal.typecheck.ts +68 -0
  67. package/src/state/state.test.ts +17 -0
  68. package/src/state/state.ts +79 -26
  69. package/src/state/stateFamily.test.ts +14 -0
  70. package/src/state/stateFamily.ts +55 -22
  71. package/src/state/stateGroup.ts +53 -17
  72. package/src/state/token.ts +40 -10
  73. package/src/synced/syncedStore.ts +46 -23
  74. package/src/testkit/flight.testkit.ts +75 -0
  75. package/src/wire/tree.test.ts +8 -4
  76. package/src/wire/triggers.test.ts +6 -2
  77. package/src/wire/triggers.ts +14 -10
  78. package/src/calc/asyncCalcDefinitions.ts +0 -48
  79. package/src/channel/procedureRegistry.ts +0 -90
  80. package/src/feature/featureBinding.ts +0 -48
  81. package/src/internal/ctx.ts +0 -9
  82. package/src/remote/remoteStateDefinition.ts +0 -135
  83. package/src/remote/remoteStateLayers.ts +0 -118
  84. package/src/remote/remoteStateLiveTypes.ts +0 -59
  85. package/src/remote/remoteStateSend.ts +0 -64
@@ -1,4 +1,4 @@
1
- import { Context, Effect, Layer, MutableRef } from 'effect'
1
+ import { Context, Effect, Layer, MutableRef, type Schema } from 'effect'
2
2
  import { type Manifest, definitionClass } from '../definition/definition'
3
3
  import {
4
4
  type InputsObject,
@@ -9,45 +9,70 @@ import {
9
9
  } from '../internal/sources'
10
10
  import { resolveScheduler } from '../internal/scheduler'
11
11
  import { resolveInstrumentation, stateUpdateHook } from '../runtime/instrumentation'
12
+ import { reuse } from '../internal/reuse'
12
13
  import { makeDerivedStore, type Store } from '../internal/store'
13
14
  import { readTracked } from '../internal/track'
14
15
  import { type FamilyOptions, type FamilyStore } from '../state/stateFamily'
15
16
  import { type AnySource } from '../state/token'
16
- import type { AnySchema } from '../internal/variance'
17
17
 
18
- export interface CalcFamilyManifest<N extends string, K, Out> extends Manifest {
18
+ export interface CalcFamilySchemaReflection {
19
+ readonly ast: Schema.Schema<unknown, unknown, unknown>['ast']
20
+ }
21
+
22
+ export interface CalcFamilyManifest<N extends string> extends Manifest {
19
23
  readonly kind: 'CalcFamily'
20
24
  readonly name: N
21
- readonly key: AnySchema<K>
22
- readonly output: AnySchema<Out>
25
+ readonly key: CalcFamilySchemaReflection
26
+ readonly output: CalcFamilySchemaReflection
27
+ }
28
+
29
+ export const CalcFamilyStoreTypeId: unique symbol = Symbol.for('reform/CalcFamilyStore')
30
+ export type CalcFamilyStoreTypeId = typeof CalcFamilyStoreTypeId
31
+
32
+ export interface CalcFamilyStore<N extends string, in out K, in out Out> extends FamilyStore<
33
+ K,
34
+ Out
35
+ > {
36
+ readonly [CalcFamilyStoreTypeId]: N
23
37
  }
24
38
 
25
39
  export interface CalcFamilyClass<
26
- out N extends string,
27
- out Inputs extends ReadonlyArray<AnySource>,
28
- in out K,
29
- in out Out,
40
+ N extends string,
41
+ Inputs extends ReadonlyArray<AnySource>,
42
+ K,
43
+ Out,
30
44
  > {
31
45
  new (): { readonly Key: K }
32
- readonly manifest: CalcFamilyManifest<N, K, Out>
33
- readonly store: Context.Tag<FamilyStore<K, Out>, FamilyStore<K, Out>>
46
+ readonly manifest: CalcFamilyManifest<N>
47
+ readonly store: Context.Tag<CalcFamilyStore<N, K, Out>, FamilyStore<K, Out>>
34
48
  readonly inputs: Inputs
35
49
  }
36
50
 
37
- export interface CalcFamilyConfig<Inputs extends ReadonlyArray<AnySource>, K, Out> {
38
- readonly key: AnySchema<K>
51
+ export interface CalcFamilyConfig<
52
+ Inputs extends ReadonlyArray<AnySource>,
53
+ KeySchema extends Schema.Schema.AnyNoContext,
54
+ OutputSchema extends Schema.Schema.AnyNoContext,
55
+ > {
56
+ readonly key: KeySchema
39
57
  readonly inputs: Inputs
40
- readonly output: AnySchema<Out>
58
+ readonly output: OutputSchema
41
59
  }
42
60
 
43
- export const make = <const N extends string, const Inputs extends ReadonlyArray<AnySource>, K, Out>(
61
+ export const make = <
62
+ const N extends string,
63
+ const Inputs extends ReadonlyArray<AnySource>,
64
+ KeySchema extends Schema.Schema.AnyNoContext,
65
+ OutputSchema extends Schema.Schema.AnyNoContext,
66
+ >(
44
67
  name: N,
45
- config: CalcFamilyConfig<Inputs, K, Out>,
46
- ): CalcFamilyClass<N, Inputs, K, Out> => {
47
- const store = Context.GenericTag<FamilyStore<K, Out>, FamilyStore<K, Out>>(
68
+ config: CalcFamilyConfig<Inputs, KeySchema, OutputSchema>,
69
+ ): CalcFamilyClass<N, Inputs, Schema.Schema.Type<KeySchema>, Schema.Schema.Type<OutputSchema>> => {
70
+ type K = Schema.Schema.Type<KeySchema>
71
+ type Out = Schema.Schema.Type<OutputSchema>
72
+ const store = Context.GenericTag<CalcFamilyStore<N, K, Out>, FamilyStore<K, Out>>(
48
73
  `reform/calcFamily/${name}`,
49
74
  )
50
- const manifest: CalcFamilyManifest<N, K, Out> = {
75
+ const manifest: CalcFamilyManifest<N> = {
51
76
  kind: 'CalcFamily',
52
77
  name,
53
78
  key: config.key,
@@ -63,19 +88,21 @@ export const make = <const N extends string, const Inputs extends ReadonlyArray<
63
88
  export const read = <N extends string, Inputs extends ReadonlyArray<AnySource>, K, Out>(
64
89
  family: CalcFamilyClass<N, Inputs, K, Out>,
65
90
  key: K,
66
- ): Effect.Effect<Out, never, FamilyStore<K, Out>> =>
91
+ ): Effect.Effect<Out, never, CalcFamilyStore<N, K, Out>> =>
67
92
  Effect.flatMap(family.store, (familyStore) => readTracked(familyStore.at(key)))
68
93
 
69
- export interface CalcFamilyLiveOptionsExternalApi<Inputs extends ReadonlyArray<AnySource>>
70
- extends FamilyOptions {
94
+ export interface CalcFamilyLiveOptionsExternalApi<
95
+ Inputs extends ReadonlyArray<AnySource>,
96
+ > extends FamilyOptions {
71
97
  readonly invalidateBy?: InvalidateBy<Inputs>
98
+ readonly reuse?: boolean
72
99
  }
73
100
 
74
101
  export const live = <N extends string, Inputs extends ReadonlyArray<AnySource>, K, Out>(
75
102
  family: CalcFamilyClass<N, Inputs, K, Out>,
76
103
  compute: (key: K) => (inputs: InputsObject<Inputs>) => Out,
77
104
  options: CalcFamilyLiveOptionsExternalApi<Inputs> = {},
78
- ): Layer.Layer<FamilyStore<K, Out>, never, InputStores<Inputs>> =>
105
+ ): Layer.Layer<CalcFamilyStore<N, K, Out>, never, InputStores<Inputs>> =>
79
106
  Layer.scoped(
80
107
  family.store,
81
108
  Effect.gen(function* () {
@@ -96,7 +123,9 @@ export const live = <N extends string, Inputs extends ReadonlyArray<AnySource>,
96
123
  subscribers.delete(key)
97
124
  }
98
125
 
99
- const createMember = (key: K): { readonly store: Store<Out>; readonly unsubscribe: () => void } => {
126
+ const createMember = (
127
+ key: K,
128
+ ): { readonly store: Store<Out>; readonly unsubscribe: () => void } => {
100
129
  const body = compute(key)
101
130
  const memo = MutableRef.make<
102
131
  { readonly key: ReadonlyArray<unknown>; readonly output: Out } | undefined
@@ -109,7 +138,9 @@ export const live = <N extends string, Inputs extends ReadonlyArray<AnySource>,
109
138
  return prev.output
110
139
  }
111
140
  const end = instrumentation.calcRecomputed(family.manifest.name)
112
- const output = body(args)
141
+ const fresh = body(args)
142
+ const output =
143
+ options.reuse === true && prev !== undefined ? reuse(prev.output, fresh) : fresh
113
144
  end()
114
145
  MutableRef.set(memo, { key: memoKey, output })
115
146
  return output
@@ -13,20 +13,7 @@ export function composeCalcs<LOut, LErr, LIn, AOut, AErr, AIn, BOut, BErr, BIn>(
13
13
  LErr | AErr | BErr,
14
14
  BIn | Exclude<AIn | Exclude<LIn, AOut>, BOut>
15
15
  >
16
- export function composeCalcs<
17
- LOut,
18
- LErr,
19
- LIn,
20
- AOut,
21
- AErr,
22
- AIn,
23
- BOut,
24
- BErr,
25
- BIn,
26
- COut,
27
- CErr,
28
- CIn,
29
- >(
16
+ export function composeCalcs<LOut, LErr, LIn, AOut, AErr, AIn, BOut, BErr, BIn, COut, CErr, CIn>(
30
17
  leaf: Layer.Layer<LOut, LErr, LIn>,
31
18
  upstream1: Layer.Layer<AOut, AErr, AIn>,
32
19
  upstream2: Layer.Layer<BOut, BErr, BIn>,
@@ -1,14 +1,20 @@
1
- import { Chunk, Context, Duration, Effect, Exit, Layer, Match, Option, Queue, Scope, Stream } from 'effect'
1
+ import {
2
+ Chunk,
3
+ Context,
4
+ Duration,
5
+ Effect,
6
+ Exit,
7
+ Layer,
8
+ Match,
9
+ Option,
10
+ Queue,
11
+ Scope,
12
+ Stream,
13
+ } from 'effect'
2
14
  import { type Manifest, definitionClass } from '../definition/definition'
15
+ import { makeBucketCache } from '../internal/bucketCache'
3
16
  import type { Tagged } from '../runtime/bus'
4
17
  import { resolveInstrumentation } from '../runtime/instrumentation'
5
- import { type ProcedureEntry, Procedures } from './procedureRegistry'
6
- export {
7
- type ProcedureEntry,
8
- type ProcedureRegistry,
9
- Procedures,
10
- proceduresLayer,
11
- } from './procedureRegistry'
12
18
 
13
19
  export type ChannelPolicy =
14
20
  | { readonly _tag: 'merge' }
@@ -38,6 +44,111 @@ export interface ChannelConfig {
38
44
  readonly policy: ChannelPolicy
39
45
  }
40
46
 
47
+ export interface ProcedureEntry {
48
+ readonly name: string
49
+ readonly channelName: string
50
+ readonly handles: ReadonlySet<string>
51
+ readonly run: (event: Tagged) => Effect.Effect<unknown, unknown, never>
52
+ }
53
+
54
+ export interface ProcedureRegistry {
55
+ readonly entries: Array<ProcedureEntry>
56
+ readonly byName: Map<string, Set<ProcedureEntry>>
57
+ readonly byTag: Map<string, Set<ProcedureEntry>>
58
+ readonly channelsByTag: Map<string, Set<string>>
59
+ readonly channelsFor: (tag: string) => ReadonlyArray<string>
60
+ readonly byChannelTag: Map<string, Map<string, Set<ProcedureEntry>>>
61
+ readonly register: (entry: ProcedureEntry) => void
62
+ readonly unregister: (entry: ProcedureEntry) => void
63
+ }
64
+
65
+ const ProceduresBase: Context.TagClass<Procedures, 'reform/Procedures', ProcedureRegistry> =
66
+ Context.Tag('reform/Procedures')<Procedures, ProcedureRegistry>()
67
+ export class Procedures extends ProceduresBase {}
68
+
69
+ const getOrCreate = <K, V>(map: Map<K, V>, key: K, make: () => V): V => {
70
+ const existing = map.get(key)
71
+ if (existing !== undefined) {
72
+ return existing
73
+ }
74
+ const created = make()
75
+ map.set(key, created)
76
+ return created
77
+ }
78
+
79
+ const dropFrom = <K, T>(map: Map<K, Set<T>>, key: K, target: T): void => {
80
+ const bucket = map.get(key)
81
+ if (bucket === undefined) {
82
+ return
83
+ }
84
+ bucket.delete(target)
85
+ if (bucket.size === 0) {
86
+ map.delete(key)
87
+ }
88
+ }
89
+
90
+ const makeProcedureRegistry = (): ProcedureRegistry => {
91
+ const entries = new Set<ProcedureEntry>()
92
+ const byName = new Map<string, Set<ProcedureEntry>>()
93
+ const byTag = new Map<string, Set<ProcedureEntry>>()
94
+ const channelsByTag = new Map<string, Set<string>>()
95
+ const channelsFor = makeBucketCache(channelsByTag)
96
+ const byChannelTag = new Map<string, Map<string, Set<ProcedureEntry>>>()
97
+ const snapshot: { value: Option.Option<Array<ProcedureEntry>> } = {
98
+ value: Option.some([]),
99
+ }
100
+ return {
101
+ get entries() {
102
+ return Option.getOrElse(snapshot.value, () => {
103
+ const current = Array.from(entries)
104
+ snapshot.value = Option.some(current)
105
+ return current
106
+ })
107
+ },
108
+ byName,
109
+ byTag,
110
+ channelsByTag,
111
+ channelsFor: channelsFor.read,
112
+ byChannelTag,
113
+ register: (entry) => {
114
+ entries.add(entry)
115
+ snapshot.value = Option.none()
116
+ getOrCreate(byName, entry.name, () => new Set()).add(entry)
117
+ entry.handles.forEach((tag) => {
118
+ getOrCreate(byTag, tag, () => new Set()).add(entry)
119
+ getOrCreate(channelsByTag, tag, () => new Set()).add(entry.channelName)
120
+ channelsFor.invalidate(tag)
121
+ const channelMap = getOrCreate(byChannelTag, entry.channelName, () => new Map())
122
+ getOrCreate(channelMap, tag, () => new Set()).add(entry)
123
+ })
124
+ },
125
+ unregister: (entry) => {
126
+ entries.delete(entry)
127
+ snapshot.value = Option.none()
128
+ dropFrom(byName, entry.name, entry)
129
+ entry.handles.forEach((tag) => {
130
+ dropFrom(byTag, tag, entry)
131
+ const channelMap = byChannelTag.get(entry.channelName)
132
+ if (channelMap !== undefined) {
133
+ dropFrom(channelMap, tag, entry)
134
+ if (channelMap.get(tag) === undefined) {
135
+ dropFrom(channelsByTag, tag, entry.channelName)
136
+ channelsFor.invalidate(tag)
137
+ }
138
+ if (channelMap.size === 0) {
139
+ byChannelTag.delete(entry.channelName)
140
+ }
141
+ }
142
+ })
143
+ },
144
+ }
145
+ }
146
+
147
+ export const proceduresLayer: Layer.Layer<Procedures> = Layer.sync(
148
+ Procedures,
149
+ makeProcedureRegistry,
150
+ )
151
+
41
152
  export interface ChannelRuntime {
42
153
  readonly name: string
43
154
  readonly policy: ChannelPolicy
@@ -53,7 +164,9 @@ const ChannelsBase: Context.TagClass<
53
164
  > = Context.Tag('reform/Channels')<Channels, { readonly byName: Map<string, ChannelRuntime> }>()
54
165
  export class Channels extends ChannelsBase {}
55
166
 
56
- export const channelsLayer: Layer.Layer<Channels> = Layer.sync(Channels, () => ({ byName: new Map() }))
167
+ export const channelsLayer: Layer.Layer<Channels> = Layer.sync(Channels, () => ({
168
+ byName: new Map(),
169
+ }))
57
170
 
58
171
  export const make = (name: string, config: ChannelConfig): ChannelClass =>
59
172
  definitionClass<ChannelClass>({
@@ -155,9 +268,13 @@ export const live = (channel: ChannelClass): Layer.Layer<never, never, Channels
155
268
 
156
269
  const events = Stream.fromQueue(queue)
157
270
  const driven: Stream.Stream<unknown> = Match.value(channel.policy).pipe(
158
- Match.tag('merge', () => Stream.mapEffect(events, runMatching, { concurrency: 'unbounded' })),
271
+ Match.tag('merge', () =>
272
+ Stream.mapEffect(events, runMatching, { concurrency: 'unbounded' }),
273
+ ),
159
274
  Match.tag('latest', () =>
160
- Stream.flatMap(events, (event) => Stream.fromEffect(runMatching(event)), { switch: true }),
275
+ Stream.flatMap(events, (event) => Stream.fromEffect(runMatching(event)), {
276
+ switch: true,
277
+ }),
161
278
  ),
162
279
  Match.tag('debounce', (policy) =>
163
280
  events.pipe(
@@ -0,0 +1,19 @@
1
+ import { Option, Schema } from 'effect'
2
+ import { expect, it } from '@effect/vitest'
3
+ import { make } from './composition'
4
+ import { ui } from './ui'
5
+
6
+ class ItemUi extends ui('composition.parser.ItemUi')<{
7
+ props: { readonly label: string }
8
+ }>() {}
9
+
10
+ class Item extends make('composition.parser.Item', {
11
+ title: 'Item',
12
+ props: Schema.Struct({ label: Schema.String }),
13
+ ui: ItemUi,
14
+ })<Item>() {}
15
+
16
+ it('validates reflected props against the composition schema', () => {
17
+ expect(Item.parseProps({ label: 'ready' })).toEqual(Option.some({ label: 'ready' }))
18
+ expect(Option.isNone(Item.parseProps({ label: 1 }))).toBe(true)
19
+ })