@playfast/reform 1.1.1 → 1.2.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 (106) hide show
  1. package/README.md +85 -55
  2. package/package.json +17 -17
  3. package/src/boundary/boundary.test.ts +82 -33
  4. package/src/boundary/boundary.ts +141 -32
  5. package/src/calc/asyncCalc.invalidate.test.ts +733 -32
  6. package/src/calc/asyncCalc.test.ts +397 -225
  7. package/src/calc/asyncCalc.ts +115 -100
  8. package/src/calc/asyncCalcTypes.ts +147 -0
  9. package/src/calc/calc.test.ts +13 -16
  10. package/src/calc/calc.ts +44 -18
  11. package/src/calc/calcFamily.test.ts +90 -32
  12. package/src/calc/calcFamily.ts +56 -25
  13. package/src/calc/compose.ts +1 -14
  14. package/src/channel/channel.ts +25 -6
  15. package/src/channel/{procedureRegistry.ts → procedures.ts} +41 -23
  16. package/src/compose/composition.test.ts +19 -0
  17. package/src/compose/composition.ts +168 -81
  18. package/src/compose/compositionTypes.ts +249 -0
  19. package/src/compose/props.ts +13 -6
  20. package/src/compose/provide.ts +187 -46
  21. package/src/compose/slot.ts +156 -19
  22. package/src/compose/structure.test.ts +6 -3
  23. package/src/compose/structure.ts +106 -13
  24. package/src/compose/ui.test.ts +19 -3
  25. package/src/compose/ui.ts +147 -54
  26. package/src/compose/ui.typecheck.ts +40 -4
  27. package/src/definition/definition.ts +22 -9
  28. package/src/event/event.fromSource.test.ts +172 -0
  29. package/src/event/event.test.ts +10 -0
  30. package/src/event/event.ts +102 -18
  31. package/src/feature/feature.mount.test.ts +123 -56
  32. package/src/feature/feature.test.ts +67 -63
  33. package/src/feature/feature.ts +222 -262
  34. package/src/feature/feature.typecheck.ts +224 -91
  35. package/src/feature/featureBinding.ts +130 -20
  36. package/src/feature/featureClass.ts +162 -0
  37. package/src/feature/featureConfig.ts +192 -0
  38. package/src/feature/featureFactory.ts +163 -0
  39. package/src/feature/featureModule.ts +136 -0
  40. package/src/feature/featureModule.typecheck.ts +63 -0
  41. package/src/feature/featureNativeTree.ts +152 -0
  42. package/src/feature/featureRequirement.ts +82 -0
  43. package/src/feature/featureVariants.ts +234 -0
  44. package/src/feature/mountFeature.ts +54 -0
  45. package/src/graph/closure.ts +222 -0
  46. package/src/graph/services.ts +95 -0
  47. package/src/graph/summary.ts +134 -0
  48. package/src/index.ts +171 -245
  49. package/src/internal/bucketCache.ts +39 -0
  50. package/src/internal/capture.ts +9 -4
  51. package/src/internal/env.ts +1 -3
  52. package/src/internal/errors.ts +21 -10
  53. package/src/internal/queryDriver.ts +61 -25
  54. package/src/internal/queryDriverHydrate.ts +45 -0
  55. package/src/internal/queryDriverStore.ts +8 -5
  56. package/src/internal/queryDriverTypes.ts +11 -11
  57. package/src/internal/reuse.test.ts +10 -3
  58. package/src/internal/reuse.ts +5 -2
  59. package/src/internal/scheduler.ts +4 -1
  60. package/src/internal/sources.ts +25 -11
  61. package/src/internal/store.test.ts +27 -6
  62. package/src/internal/track.ts +3 -2
  63. package/src/internal.ts +222 -0
  64. package/src/namespace/namespace.ts +46 -25
  65. package/src/procedure/procedure.ts +38 -11
  66. package/src/reducer/reducer.ts +78 -34
  67. package/src/remote/pendingQueue.ts +145 -0
  68. package/src/remote/remoteState.test.ts +536 -354
  69. package/src/remote/remoteState.ts +145 -106
  70. package/src/remote/remoteState.typecheck.ts +47 -23
  71. package/src/remote/remoteStateMake.ts +97 -0
  72. package/src/remote/remoteStateSend.ts +95 -37
  73. package/src/remote/remoteStateTypes.ts +155 -0
  74. package/src/runtime/appRuntime.activation.test.ts +104 -0
  75. package/src/runtime/appRuntime.test.ts +187 -111
  76. package/src/runtime/appRuntime.ts +36 -130
  77. package/src/runtime/capturedAppRuntime.ts +274 -0
  78. package/src/runtime/eventBudget.test.ts +39 -12
  79. package/src/runtime/eventBudget.ts +9 -8
  80. package/src/runtime/featureMount.ts +94 -0
  81. package/src/runtime/hardening.test.ts +22 -13
  82. package/src/runtime/instrumentation.test.ts +213 -194
  83. package/src/runtime/instrumentation.ts +6 -11
  84. package/src/runtime/loop.test.ts +84 -7
  85. package/src/runtime/loop.ts +69 -40
  86. package/src/runtime/runtimeHandle.ts +124 -0
  87. package/src/scene/featureScene.test.ts +4 -2
  88. package/src/scene/scene.ts +234 -64
  89. package/src/scene/seedScene.test.ts +69 -86
  90. package/src/state/state.nominal.typecheck.ts +68 -0
  91. package/src/state/state.test.ts +17 -0
  92. package/src/state/state.ts +79 -26
  93. package/src/state/stateFamily.test.ts +39 -8
  94. package/src/state/stateFamily.ts +55 -22
  95. package/src/state/stateGroup.ts +53 -17
  96. package/src/state/token.ts +40 -10
  97. package/src/synced/syncedStore.ts +46 -23
  98. package/src/testkit/flight.testkit.ts +84 -0
  99. package/src/wire/tree.test.ts +8 -4
  100. package/src/wire/triggers.test.ts +6 -2
  101. package/src/wire/triggers.ts +14 -10
  102. package/src/calc/asyncCalcDefinitions.ts +0 -48
  103. package/src/internal/ctx.ts +0 -9
  104. package/src/remote/remoteStateDefinition.ts +0 -135
  105. package/src/remote/remoteStateLayers.ts +0 -118
  106. package/src/remote/remoteStateLiveTypes.ts +0 -59
