@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,135 +0,0 @@
1
- import { Context, Effect, Schema } from 'effect'
2
- import type { Effect as EffectType } from 'effect/Effect'
3
- import { type Manifest, yieldableClass } from '../definition/definition'
4
- import * as Event from '../event/event'
5
- import { readTracked } from '../internal/track'
6
- import type { AnySchema } from '../internal/variance'
7
- import type { GatedOf } from '../internal/queryDriver'
8
- import { gatedFlag } from '../internal/queryDriver'
9
- import type { AnySource } from '../state/token'
10
- import { StateToken } from '../state/token'
11
- import type { Store } from '../internal/store'
12
- import type { AsyncData, AsyncError, AsyncSuccess } from '../calc/asyncData'
13
-
14
- export interface PendingIntent<I> {
15
- readonly opId: string
16
- readonly intent: I
17
- readonly status: 'sending' | 'confirmed'
18
- }
19
-
20
- export interface FailedIntent<I> {
21
- readonly intent: I
22
- readonly error: unknown
23
- }
24
-
25
- export type RemoteStateSettled<A, E> = AsyncSuccess<A> | AsyncError<E>
26
-
27
- // ExternalApi: optional `intent` only on Queued envelopes
28
- export interface QueueFoldEventExternalApi<I> {
29
- readonly _tag: string
30
- readonly opId: string
31
- readonly intent?: I
32
- }
33
-
34
- export interface RemoteStateManifestExternalApi<N extends string, A, E> extends Manifest {
35
- readonly kind: 'RemoteState'
36
- readonly name: N
37
- readonly output: AnySchema<A>
38
- readonly error?: AnySchema<E>
39
- readonly gated: boolean
40
- readonly intents: ReadonlyArray<string>
41
- }
42
-
43
- export interface RemoteStateClass<
44
- out N extends string,
45
- out Inputs extends ReadonlyArray<AnySource>,
46
- in out Intents extends ReadonlyArray<Event.AnyEvent>,
47
- in out A,
48
- in out E,
49
- in out Gated extends boolean,
50
- > extends EffectType<AsyncData<A, E, Gated>, never, Store<AsyncData<A, E, Gated>>> {
51
- new (): {}
52
- readonly manifest: RemoteStateManifestExternalApi<N, A, E>
53
- readonly store: Context.Tag<Store<AsyncData<A, E, Gated>>, Store<AsyncData<A, E, Gated>>>
54
- readonly name: N
55
- readonly inputs: Inputs
56
- readonly intents: Intents
57
- readonly gated: Gated
58
- readonly truth: StateToken<`${N}/truth`, AsyncData<A, E, Gated>>
59
- // pending is a Source (not StateClass) so user reducers cannot target it
60
- readonly pending: StateToken<
61
- `${N}/pending`,
62
- ReadonlyArray<PendingIntent<Event.EventType<Intents[number]>>>
63
- >
64
- readonly Failed: Event.EventClass<`${N}/Failed`, FailedIntent<Event.EventType<Intents[number]>>>
65
- }
66
-
67
- export interface RemoteStateConfigExternalApi<
68
- Inputs extends ReadonlyArray<AnySource>,
69
- Intents extends ReadonlyArray<Event.AnyEvent>,
70
- A,
71
- E,
72
- AlwaysOn extends boolean,
73
- > {
74
- readonly inputs: Inputs
75
- readonly output: AnySchema<A>
76
- readonly error?: AnySchema<E>
77
- readonly alwaysOn?: AlwaysOn
78
- readonly intents: Intents
79
- }
80
-
81
- // Schema.Unknown in intent slot: Intents is open at make-time; event schemas are reflective only
82
- export const looseSchema = <P>(schema: Schema.Schema.AnyNoContext): AnySchema<P> => schema
83
-
84
- export const make = <
85
- const N extends string,
86
- const Inputs extends ReadonlyArray<AnySource>,
87
- const Intents extends ReadonlyArray<Event.AnyEvent>,
88
- A,
89
- E = never,
90
- const AlwaysOn extends boolean = false,
91
- >(
92
- name: N,
93
- config: RemoteStateConfigExternalApi<Inputs, Intents, A, E, AlwaysOn>,
94
- ): RemoteStateClass<N, Inputs, Intents, A, E, GatedOf<AlwaysOn>> => {
95
- type I = Event.EventType<Intents[number]>
96
- const store = Context.GenericTag<Store<AsyncData<A, E, GatedOf<AlwaysOn>>>>(
97
- `reform/remoteState/${name}`,
98
- )
99
- const truthTag = Context.GenericTag<Store<AsyncData<A, E, GatedOf<AlwaysOn>>>>(
100
- `reform/remoteState/${name}/truth`,
101
- )
102
- const pendingTag = Context.GenericTag<Store<ReadonlyArray<PendingIntent<I>>>>(
103
- `reform/remoteState/${name}/pending`,
104
- )
105
- const gated = gatedFlag(config.alwaysOn)
106
- const manifest: RemoteStateManifestExternalApi<N, A, E> = {
107
- kind: 'RemoteState',
108
- name,
109
- output: config.output,
110
- gated,
111
- intents: config.intents.map((event) => event.tag),
112
- ...(config.error !== undefined ? { error: config.error } : {}),
113
- }
114
- // Annotated consts preserve template-literal types for interface matching
115
- const truthName: `${N}/truth` = `${name}/truth`
116
- const pendingName: `${N}/pending` = `${name}/pending`
117
- const failedName: `${N}/Failed` = `${name}/Failed`
118
- const read = Effect.flatMap(store, readTracked)
119
- return yieldableClass(read, {
120
- manifest,
121
- store,
122
- name,
123
- inputs: config.inputs,
124
- intents: config.intents,
125
- gated,
126
- truth: new StateToken(truthName, truthTag),
127
- pending: new StateToken(pendingName, pendingTag),
128
- Failed: Event.make(
129
- failedName,
130
- looseSchema<FailedIntent<I>>(
131
- Schema.Struct({ intent: Schema.Unknown, error: Schema.Unknown }),
132
- ),
133
- ),
134
- })
135
- }
@@ -1,118 +0,0 @@
1
- import { Context, Effect, Layer, MutableRef } from 'effect'
2
- import { type AnyAsyncData, AsyncData, narrowStore, widenStore } from '../calc/asyncData'
3
- import { reuse as shareStructure } from '../internal/reuse'
4
- import { resolveScheduler } from '../internal/scheduler'
5
- import { sameKey } from '../internal/sources'
6
- import { makeDerivedStore, type Store } from '../internal/store'
7
- import { narrowHandled } from '../runtime/bus'
8
- import { type ReducerEntry, Reducers } from '../runtime/loop'
9
- import type { PendingIntent, QueueFoldEventExternalApi } from './remoteStateDefinition'
10
-
11
- interface OverlayLayerOptions<I, A, E, Gated extends boolean> {
12
- readonly apply: (current: A, intent: I) => A
13
- readonly pendingTag: Context.Tag<Store<ReadonlyArray<PendingIntent<I>>>, Store<ReadonlyArray<PendingIntent<I>>>>
14
- readonly reuse: boolean | undefined
15
- readonly storeTag: Context.Tag<Store<AsyncData<A, E, Gated>>, Store<AsyncData<A, E, Gated>>>
16
- readonly truthTag: Context.Tag<Store<AsyncData<A, E, Gated>>, Store<AsyncData<A, E, Gated>>>
17
- }
18
-
19
- export const makeOverlayLayer = <I, A, E, Gated extends boolean>(
20
- options: OverlayLayerOptions<I, A, E, Gated>,
21
- ): Layer.Layer<Store<AsyncData<A, E, Gated>>, never, Store<AsyncData<A, E, Gated>> | Store<ReadonlyArray<PendingIntent<I>>>> =>
22
- Layer.scoped(
23
- options.storeTag,
24
- Effect.gen(function* () {
25
- const scheduler = yield* resolveScheduler
26
- const feedStore = widenStore<A, E, Gated>(yield* options.truthTag)
27
- const queueStore = yield* options.pendingTag
28
- const memo = MutableRef.make<
29
- { readonly key: ReadonlyArray<unknown>; readonly output: AnyAsyncData<A, E> } | undefined
30
- >(undefined)
31
- const recompute = (): AnyAsyncData<A, E> => {
32
- const feed = feedStore.getSnapshot()
33
- const pending = queueStore.getSnapshot()
34
- const key = [feed, pending]
35
- const previous = MutableRef.get(memo)
36
- if (previous !== undefined && sameKey(key, previous.key)) {
37
- return previous.output
38
- }
39
- const applyPending = (base: A): A =>
40
- pending.reduce((applied, entry) => options.apply(applied, entry.intent), base)
41
- const overlaid: AnyAsyncData<A, E> =
42
- feed._tag === 'Success' ? AsyncData.success(applyPending(feed.value), feed.refetching) : feed
43
- const output =
44
- options.reuse === true &&
45
- previous !== undefined &&
46
- previous.output._tag === 'Success' &&
47
- overlaid._tag === 'Success'
48
- ? AsyncData.success(shareStructure(previous.output.value, overlaid.value), overlaid.refetching)
49
- : overlaid
50
- MutableRef.set(memo, { key, output })
51
- return output
52
- }
53
- const subscribe = (listener: () => void): (() => void) => {
54
- const offFeed = feedStore.subscribe(listener)
55
- const offQueue = queueStore.subscribe(listener)
56
- return () => {
57
- offFeed()
58
- offQueue()
59
- }
60
- }
61
- const derived = makeDerivedStore(recompute, subscribe, scheduler)
62
- yield* Effect.addFinalizer(() => Effect.sync(derived.unsubscribe))
63
- return narrowStore<A, E, Gated>(derived.store)
64
- }),
65
- )
66
-
67
- interface QueueReducerOptions<I> {
68
- readonly ackedTag: string
69
- readonly name: string
70
- readonly pendingTag: Context.Tag<Store<ReadonlyArray<PendingIntent<I>>>, Store<ReadonlyArray<PendingIntent<I>>>>
71
- readonly queuedTag: string
72
- readonly settledTag: string
73
- }
74
-
75
- export const makeQueueReducerLayer = <I>(
76
- options: QueueReducerOptions<I>,
77
- ): Layer.Layer<never, never, Reducers | Store<ReadonlyArray<PendingIntent<I>>>> => {
78
- const handles: ReadonlySet<string> = new Set([options.queuedTag, options.ackedTag, options.settledTag])
79
- const fold = (
80
- queue: ReadonlyArray<PendingIntent<I>>,
81
- event: QueueFoldEventExternalApi<I>,
82
- ): ReadonlyArray<PendingIntent<I>> => {
83
- if (event._tag === options.queuedTag) {
84
- return event.intent === undefined
85
- ? queue
86
- : [...queue, { opId: event.opId, intent: event.intent, status: 'sending' }]
87
- }
88
- if (event._tag === options.ackedTag) {
89
- const sending = queue.some((entry) => entry.opId === event.opId && entry.status === 'sending')
90
- if (!sending) {
91
- return queue
92
- }
93
- return queue.map((entry) =>
94
- entry.opId === event.opId ? { ...entry, status: 'confirmed' as const } : entry,
95
- )
96
- }
97
- return queue.some((entry) => entry.opId === event.opId)
98
- ? queue.filter((entry) => entry.opId !== event.opId)
99
- : queue
100
- }
101
- return Layer.scopedDiscard(
102
- Effect.gen(function* () {
103
- const reducers = yield* Reducers
104
- if (reducers.entries.some((entry) => entry.name === options.name)) {
105
- yield* Effect.logWarning(`reform: duplicate reducer name '${options.name}' registered`)
106
- }
107
- const store = yield* options.pendingTag
108
- const entry: ReducerEntry = {
109
- name: options.name,
110
- handles,
111
- apply: (event) => store.set(fold(store.get(), narrowHandled(event))),
112
- }
113
- yield* Effect.acquireRelease(Effect.sync(() => reducers.register(entry)), () =>
114
- Effect.sync(() => reducers.unregister(entry)),
115
- )
116
- }),
117
- )
118
- }
@@ -1,59 +0,0 @@
1
- import { Effect } from 'effect'
2
- import * as Event from '../event/event'
3
- import * as Channel from '../channel/channel'
4
- import type { InputsObject, InvalidateBy } from '../internal/sources'
5
- import type { AnySource } from '../state/token'
6
- import type { RemoteStateSettled } from './remoteStateDefinition'
7
-
8
- type EventPayload<Ev extends Event.AnyEvent> = Ev extends Event.EventClass<string, infer P>
9
- ? P
10
- : never
11
-
12
- export type RemoteStateLiveExternalApi<
13
- Inputs extends ReadonlyArray<AnySource>,
14
- Intents extends ReadonlyArray<Event.AnyEvent>,
15
- A,
16
- E,
17
- Gated extends boolean,
18
- R,
19
- R2,
20
- SettledEvent extends Event.AnyEvent = never,
21
- > = {
22
- readonly query: (inputs: InputsObject<Inputs>) => Effect.Effect<A, E, R>
23
- // Any failure settles + Failed; response value ignored — truth via invalidated query
24
- readonly send: (intent: Event.EventType<Intents[number]>) => Effect.Effect<unknown, unknown, R2>
25
- // Must be idempotent against truth that already carries the intent
26
- readonly apply: (value: A, intent: Event.EventType<Intents[number]>) => A
27
- readonly channel?: Channel.ChannelClass
28
- readonly invalidateBy?: InvalidateBy<Inputs>
29
- readonly invalidateOn?: ReadonlyArray<Event.AnyEvent>
30
- readonly coalesce?: 'switch' | 'trailing'
31
- readonly reuse?: boolean
32
- readonly settled?: {
33
- readonly event: SettledEvent
34
- readonly payload: (result: RemoteStateSettled<A, E>) => EventPayload<SettledEvent>
35
- }
36
- // Only settled server truth is persisted (overlay is a separate store)
37
- readonly persist?:
38
- | boolean
39
- | { readonly key?: string | ((inputs: InputsObject<Inputs>) => string) }
40
- } & (Gated extends true
41
- ? { readonly disabled?: (inputs: InputsObject<Inputs>) => boolean }
42
- : { readonly disabled?: never })
43
-
44
- // Runtime view: `disabled` is `never` at type level for non-gated definitions
45
- export type RemoteStateLiveRuntimeExternalApi<Inputs extends ReadonlyArray<AnySource>, A, E, R> = {
46
- readonly query: (inputs: InputsObject<Inputs>) => Effect.Effect<A, E, R>
47
- readonly invalidateBy?: InvalidateBy<Inputs>
48
- readonly disabled?: (inputs: InputsObject<Inputs>) => boolean
49
- readonly coalesce?: 'switch' | 'trailing'
50
- readonly reuse?: boolean
51
- readonly persist?:
52
- | boolean
53
- | { readonly key?: string | ((inputs: InputsObject<Inputs>) => string) }
54
- }
55
-
56
- export interface SettleLink {
57
- readonly requested: () => number
58
- readonly register: (opId: string, waitFor: number) => void
59
- }
@@ -1,64 +0,0 @@
1
- import { Context, Effect, Either, Layer } from 'effect'
2
- import * as Channel from '../channel/channel'
3
- import * as Event from '../event/event'
4
- import { Bus, narrowHandled } from '../runtime/bus'
5
- import type { SettleLink } from './remoteStateLiveTypes'
6
-
7
- interface SendProcedureOptions<I, R> {
8
- readonly acked: Event.EventClass<string, { readonly opId: string }>
9
- readonly channel: Channel.ChannelClass
10
- readonly failed: Event.EventClass<string, { readonly intent: I; readonly error: unknown }>
11
- readonly handles: ReadonlySet<string>
12
- readonly invalidated: Event.EventClass<string, {}>
13
- readonly linkTag: Context.Tag<SettleLink, SettleLink>
14
- readonly name: string
15
- readonly queued: Event.EventClass<string, { readonly opId: string; readonly intent: I }>
16
- readonly send: (intent: I) => Effect.Effect<unknown, unknown, R>
17
- readonly settled: Event.EventClass<string, { readonly opId: string }>
18
- }
19
-
20
- export const makeSendProcedureLayer = <I, R>(
21
- options: SendProcedureOptions<I, R>,
22
- ): Layer.Layer<never, never, Channel.Procedures | SettleLink | Bus | R> =>
23
- Layer.scopedDiscard(
24
- Effect.gen(function* () {
25
- const procedures = yield* Channel.Procedures
26
- if (procedures.entries.some((entry) => entry.name === options.name)) {
27
- yield* Effect.logWarning(`reform: duplicate procedure name '${options.name}' registered`)
28
- }
29
- const link = yield* options.linkTag
30
- const runtime = yield* Effect.runtime<Bus | R>()
31
- const deliver = Effect.fn('deliver')(
32
- function* (intent: I): Effect.fn.Return<void, never, Bus | R> {
33
- const opId = yield* Effect.sync(() => crypto.randomUUID())
34
- yield* Event.dispatch(options.queued, { opId, intent })
35
- const outcome = yield* options.send(intent).pipe(
36
- Effect.catchAllDefect((defect) => Effect.fail(defect)),
37
- Effect.either,
38
- Effect.onInterrupt(() => Event.dispatch(options.settled, { opId })),
39
- )
40
- yield* Either.match(outcome, {
41
- onLeft: (error) =>
42
- Effect.zipRight(
43
- Event.dispatch(options.failed, { intent, error }),
44
- Event.dispatch(options.settled, { opId }),
45
- ),
46
- onRight: () =>
47
- Effect.sync(() => link.register(opId, link.requested() + 1)).pipe(
48
- Effect.zipRight(Event.dispatch(options.acked, { opId })),
49
- Effect.zipRight(Event.dispatch(options.invalidated, {})),
50
- ),
51
- })
52
- },
53
- )
54
- const entry: Channel.ProcedureEntry = {
55
- name: options.name,
56
- channelName: options.channel.manifest.name,
57
- handles: options.handles,
58
- run: (event) => Effect.provide(deliver(narrowHandled<I>(event)), runtime),
59
- }
60
- yield* Effect.acquireRelease(Effect.sync(() => procedures.register(entry)), () =>
61
- Effect.sync(() => procedures.unregister(entry)),
62
- )
63
- }),
64
- )