@@ -1,13 +1,5 @@
1
- import {
2
- Context,
3
- Effect,
4
- Layer,
5
- MutableRef,
6
- Option,
7
- Runtime,
8
- Schema,
9
- } from 'effect'
10
- import { type AsyncData, narrowStore, widenStore } from '../calc/asyncData'
1
+ import { Context, Effect, Iterable, Layer, MutableRef, Option, Runtime, Schema } from 'effect'
2
+ import { type AnyAsyncData, AsyncData, narrowStore, widenStore } from '../calc/asyncData'
11
3
  import * as Channel from '../channel/channel'
12
4
  import * as Event from '../event/event'
13
5
  import {
@@ -16,42 +8,48 @@ import {
16
8
  RevisionSchema,
17
9
  revisionZero,
18
10
  } from '../internal/queryDriver'
11
+ import { reuse as shareStructure } from '../internal/reuse'
19
12
  import { resolveScheduler } from '../internal/scheduler'
20
- import { type InputStores } from '../internal/sources'
21
- import { makeStore, type Store } from '../internal/store'
13
+ import { type InputStores, sameKey } from '../internal/sources'
14
+ import { makeDerivedStore } from '../internal/store'
22
15
  import * as Reducer from '../reducer/reducer'
23
16
  import { Bus, publish } from '../runtime/bus'
24
17
  import { Reducers } from '../runtime/loop'
25
18
  import * as State from '../state/state'
26
19
  import { type AnySource } from '../state/token'
27
- import {
28
- looseSchema,
29
- type PendingIntent,
30
- type RemoteStateClass,
31
- type RemoteStateSettled,
32
- } from './remoteStateDefinition'
20
+ import { makePendingQueue, type PendingQueue } from './pendingQueue'
21
+ import { looseSchema } from './remoteStateMake'
22
+ import { queueReducerLayer, sendProcedureLayer } from './remoteStateSend'
33
23
  import type {
24
+ RemoteStateClass,
34
25
  RemoteStateLiveExternalApi,
35
- RemoteStateLiveRuntimeExternalApi,
26
+ RemoteStatePendingStore,
27
+ RemoteStateSettled,
28
+ RemoteStateTruthStore,
29
+ RemoteStateVisibleStore,
36
30
  SettleLink,
37
- } from './remoteStateLiveTypes'
38
- import { makeOverlayLayer, makeQueueReducerLayer } from './remoteStateLayers'
39
- import { makeSendProcedureLayer } from './remoteStateSend'
40
-
41
- // Generation rule: intent acked at gen g settles only on Success with gen > g (no flicker).
42
- // apply must be idempotent: between refetch landing and settle, intent is applied over truth that already has it.
31
+ WaiterRegistration,
32
+ } from './remoteStateTypes'
43
33
 
34
+ export { make } from './remoteStateMake'
44
35
  export {
45
- type FailedIntent,
46
- make,
47
- type PendingIntent,
48
- type RemoteStateClass,
49
- type RemoteStateConfigExternalApi,
50
- type RemoteStateManifestExternalApi,
51
- type RemoteStateSettled,
52
- } from './remoteStateDefinition'
53
-
54
- export type { RemoteStateLiveExternalApi } from './remoteStateLiveTypes'
36
+ RemoteStatePendingStoreTypeId,
37
+ RemoteStateTruthStoreTypeId,
38
+ RemoteStateVisibleStoreTypeId,
39
+ } from './remoteStateTypes'
40
+ export type {
41
+ FailedIntent,
42
+ PendingIntent,
43
+ RemoteStateClass,
44
+ RemoteStateConfigExternalApi,
45
+ RemoteStateLiveExternalApi,
46
+ RemoteStateManifestExternalApi,
47
+ RemoteStatePendingStore,
48
+ RemoteStateSchemaReflection,
49
+ RemoteStateSettled,
50
+ RemoteStateTruthStore,
51
+ RemoteStateVisibleStore,
52
+ } from './remoteStateTypes'
55
53
 
56
54
  export const live = <
57
55
  N extends string,
@@ -67,21 +65,17 @@ export const live = <
67
65
  remote: RemoteStateClass<N, Inputs, Intents, A, E, Gated>,
68
66
  config: RemoteStateLiveExternalApi<Inputs, Intents, A, E, Gated, R, R2, SettledEvent>,
69
67
  ): Layer.Layer<
70
- | Store<AsyncData<A, E, Gated>>
71
- | Store<ReadonlyArray<PendingIntent<Event.EventType<Intents[number]>>>>,
68
+ RemoteStateVisibleStore<N> | RemoteStateTruthStore<N> | RemoteStatePendingStore<N>,
72
69
  never,
73
- | InputStores<Inputs>
74
- | R
75
- | R2
76
- | Reducers
77
- | Channel.Procedures
78
- | Channel.Channels
79
- | Bus
70
+ InputStores<Inputs> | R | R2 | Reducers | Channel.Procedures | Channel.Channels | Bus
80
71
  > => {
81
72
  type I = Event.EventType<Intents[number]>
82
73
  const name = remote.manifest.name
83
74
  const truthTag = remote.truth.store
84
75
  const pendingTag = remote.pending.store
76
+ const pendingQueueTag = Context.GenericTag<PendingQueue<I>>(
77
+ `reform/remoteState/${name}/pendingQueue`,
78
+ )
85
79
 
86
80
  const Queued = Event.make(
87
81
  `${name}/Queued`,
@@ -107,53 +101,57 @@ export const live = <
107
101
 
108
102
  const driverLayer = Layer.scopedContext(
109
103
  Effect.gen(function* () {
110
- // oxlint-disable-next-line reform-rules/no-type-assertion -- type-level erasure seam: `disabled` is `never` for non-gated definitions; read it back through the runtime view
111
- const cfg = config as RemoteStateLiveRuntimeExternalApi<Inputs, A, E, R>
112
104
  const revision = Option.getOrUndefined(yield* Effect.serviceOption(revisionState.store))
113
105
  const runtime = yield* Effect.runtime<Bus>()
114
106
 
115
- // Registry (not per-intent fibers): latest/exclusive cancel must not orphan waiters
116
- const waiters = MutableRef.make<
117
- ReadonlyArray<{ readonly opId: string; readonly waitFor: number }>
118
- >([])
107
+ // Buckets, not cancellable fibers, keep waiters alive under latest/exclusive policies.
108
+ // Monotonic waitFor values make insertion order match generation order.
109
+ const waiters = new Map<number, Set<{ readonly opId: string }>>()
119
110
  const onSettled = (generation: number) => {
120
- const due = MutableRef.get(waiters).filter((waiter) => waiter.waitFor <= generation)
121
- if (due.length === 0) {
122
- return
123
- }
124
- MutableRef.update(waiters, (all) => all.filter((waiter) => waiter.waitFor > generation))
125
- // runSync: settle enqueued in same flush as converged value write
126
- due.forEach((waiter) =>
127
- Runtime.runSync(runtime)(
128
- publish('Normal', Event.construct(Settled, { opId: waiter.opId })),
129
- ),
111
+ Iterable.forEach(
112
+ Iterable.takeWhile(waiters, ([waitFor]) => waitFor <= generation),
113
+ ([waitFor, generationWaiters]) => {
114
+ waiters.delete(waitFor)
115
+ // runSync enqueues settles in the same flush as converged truth.
116
+ generationWaiters.forEach(({ opId }) =>
117
+ Runtime.runSync(runtime)(publish('Normal', Event.construct(Settled, { opId }))),
118
+ )
119
+ },
130
120
  )
131
121
  }
122
+ const registerWaiter = ({ opId, waitFor }: WaiterRegistration): void => {
123
+ const generationWaiters = waiters.get(waitFor)
124
+ if (generationWaiters === undefined) {
125
+ waiters.set(waitFor, new Set([{ opId }]))
126
+ } else {
127
+ generationWaiters.add({ opId })
128
+ }
129
+ }
132
130
 
133
- // Persist only settled server truth (overlay is a separate store the driver never sees)
134
- const persistOption = cfg.persist
131
+ const persistOption = config.persist
135
132
  const persistKey =
136
133
  persistOption === true || persistOption === undefined || persistOption === false
137
134
  ? name
138
- : persistOption.key ?? name
135
+ : (persistOption.key ?? name)
139
136
  const persist =
140
137
  persistOption === undefined || persistOption === false
141
138
  ? undefined
142
- : { key: persistKey, schema: remote.manifest.output }
143
- const extraKey =
144
- revision === undefined
145
- ? undefined
146
- : { read: () => revision.getSnapshot(), subscribe: (listener: () => void) => revision.subscribe(listener) }
139
+ : { key: persistKey, codec: remote.codec }
140
+ const revisionKey = (source: NonNullable<typeof revision>) => ({
141
+ read: () => source.getSnapshot(),
142
+ subscribe: (listener: () => void) => source.subscribe(listener),
143
+ })
144
+ const extraKey = revision === undefined ? undefined : revisionKey(revision)
147
145
  const driver = yield* makeQueryDriver({
148
146
  name,
149
147
  label: 'RemoteState',
150
148
  gated: remote.gated,
151
149
  inputs: remote.inputs,
152
- query: cfg.query,
153
- invalidateBy: cfg.invalidateBy,
154
- disabled: cfg.disabled,
155
- coalesce: cfg.coalesce,
156
- reuse: cfg.reuse,
150
+ query: config.query,
151
+ invalidateBy: config.invalidateBy,
152
+ disabled: config.disabled,
153
+ coalesce: config.coalesce,
154
+ reuse: config.reuse,
157
155
  persist,
158
156
  extraKey,
159
157
  onSettled,
@@ -173,7 +171,7 @@ export const live = <
173
171
  Runtime.runSync(runtime)(
174
172
  publish(
175
173
  'Normal',
176
- Event.construct(settledConfig.event, settledConfig.payload(settledTruth)),
174
+ settledConfig.event.buildUnknown(settledConfig.payload(settledTruth)),
177
175
  ),
178
176
  )
179
177
  }
@@ -185,8 +183,7 @@ export const live = <
185
183
  }
186
184
  const link: SettleLink = {
187
185
  requested: driver.requested,
188
- register: (opId, waitFor) =>
189
- MutableRef.update(waiters, (all) => [...all, { opId, waitFor }]),
186
+ register: (opId, waitFor) => registerWaiter({ opId, waitFor }),
190
187
  }
191
188
  return Context.make(truthTag, narrowStore<A, E, Gated>(driver.store)).pipe(
192
189
  Context.add(linkTag, link),
@@ -194,48 +191,90 @@ export const live = <
194
191
  }),
195
192
  )
196
193
 
197
- const overlayLayer = makeOverlayLayer<I, A, E, Gated>({
198
- apply: config.apply,
199
- pendingTag,
200
- reuse: config.reuse,
201
- storeTag: remote.store,
202
- truthTag,
203
- })
194
+ const overlayLayer = Layer.scoped(
195
+ remote.store,
196
+ Effect.gen(function* () {
197
+ const scheduler = yield* resolveScheduler
198
+ const feedStore = widenStore<A, E, Gated>(yield* truthTag)
199
+ const queue = yield* pendingQueueTag
200
+ const memo = MutableRef.make<
201
+ { readonly key: ReadonlyArray<unknown>; readonly output: AnyAsyncData<A, E> } | undefined
202
+ >(undefined)
203
+ const recompute = (): AnyAsyncData<A, E> => {
204
+ const feed = feedStore.getSnapshot()
205
+ const key = [feed, queue.revision()]
206
+ const prev = MutableRef.get(memo)
207
+ if (prev !== undefined && sameKey(key, prev.key)) {
208
+ return prev.output
209
+ }
210
+ // Full replay preserves observable value identity when apply allocates and reuse is off.
211
+ const applyPending = (base: A): A =>
212
+ queue.fold(base, (applied, entry) => config.apply(applied, entry.intent))
213
+ const overlaid: AnyAsyncData<A, E> =
214
+ feed._tag === 'Success'
215
+ ? AsyncData.success(applyPending(feed.value), feed.refetching)
216
+ : feed
217
+ const reusable =
218
+ config.reuse === true &&
219
+ prev !== undefined &&
220
+ prev.output._tag === 'Success' &&
221
+ overlaid._tag === 'Success'
222
+ ? shareStructure(prev.output.value, overlaid.value)
223
+ : undefined
224
+ const output: AnyAsyncData<A, E> =
225
+ reusable === undefined || overlaid._tag !== 'Success'
226
+ ? overlaid
227
+ : AsyncData.success(reusable, overlaid.refetching)
228
+ MutableRef.set(memo, { key, output })
229
+ return output
230
+ }
231
+ const subscribe = (listener: () => void): (() => void) => {
232
+ const offFeed = feedStore.subscribe(listener)
233
+ const offQueue = queue.store.subscribe(listener)
234
+ return () => {
235
+ offFeed()
236
+ offQueue()
237
+ }
238
+ }
239
+ const derived = makeDerivedStore(recompute, subscribe, scheduler)
240
+ yield* Effect.addFinalizer(() => Effect.sync(derived.unsubscribe))
241
+ return narrowStore<A, E, Gated>(derived.store)
242
+ }),
243
+ )
204
244
 
205
- const queueReducerLayer = makeQueueReducerLayer<I>({
206
- ackedTag: Acked.tag,
207
- name: `${name}/pending`,
208
- pendingTag,
209
- queuedTag: Queued.tag,
210
- settledTag: Settled.tag,
211
- })
212
245
  const channel = config.channel ?? Channel.make(`${name}/sends`, { policy: { _tag: 'merge' } })
213
- const procedureName = `${name}/send`
214
- const sendHandles: ReadonlySet<string> = new Set(remote.intents.map((event) => event.tag))
215
- const sendProcedureLayer = makeSendProcedureLayer<I, R2>({
246
+ const intentEvents = {
247
+ queued: Queued,
216
248
  acked: Acked,
217
- channel,
218
- failed: remote.Failed,
219
- handles: sendHandles,
249
+ settled: Settled,
220
250
  invalidated: Invalidated,
251
+ }
252
+ const pendingReducer = queueReducerLayer({
253
+ name: `${name}/pending`,
254
+ queueTag: pendingQueueTag,
255
+ events: intentEvents,
256
+ })
257
+ const sendProcedure = sendProcedureLayer({
258
+ name: `${name}/send`,
259
+ channel,
260
+ handles: new Set(remote.intents.map((event) => event.tag)),
221
261
  linkTag,
222
- name: procedureName,
223
- queued: Queued,
262
+ failed: remote.Failed,
224
263
  send: config.send,
225
- settled: Settled,
264
+ events: intentEvents,
226
265
  })
227
266
 
228
- const pendingStoreLayer = Layer.effect(
229
- pendingTag,
230
- Effect.map(resolveScheduler, (scheduler) =>
231
- makeStore<ReadonlyArray<PendingIntent<I>>>([], scheduler),
232
- ),
267
+ const pendingStoreLayer = Layer.scopedContext(
268
+ Effect.map(resolveScheduler, (scheduler) => {
269
+ const queue = makePendingQueue<I>(scheduler)
270
+ return Context.make(pendingTag, queue.store).pipe(Context.add(pendingQueueTag, queue))
271
+ }),
233
272
  )
234
273
  return Layer.mergeAll(
235
274
  overlayLayer,
236
- queueReducerLayer,
275
+ pendingReducer,
237
276
  Reducer.live(revisionReducer, bumpRevision),
238
- sendProcedureLayer,
277
+ sendProcedure,
239
278
  Channel.live(channel),
240
279
  ).pipe(
241
280
  Layer.provideMerge(driverLayer),
@@ -3,7 +3,6 @@ import * as Boundary from '../boundary/boundary'
3
3
  import type { AsyncData } from '../calc/asyncData'
4
4
  import { type Channels, type Procedures } from '../channel/channel'
5
5
  import * as Event from '../event/event'
6
- import type { Store } from '../internal/store'
7
6
  import * as Reducer from '../reducer/reducer'
8
7
  import type { Bus } from '../runtime/bus'
9
8
  import { type Reducers } from '../runtime/loop'
@@ -12,8 +11,10 @@ import * as StateGroup from '../state/stateGroup'
12
11
  import { type AnySource, type Source } from '../state/token'
13
12
  import * as RemoteState from './remoteState'
14
13
 
15
- const AddIntentBase: Event.EventClass<'TAddIntent', { readonly id: string; readonly name: string }> =
16
- Event.make('TAddIntent', S.Struct({ id: S.String, name: S.String }))
14
+ const AddIntentBase: Event.EventClass<
15
+ 'TAddIntent',
16
+ { readonly id: string; readonly name: string }
17
+ > = Event.make('TAddIntent', S.Struct({ id: S.String, name: S.String }))
17
18
  class AddIntent extends AddIntentBase {}
18
19
  const RenameIntentBase: Event.EventClass<
19
20
  'TRenameIntent',
@@ -27,7 +28,10 @@ const PageBase: State.StateClass<'tcPage', number> = State.make('tcPage', S.Numb
27
28
  class Page extends PageBase {}
28
29
  const InputsBase: StateGroup.StateGroupClass<readonly [typeof Page]> = StateGroup.make(Page)
29
30
  class Inputs extends InputsBase {}
30
- const page: Source<'tcPage', number> = StateGroup.select(Inputs, 'tcPage')
31
+ const page: Source<'tcPage', number, State.StateStore<'tcPage', number>> = StateGroup.select(
32
+ Inputs,
33
+ 'tcPage',
34
+ )
31
35
 
32
36
  const Row: S.Struct<{ id: typeof S.String; name: typeof S.String }> = S.Struct({
33
37
  id: S.String,
@@ -58,10 +62,11 @@ const ItemsBase: RemoteState.RemoteStateClass<
58
62
  class Items extends ItemsBase {}
59
63
 
60
64
  export const okLive: Layer.Layer<
61
- | Store<AsyncData<ReadonlyArray<Row>, string, true>>
62
- | Store<ReadonlyArray<RemoteState.PendingIntent<Intent>>>,
65
+ | RemoteState.RemoteStateVisibleStore<'TItems'>
66
+ | RemoteState.RemoteStateTruthStore<'TItems'>
67
+ | RemoteState.RemoteStatePendingStore<'TItems'>,
63
68
  never,
64
- Store<number> | SendDep | Reducers | Procedures | Channels | Bus
69
+ State.StateStore<'tcPage', number> | SendDep | Reducers | Procedures | Channels | Bus
65
70
  > = RemoteState.live(Items, {
66
71
  query: ({ tcPage }) =>
67
72
  tcPage > 0 ? Effect.succeed<ReadonlyArray<Row>>([]) : Effect.fail('empty page'),
@@ -78,10 +83,11 @@ export const okLive: Layer.Layer<
78
83
  })
79
84
 
80
85
  export const badIntent: Layer.Layer<
81
- | Store<AsyncData<ReadonlyArray<Row>, string, true>>
82
- | Store<ReadonlyArray<RemoteState.PendingIntent<Intent>>>,
86
+ | RemoteState.RemoteStateVisibleStore<'TItems'>
87
+ | RemoteState.RemoteStateTruthStore<'TItems'>
88
+ | RemoteState.RemoteStatePendingStore<'TItems'>,
83
89
  never,
84
- Store<number> | Reducers | Procedures | Channels | Bus
90
+ State.StateStore<'tcPage', number> | Reducers | Procedures | Channels | Bus
85
91
  > = RemoteState.live(Items, {
86
92
  query: () => Effect.succeed<ReadonlyArray<Row>>([]),
87
93
  send: () => Effect.void,
@@ -90,16 +96,20 @@ export const badIntent: Layer.Layer<
90
96
  })
91
97
 
92
98
  export const okLayer: Layer.Layer<
93
- Store<AsyncData<ReadonlyArray<Row>, string, true>> | Store<ReadonlyArray<RemoteState.PendingIntent<Intent>>>,
99
+ | RemoteState.RemoteStateVisibleStore<'TItems'>
100
+ | RemoteState.RemoteStateTruthStore<'TItems'>
101
+ | RemoteState.RemoteStatePendingStore<'TItems'>,
94
102
  never,
95
- Store<number> | SendDep | Reducers | Procedures | Channels | Bus
103
+ State.StateStore<'tcPage', number> | SendDep | Reducers | Procedures | Channels | Bus
96
104
  > = okLive
97
105
 
98
106
  // @ts-expect-error the send body's R2 must appear in the layer's requirements
99
107
  export const badLayer: Layer.Layer<
100
- Store<AsyncData<ReadonlyArray<Row>, string, true>> | Store<ReadonlyArray<RemoteState.PendingIntent<Intent>>>,
108
+ | RemoteState.RemoteStateVisibleStore<'TItems'>
109
+ | RemoteState.RemoteStateTruthStore<'TItems'>
110
+ | RemoteState.RemoteStatePendingStore<'TItems'>,
101
111
  never,
102
- Store<number> | Reducers | Procedures | Channels | Bus
112
+ State.StateStore<'tcPage', number> | Reducers | Procedures | Channels | Bus
103
113
  > = okLive
104
114
 
105
115
  const AlwaysItemsBase: RemoteState.RemoteStateClass<
@@ -118,10 +128,11 @@ const AlwaysItemsBase: RemoteState.RemoteStateClass<
118
128
  class AlwaysItems extends AlwaysItemsBase {}
119
129
 
120
130
  export const badDisabled: Layer.Layer<
121
- | Store<AsyncData<ReadonlyArray<Row>, never, false>>
122
- | Store<ReadonlyArray<RemoteState.PendingIntent<Event.EventType<typeof AddIntent>>>>,
131
+ | RemoteState.RemoteStateVisibleStore<'TAlwaysItems'>
132
+ | RemoteState.RemoteStateTruthStore<'TAlwaysItems'>
133
+ | RemoteState.RemoteStatePendingStore<'TAlwaysItems'>,
123
134
  never,
124
- Store<number> | Reducers | Procedures | Channels | Bus
135
+ State.StateStore<'tcPage', number> | Reducers | Procedures | Channels | Bus
125
136
  > = RemoteState.live(AlwaysItems, {
126
137
  query: () => Effect.succeed<ReadonlyArray<Row>>([]),
127
138
  send: () => Effect.void,
@@ -145,19 +156,32 @@ export const noIdle: Effect.Effect.Success<typeof AlwaysItems>['_tag'] = idleArm
145
156
  // @ts-expect-error no `error` schema ⇒ no Error arm
146
157
  export const noError: Effect.Effect.Success<typeof AlwaysItems>['_tag'] = errorArm
147
158
 
148
- export const classAsSource: Source<'TItems', AsyncData<ReadonlyArray<Row>, string, true>> = Items
159
+ export const classAsSource: Source<
160
+ 'TItems',
161
+ AsyncData<ReadonlyArray<Row>, string, true>,
162
+ RemoteState.RemoteStateVisibleStore<'TItems'>
163
+ > = Items
149
164
  export const classAsAnySource: AnySource = Items
150
165
 
151
- export const pendingAsSource: Source<'TItems/pending', ReadonlyArray<RemoteState.PendingIntent<Intent>>> =
152
- Items.pending
166
+ export const pendingAsSource: Source<
167
+ 'TItems/pending',
168
+ ReadonlyArray<RemoteState.PendingIntent<Intent>>,
169
+ RemoteState.RemoteStatePendingStore<'TItems'>
170
+ > = Items.pending
153
171
 
154
- export const truthAsSource: Source<'TItems/truth', AsyncData<ReadonlyArray<Row>, string, true>> =
155
- Items.truth
172
+ export const truthAsSource: Source<
173
+ 'TItems/truth',
174
+ AsyncData<ReadonlyArray<Row>, string, true>,
175
+ RemoteState.RemoteStateTruthStore<'TItems'>
176
+ > = Items.truth
156
177
 
157
178
  export const covered: Boundary.BoundaryClass<'TItemsBoundary', readonly [typeof Items]> =
158
179
  Boundary.make('TItemsBoundary', { over: [Items] })
159
180
 
160
- export const badReducerTarget: Reducer.StateReducerClass<State.AnyState, readonly [typeof AddIntent]> =
181
+ export const badReducerTarget: Reducer.StateReducerClass<
182
+ State.AnyState,
183
+ readonly [typeof AddIntent]
184
+ > =
161
185
  // @ts-expect-error the pending queue is not a reducible state
162
186
  Reducer.make('TBadReducer', {
163
187
  states: [Items.pending],
@@ -0,0 +1,97 @@
1
+ import { Context, Effect, Schema } from 'effect'
2
+ import type { AsyncData } from '../calc/asyncData'
3
+ import { yieldableClass } from '../definition/definition'
4
+ import * as Event from '../event/event'
5
+ import { type GatedOf, gatedFlag, type QueryCodec } from '../internal/queryDriver'
6
+ import type { Store } from '../internal/store'
7
+ import { readTracked } from '../internal/track'
8
+ import { type AnySource, type SourceCapture, StateToken } from '../state/token'
9
+ import type {
10
+ FailedIntent,
11
+ PendingIntent,
12
+ RemoteStateClass,
13
+ RemoteStateConfigExternalApi,
14
+ RemoteStateManifestExternalApi,
15
+ RemoteStatePendingStore,
16
+ RemoteStateTruthStore,
17
+ RemoteStateVisibleStore,
18
+ } from './remoteStateTypes'
19
+
20
+ // Schema.Unknown is structural because Intents is open at make-time; the declaration preserves its authored payload type.
21
+ export const looseSchema = <P>(schema: Schema.Schema.AnyNoContext): Schema.Schema<P, P, never> =>
22
+ Schema.declare((input): input is P => Schema.is(schema)(input))
23
+
24
+ export const make = <
25
+ const N extends string,
26
+ const Inputs extends ReadonlyArray<AnySource>,
27
+ const Intents extends ReadonlyArray<Event.AnyEvent>,
28
+ OutputSchema extends Schema.Schema.AnyNoContext,
29
+ ErrorSchema extends Schema.Schema.AnyNoContext | undefined = undefined,
30
+ const AlwaysOn extends boolean = false,
31
+ >(
32
+ name: N,
33
+ config: RemoteStateConfigExternalApi<Inputs, Intents, OutputSchema, ErrorSchema, AlwaysOn>,
34
+ ): RemoteStateClass<
35
+ N,
36
+ Inputs,
37
+ Intents,
38
+ Schema.Schema.Type<OutputSchema>,
39
+ ErrorSchema extends Schema.Schema.AnyNoContext ? Schema.Schema.Type<ErrorSchema> : never,
40
+ GatedOf<AlwaysOn>
41
+ > => {
42
+ type A = Schema.Schema.Type<OutputSchema>
43
+ type E = ErrorSchema extends Schema.Schema.AnyNoContext ? Schema.Schema.Type<ErrorSchema> : never
44
+ type I = Event.EventType<Intents[number]>
45
+ const store = Context.GenericTag<
46
+ RemoteStateVisibleStore<N>,
47
+ Store<AsyncData<A, E, GatedOf<AlwaysOn>>>
48
+ >(`reform/remoteState/${name}`)
49
+ const truthTag = Context.GenericTag<
50
+ RemoteStateTruthStore<N>,
51
+ Store<AsyncData<A, E, GatedOf<AlwaysOn>>>
52
+ >(`reform/remoteState/${name}/truth`)
53
+ const pendingTag = Context.GenericTag<
54
+ RemoteStatePendingStore<N>,
55
+ Store<ReadonlyArray<PendingIntent<I>>>
56
+ >(`reform/remoteState/${name}/pending`)
57
+ const gated = gatedFlag(config.alwaysOn)
58
+ const manifest: RemoteStateManifestExternalApi<N> = {
59
+ kind: 'RemoteState',
60
+ name,
61
+ output: config.output,
62
+ gated,
63
+ intents: config.intents.map((event) => event.tag),
64
+ ...(config.error !== undefined ? { error: config.error } : {}),
65
+ }
66
+ const codec: QueryCodec<A> = {
67
+ encode: Schema.encode(config.output),
68
+ decode: Schema.decodeUnknown(config.output),
69
+ }
70
+ // Annotations preserve template-literal types for the public statics.
71
+ const truthName: `${N}/truth` = `${name}/truth`
72
+ const pendingName: `${N}/pending` = `${name}/pending`
73
+ const failedName: `${N}/Failed` = `${name}/Failed`
74
+ const read = Effect.flatMap(store, readTracked)
75
+ const remote: RemoteStateClass<N, Inputs, Intents, A, E, GatedOf<AlwaysOn>> = yieldableClass(
76
+ read,
77
+ {
78
+ manifest,
79
+ codec,
80
+ store,
81
+ name,
82
+ inputs: config.inputs,
83
+ intents: config.intents,
84
+ gated,
85
+ capture: <Result>(visit: SourceCapture<Result>): Result => visit(remote),
86
+ truth: new StateToken(truthName, truthTag),
87
+ pending: new StateToken(pendingName, pendingTag),
88
+ Failed: Event.make(
89
+ failedName,
90
+ looseSchema<FailedIntent<I>>(
91
+ Schema.Struct({ intent: Schema.Unknown, error: Schema.Unknown }),
92
+ ),
93
+ ),
94
+ },
95
+ )
96
+ return remote
97
+ }