@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
@@ -4,7 +4,9 @@ type TaggedErrorClass<Tag extends string, A extends Record<string, unknown>> = n
4
4
  args: A,
5
5
  ) => Cause.YieldableError & { readonly _tag: Tag } & Readonly<A>
6
6
 
7
- type EmptyTaggedErrorClass<Tag extends string> = new () => Cause.YieldableError & { readonly _tag: Tag }
7
+ type EmptyTaggedErrorClass<Tag extends string> = new () => Cause.YieldableError & {
8
+ readonly _tag: Tag
9
+ }
8
10
 
9
11
  const InvalidProvideTargetBase: EmptyTaggedErrorClass<'reform/InvalidProvideTarget'> =
10
12
  Data.TaggedError('reform/InvalidProvideTarget')<{}>
@@ -15,8 +17,10 @@ export class InvalidProvideTarget extends InvalidProvideTargetBase {
15
17
  }
16
18
  }
17
19
 
18
- const UnknownGroupStateBase: TaggedErrorClass<'reform/UnknownGroupState', { readonly name: string }> =
19
- Data.TaggedError('reform/UnknownGroupState')<{ readonly name: string }>
20
+ const UnknownGroupStateBase: TaggedErrorClass<
21
+ 'reform/UnknownGroupState',
22
+ { readonly name: string }
23
+ > = Data.TaggedError('reform/UnknownGroupState')<{ readonly name: string }>
20
24
 
21
25
  export class UnknownGroupState extends UnknownGroupStateBase {
22
26
  override get message(): string {
@@ -24,8 +28,10 @@ export class UnknownGroupState extends UnknownGroupStateBase {
24
28
  }
25
29
  }
26
30
 
27
- const FeatureLoadFailedBase: TaggedErrorClass<'reform/FeatureLoadFailed', { readonly cause: unknown }> =
28
- Data.TaggedError('reform/FeatureLoadFailed')<{ readonly cause: unknown }>
31
+ const FeatureLoadFailedBase: TaggedErrorClass<
32
+ 'reform/FeatureLoadFailed',
33
+ { readonly cause: unknown }
34
+ > = Data.TaggedError('reform/FeatureLoadFailed')<{ readonly cause: unknown }>
29
35
 
30
36
  export class FeatureLoadFailed extends FeatureLoadFailedBase {
31
37
  override get message(): string {
@@ -33,8 +39,9 @@ export class FeatureLoadFailed extends FeatureLoadFailedBase {
33
39
  }
34
40
  }
35
41
 
36
- const AsyncReducerBase: EmptyTaggedErrorClass<'reform/AsyncReducer'> =
37
- Data.TaggedError('reform/AsyncReducer')<{}>
42
+ const AsyncReducerBase: EmptyTaggedErrorClass<'reform/AsyncReducer'> = Data.TaggedError(
43
+ 'reform/AsyncReducer',
44
+ )<{}>
38
45
 
39
46
  export class AsyncReducer extends AsyncReducerBase {
40
47
  override get message(): string {
@@ -42,8 +49,9 @@ export class AsyncReducer extends AsyncReducerBase {
42
49
  }
43
50
  }
44
51
 
45
- const AsyncSceneLayerBase: EmptyTaggedErrorClass<'reform/AsyncSceneLayer'> =
46
- Data.TaggedError('reform/AsyncSceneLayer')<{}>
52
+ const AsyncSceneLayerBase: EmptyTaggedErrorClass<'reform/AsyncSceneLayer'> = Data.TaggedError(
53
+ 'reform/AsyncSceneLayer',
54
+ )<{}>
47
55
 
48
56
  export class AsyncSceneLayer extends AsyncSceneLayerBase {
49
57
  override get message(): string {
@@ -81,7 +89,10 @@ export const forceSync = <A>(thunk: () => A): A => {
81
89
  const DuplicateRegistrationBase: TaggedErrorClass<
82
90
  'reform/DuplicateRegistration',
83
91
  { readonly kind: string; readonly name: string }
84
- > = Data.TaggedError('reform/DuplicateRegistration')<{ readonly kind: string; readonly name: string }>
92
+ > = Data.TaggedError('reform/DuplicateRegistration')<{
93
+ readonly kind: string
94
+ readonly name: string
95
+ }>
85
96
 
86
97
  export class DuplicateRegistration extends DuplicateRegistrationBase {
87
98
  override get message(): string {
@@ -1,35 +1,20 @@
1
- import {
2
- Cause,
3
- Chunk,
4
- Effect,
5
- MutableRef,
6
- Option,
7
- Queue,
8
- type Scope,
9
- Stream,
10
- } from 'effect'
1
+ import { Cause, Chunk, Effect, MutableRef, Option, Queue, Runtime, type Scope, Stream } from 'effect'
11
2
  import { empty, type QueryState } from '../calc/queryState'
12
3
  import { Queries, type QueryHandle } from '../runtime/queries'
13
4
  import { reuse } from './reuse'
14
5
  import { resolveInstrumentation } from '../runtime/instrumentation'
15
6
  import { resolveQueryStore } from './queryStore'
16
- import type { QueryDriver, QueryDriverOptions } from './queryDriverTypes'
7
+ import { hydratePersistedQuery } from './queryDriverHydrate'
17
8
  import {
18
- hydrateQuery,
19
9
  incrementGeneration,
20
- makePersistWriter,
21
10
  makeQueryProjection,
22
11
  subscribeQuerySources,
23
12
  settleQueryError,
24
13
  withReaderTracking,
25
14
  } from './queryDriverStore'
15
+ import type { QueryDriver, QueryDriverOptions } from './queryDriverTypes'
26
16
  import { resolveScheduler } from './scheduler'
27
- import {
28
- type InputsObject,
29
- type InputStores,
30
- sameKey,
31
- wireSources,
32
- } from './sources'
17
+ import { type InputsObject, type InputStores, sameKey, wireSources } from './sources'
33
18
  import { makeStore } from './store'
34
19
  import { type AnySource } from '../state/token'
35
20
 
@@ -37,6 +22,7 @@ export {
37
22
  bumpRevision,
38
23
  gatedFlag,
39
24
  type GatedOf,
25
+ type QueryCodec,
40
26
  type QueryDriver,
41
27
  type QueryDriverOptions,
42
28
  Revision,
@@ -58,6 +44,10 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
58
44
 
59
45
  const keyOf = (args: InputsObject<Inputs>): ReadonlyArray<unknown> =>
60
46
  extraKey === undefined ? sources.keyOf(args) : [...sources.keyOf(args), extraKey.read()]
47
+ const persistKey = (
48
+ key: string | ((inputs: InputsObject<Inputs>) => string),
49
+ args: InputsObject<Inputs>,
50
+ ): string => (typeof key === 'function' ? key(args) : key)
61
51
  const disabledNow = (args: InputsObject<Inputs> = sources.snapshot()): boolean =>
62
52
  options.gated && options.disabled !== undefined ? options.disabled(args) : false
63
53
 
@@ -67,8 +57,17 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
67
57
  const readers = MutableRef.make(0)
68
58
 
69
59
  const lastKey = MutableRef.make<ReadonlyArray<unknown> | undefined>(undefined)
60
+ // Monotonic source epoch for the one startup hydration attempt. Comparing
61
+ // only the current key is insufficient: A → B → A would otherwise let the
62
+ // original A cache land over the later A request. Same-key refetches do not
63
+ // move this epoch, so cache may still paint during an in-flight revalidation.
64
+ const sourceEpoch = MutableRef.make(0)
70
65
  const generation = MutableRef.make(0)
71
66
  const nextGeneration = (): number => incrementGeneration(generation)
67
+ // A stale cause needs a strictly newer requested generation. Hydration
68
+ // captures this before the startup kick, while invalidation captures it when
69
+ // the value is marked stale.
70
+ const staleAtGeneration = MutableRef.make(0)
72
71
  const markFetching = () => {
73
72
  const state = stateStore.get()
74
73
  if (!state.isFetching) {
@@ -89,7 +88,24 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
89
88
  })
90
89
  return shared
91
90
  }
92
- const persistWrite = makePersistWriter<Inputs, A>({ persist, queryStore })
91
+ const settleError = (error: E, failedGeneration: number) => {
92
+ // Preserve the stale cause after failure without duplicating a newer request.
93
+ MutableRef.update(staleAtGeneration, (current) => Math.max(current, failedGeneration))
94
+ settleQueryError(stateStore, error)
95
+ }
96
+
97
+ const persistWriteTo = (
98
+ target: NonNullable<typeof persist>,
99
+ produced: A,
100
+ args: InputsObject<Inputs>,
101
+ ): Effect.Effect<void> =>
102
+ target.codec.encode(produced).pipe(
103
+ Effect.flatMap((encoded) => queryStore.set(persistKey(target.key, args), encoded)),
104
+ // Persistence is best-effort and must not block the visible write.
105
+ Effect.ignore,
106
+ )
107
+ const persistWrite = (produced: A, args: InputsObject<Inputs>): Effect.Effect<void> =>
108
+ persist === undefined ? Effect.void : persistWriteTo(persist, produced, args)
93
109
 
94
110
  const derived = makeQueryProjection({ gated: options.gated, scheduler, stateStore })
95
111
  yield* Effect.addFinalizer(() => Effect.sync(derived.unsubscribe))
@@ -123,7 +139,7 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
123
139
  onFailure: (cause): Option.Option<A> => {
124
140
  const failure = Cause.failureOption(cause)
125
141
  if (Option.isSome(failure) && !disabledNow()) {
126
- settleQueryError(stateStore, failure.value)
142
+ settleError(failure.value, request.generation)
127
143
  }
128
144
  return Option.none()
129
145
  },
@@ -161,6 +177,9 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
161
177
  const enqueue = (force: boolean) => {
162
178
  const args = sources.snapshot()
163
179
  if (disabledNow(args)) {
180
+ if (MutableRef.get(lastKey) !== undefined) {
181
+ MutableRef.update(sourceEpoch, (current) => current + 1)
182
+ }
164
183
  MutableRef.set(lastKey, undefined)
165
184
  stateStore.set(idle)
166
185
  return
@@ -170,6 +189,9 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
170
189
  if (!force && previous !== undefined && sameKey(key, previous)) {
171
190
  return
172
191
  }
192
+ if (previous !== undefined && !sameKey(key, previous)) {
193
+ MutableRef.update(sourceEpoch, (current) => current + 1)
194
+ }
173
195
  MutableRef.set(lastKey, key)
174
196
  markFetching()
175
197
  Queue.unsafeOffer(requests, { args, generation: nextGeneration() })
@@ -178,7 +200,8 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
178
200
  const refetch = () => enqueue(true)
179
201
 
180
202
  const maybeAutoRefetch = () => {
181
- if (stateStore.get().isStale && MutableRef.get(readers) > 0) {
203
+ const hasCoveringRequest = MutableRef.get(generation) > MutableRef.get(staleAtGeneration)
204
+ if (stateStore.get().isStale && MutableRef.get(readers) > 0 && !hasCoveringRequest) {
182
205
  refetch()
183
206
  }
184
207
  }
@@ -187,6 +210,7 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
187
210
  if (state.isStale) {
188
211
  return
189
212
  }
213
+ MutableRef.set(staleAtGeneration, MutableRef.get(generation))
190
214
  stateStore.set({ ...state, isStale: true })
191
215
  maybeAutoRefetch()
192
216
  }
@@ -194,14 +218,26 @@ export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R
194
218
  const unsubscribe = subscribeQuerySources(sources, extraKey, trigger)
195
219
  yield* Effect.addFinalizer(() => Effect.sync(unsubscribe))
196
220
 
221
+ // Fork hydration so an asynchronous store never blocks layer acquisition.
197
222
  if (persist !== undefined && !disabledNow()) {
198
223
  const hydrateArgs = sources.snapshot()
199
- yield* hydrateQuery({
200
- cacheKey: typeof persist.key === 'function' ? persist.key(hydrateArgs) : persist.key,
224
+ const hydrateSourceKey = keyOf(hydrateArgs)
225
+ const hydrateSourceEpoch = MutableRef.get(sourceEpoch)
226
+ const hydrate = hydratePersistedQuery({
201
227
  queryStore,
202
- schema: persist.schema,
228
+ cacheKey: persistKey(persist.key, hydrateArgs),
229
+ codec: persist.codec,
203
230
  stateStore,
231
+ staleAtGeneration,
232
+ capturedGeneration: MutableRef.get(generation),
233
+ isSuperseded: () =>
234
+ disabledNow() ||
235
+ hydrateSourceEpoch !== MutableRef.get(sourceEpoch) ||
236
+ !sameKey(hydrateSourceKey, keyOf(sources.snapshot())),
204
237
  })
238
+ const runtime = yield* Effect.runtime<never>()
239
+ const scope = yield* Effect.scope
240
+ Runtime.runFork(runtime)(hydrate, { immediate: true, scope })
205
241
  }
206
242
 
207
243
  const initial = sources.snapshot()
@@ -0,0 +1,45 @@
1
+ import { Effect, MutableRef, Option } from 'effect'
2
+ import type { QueryState } from '../calc/queryState'
3
+ import type { QueryStoreApi } from './queryStore'
4
+ import type { QueryCodec } from './queryDriverTypes'
5
+ import type { Store } from './store'
6
+
7
+ interface HydratePersistedOptions<A, E> {
8
+ readonly queryStore: QueryStoreApi
9
+ readonly cacheKey: string
10
+ readonly codec: QueryCodec<A>
11
+ readonly stateStore: Store<QueryState<A, E>>
12
+ readonly staleAtGeneration: MutableRef.MutableRef<number>
13
+ readonly capturedGeneration: number
14
+ // True once the sources moved past the snapshot this hydration was captured for.
15
+ readonly isSuperseded: () => boolean
16
+ }
17
+
18
+ const paint = <A, E>(options: HydratePersistedOptions<A, E>, decoded: A): void => {
19
+ const state = options.stateStore.get()
20
+ if (options.isSuperseded() || Option.isSome(state.data) || Option.isSome(state.error)) {
21
+ return
22
+ }
23
+ MutableRef.update(options.staleAtGeneration, (current) =>
24
+ Math.max(current, options.capturedGeneration),
25
+ )
26
+ options.stateStore.set({ ...state, data: Option.some(decoded), isStale: true })
27
+ }
28
+
29
+ // Epoch, key, and settled-state guards prevent a late cache from replacing newer truth.
30
+ export const hydratePersistedQuery = <A, E>(
31
+ options: HydratePersistedOptions<A, E>,
32
+ ): Effect.Effect<void> =>
33
+ options.queryStore.get(options.cacheKey).pipe(
34
+ Effect.flatMap((cached) =>
35
+ Option.match(cached, {
36
+ onNone: () => Effect.void,
37
+ onSome: (raw) =>
38
+ options.codec
39
+ .decode(raw)
40
+ .pipe(Effect.tap((decoded) => Effect.sync(() => paint(options, decoded)))),
41
+ }),
42
+ ),
43
+ // Corrupt/incompatible cache: ignore and fetch fresh.
44
+ Effect.ignore,
45
+ )
@@ -15,9 +15,7 @@ interface HydrateQueryOptions<A, E> {
15
15
  readonly stateStore: Store<QueryState<A, E>>
16
16
  }
17
17
 
18
- export const hydrateQuery = <A, E>(
19
- options: HydrateQueryOptions<A, E>,
20
- ): Effect.Effect<void> =>
18
+ export const hydrateQuery = <A, E>(options: HydrateQueryOptions<A, E>): Effect.Effect<void> =>
21
19
  Effect.flatMap(options.queryStore.get(options.cacheKey), (cached) =>
22
20
  Option.match(cached, {
23
21
  onNone: () => Effect.void,
@@ -52,7 +50,10 @@ export const persistQueryValue = <A>(options: PersistQueryValueOptions<A>): Effe
52
50
 
53
51
  interface PersistWriterOptions<Inputs extends ReadonlyArray<AnySource>, A> {
54
52
  readonly persist:
55
- | { readonly key: string | ((inputs: InputsObject<Inputs>) => string); readonly schema: AnySchema<A> }
53
+ | {
54
+ readonly key: string | ((inputs: InputsObject<Inputs>) => string)
55
+ readonly schema: AnySchema<A>
56
+ }
56
57
  | undefined
57
58
  readonly queryStore: QueryStoreApi
58
59
  }
@@ -136,7 +137,9 @@ interface ReaderStoreOptions<A, E> {
136
137
  readonly onFirstReader: () => void
137
138
  }
138
139
 
139
- export const withReaderTracking = <A, E>(options: ReaderStoreOptions<A, E>): Store<AnyAsyncData<A, E>> => ({
140
+ export const withReaderTracking = <A, E>(
141
+ options: ReaderStoreOptions<A, E>,
142
+ ): Store<AnyAsyncData<A, E>> => ({
140
143
  ...options.store,
141
144
  subscribe: (listener) => {
142
145
  const before = MutableRef.get(options.readers)
@@ -1,13 +1,14 @@
1
- import { Brand, Effect, Schema } from 'effect'
1
+ import { Brand, Effect, type ParseResult, Schema } from 'effect'
2
2
  import type { AnyAsyncData } from '../calc/asyncData'
3
3
  import type { InputsObject, InvalidateBy } from './sources'
4
4
  import type { Store } from './store'
5
5
  import type { AnySource } from '../state/token'
6
- import type { AnySchema } from './variance'
7
6
 
8
7
  export type GatedOf<AlwaysOn extends boolean> = AlwaysOn extends true ? false : true
9
8
 
10
- export const gatedFlag = <AlwaysOn extends boolean>(alwaysOn: AlwaysOn | undefined): GatedOf<AlwaysOn> =>
9
+ export const gatedFlag = <AlwaysOn extends boolean>(
10
+ alwaysOn: AlwaysOn | undefined,
11
+ ): GatedOf<AlwaysOn> =>
11
12
  // oxlint-disable-next-line reform-rules/no-type-assertion -- restate the runtime boolean as its conditional `GatedOf` literal; no guard maps a boolean to a conditional type
12
13
  (alwaysOn !== true) as GatedOf<AlwaysOn>
13
14
 
@@ -19,35 +20,34 @@ export const RevisionSchema: Schema.Schema<Revision, number> = Schema.Number.pip
19
20
  export const revisionZero: Revision = Revision(0)
20
21
  export const bumpRevision = (revision: Revision): Revision => Revision(revision + 1)
21
22
 
23
+ // Keep schema invariance behind a covariant persistence boundary.
24
+ export interface QueryCodec<A> {
25
+ readonly encode: (value: A) => Effect.Effect<unknown, ParseResult.ParseError, never>
26
+ readonly decode: (input: unknown) => Effect.Effect<A, ParseResult.ParseError, never>
27
+ }
28
+
22
29
  export interface QueryDriverOptions<Inputs extends ReadonlyArray<AnySource>, A, E, R> {
23
30
  readonly name: string
24
31
  readonly label: string
25
32
  readonly gated: boolean
26
33
  readonly inputs: Inputs
27
34
  readonly query: (inputs: InputsObject<Inputs>) => Effect.Effect<A, E, R>
28
- // oxlint-disable-next-line reform-rules/no-optional-fields -- caller-omitted option built at the asyncCalc/remoteState construction site; Option<T> would change that call shape
29
35
  readonly invalidateBy?: InvalidateBy<Inputs> | undefined
30
- // oxlint-disable-next-line reform-rules/no-optional-fields -- caller-omitted option built at the asyncCalc/remoteState construction site; Option<T> would change that call shape
31
36
  readonly disabled?: ((inputs: InputsObject<Inputs>) => boolean) | undefined
32
- // oxlint-disable-next-line reform-rules/no-optional-fields -- caller-omitted option built at the asyncCalc/remoteState construction site; Option<T> would change that call shape
33
37
  readonly coalesce?: 'switch' | 'trailing' | undefined
34
- // oxlint-disable-next-line reform-rules/no-optional-fields -- caller-omitted option built at the asyncCalc/remoteState construction site; Option<T> would change that call shape
35
38
  readonly reuse?: boolean | undefined
36
- // oxlint-disable-next-line reform-rules/no-optional-fields -- caller-omitted option built at the asyncCalc/remoteState construction site; Option<T> would change that call shape
37
39
  readonly extraKey?:
38
40
  | {
39
41
  readonly read: () => unknown
40
42
  readonly subscribe: (listener: () => void) => () => void
41
43
  }
42
44
  | undefined
43
- // oxlint-disable-next-line reform-rules/no-optional-fields -- caller-omitted option built at the asyncCalc/remoteState construction site; Option<T> would change that call shape
44
45
  readonly persist?:
45
46
  | {
46
47
  readonly key: string | ((inputs: InputsObject<Inputs>) => string)
47
- readonly schema: AnySchema<A>
48
+ readonly codec: QueryCodec<A>
48
49
  }
49
50
  | undefined
50
- // oxlint-disable-next-line reform-rules/no-optional-fields -- caller-omitted option built at the asyncCalc/remoteState construction site; Option<T> would change that call shape
51
51
  readonly onSettled?: ((generation: number) => void) | undefined
52
52
  }
53
53
 
@@ -13,7 +13,10 @@ describe('reuse', () => {
13
13
  const e0 = { id: 1, name: 'a' }
14
14
  const e1 = { id: 2, name: 'b' }
15
15
  const prev = [e0, e1]
16
- const next = [{ id: 1, name: 'a' }, { id: 2, name: 'B' }]
16
+ const next = [
17
+ { id: 1, name: 'a' },
18
+ { id: 2, name: 'B' },
19
+ ]
17
20
  const out = reuse(prev, next)
18
21
  expect(out).not.toBe(prev)
19
22
  expect(out[0]).toBe(e0)
@@ -49,7 +52,10 @@ describe('reuse', () => {
49
52
  class View extends S.Class<View>('View')({ title: S.String, rows: S.Array(Row) }) {}
50
53
  const keep = new Row({ id: 1, name: 'a' })
51
54
  const prev = new View({ title: 't', rows: [keep, new Row({ id: 2, name: 'b' })] })
52
- const next = new View({ title: 't', rows: [new Row({ id: 1, name: 'a' }), new Row({ id: 2, name: 'B' })] })
55
+ const next = new View({
56
+ title: 't',
57
+ rows: [new Row({ id: 1, name: 'a' }), new Row({ id: 2, name: 'B' })],
58
+ })
53
59
  const out = reuse(prev, next)
54
60
  expect(out).not.toBe(prev)
55
61
  expect(out).toBeInstanceOf(View)
@@ -77,7 +83,8 @@ describe('reuse', () => {
77
83
 
78
84
  it('returns next below the depth bound', () => {
79
85
  type Nest = { readonly n?: Nest; readonly v: number }
80
- const deep = (depth: number): Nest => (depth === 0 ? { v: 0 } : { v: depth, n: deep(depth - 1) })
86
+ const deep = (depth: number): Nest =>
87
+ depth === 0 ? { v: 0 } : { v: depth, n: deep(depth - 1) }
81
88
  const prev = deep(20)
82
89
  const next = deep(20)
83
90
  const out = reuse(prev, next)
@@ -54,10 +54,13 @@ const reconcile = ({ prev, next, depth }: ReconcileArgs): unknown => {
54
54
  }
55
55
  if (Array.isArray(prev) && Array.isArray(next)) {
56
56
  const reconciled = next.map((element, index) =>
57
- index < prev.length ? reconcile({ prev: prev[index], next: element, depth: depth - 1 }) : element,
57
+ index < prev.length
58
+ ? reconcile({ prev: prev[index], next: element, depth: depth - 1 })
59
+ : element,
58
60
  )
59
61
  const unchanged =
60
- prev.length === next.length && reconciled.every((entry, index) => Object.is(entry, prev[index]))
62
+ prev.length === next.length &&
63
+ reconciled.every((entry, index) => Object.is(entry, prev[index]))
61
64
  return unchanged ? prev : reconciled
62
65
  }
63
66
  if (isPlainRecord(prev) && isPlainRecord(next)) {
@@ -54,7 +54,10 @@ const NotificationsBase: Context.TagClass<Notifications, 'reform/Notifications',
54
54
  Context.Tag('reform/Notifications')<Notifications, Scheduler>()
55
55
  export class Notifications extends NotificationsBase {}
56
56
 
57
- export const notificationsLayer: Layer.Layer<Notifications> = Layer.sync(Notifications, makeScheduler)
57
+ export const notificationsLayer: Layer.Layer<Notifications> = Layer.sync(
58
+ Notifications,
59
+ makeScheduler,
60
+ )
58
61
 
59
62
  export const resolveScheduler: Effect.Effect<Scheduler> = Effect.map(
60
63
  Effect.serviceOption(Notifications),
@@ -1,15 +1,20 @@
1
- import { type Context, Effect, Equal, Predicate } from 'effect'
2
- import { type AnySource, type SourceName, type SourceValue } from '../state/token'
3
- import { type Store } from './store'
4
- import type { AnyValue } from './variance'
1
+ import { Context, Effect, Equal, Predicate } from 'effect'
2
+ import {
3
+ type AnySource,
4
+ type SourceIdentifier,
5
+ type SourceName,
6
+ type SourceValue,
7
+ } from '../state/token'
5
8
 
6
9
  export type InputsObject<Inputs extends ReadonlyArray<AnySource>> = {
7
10
  readonly [S in Inputs[number] as SourceName<S>]: SourceValue<S>
8
11
  }
9
12
 
10
13
  export type StoresOf<Items extends ReadonlyArray<unknown>> = {
11
- [I in keyof Items]: Items[I] extends { readonly store: Context.Tag<infer Svc, AnyValue> }
12
- ? Svc
14
+ [I in keyof Items]: Items[I] extends {
15
+ readonly store: Context.Tag<infer Service, infer _Store>
16
+ }
17
+ ? Service
13
18
  : never
14
19
  }[number]
15
20
 
@@ -26,8 +31,20 @@ const inputKey = (input: AnySource): string =>
26
31
  ? input.manifest['name']
27
32
  : input.name
28
33
 
34
+ // Capture an invariant source tag through a covariant read-only surface.
35
+ interface ReadableStore<out A> {
36
+ readonly getSnapshot: () => A
37
+ readonly subscribe: (listener: () => void) => () => void
38
+ }
39
+
40
+ const readStore = <Source extends AnySource>(
41
+ source: Source,
42
+ ): Effect.Effect<ReadableStore<SourceValue<Source>>, never, SourceIdentifier<Source>> =>
43
+ Context.GenericTag<SourceIdentifier<Source>, ReadableStore<SourceValue<Source>>>(source.store.key)
44
+
29
45
  export const sameKey = (left: ReadonlyArray<unknown>, right: ReadonlyArray<unknown>): boolean =>
30
- left.length === right.length && left.every((element, index) => Equal.equals(element, right[index]))
46
+ left.length === right.length &&
47
+ left.every((element, index) => Equal.equals(element, right[index]))
31
48
 
32
49
  export interface WiredSources<Inputs extends ReadonlyArray<AnySource>> {
33
50
  readonly snapshot: () => InputsObject<Inputs>
@@ -41,10 +58,7 @@ export const wireSources = <Inputs extends ReadonlyArray<AnySource>>(
41
58
  ): Effect.Effect<WiredSources<Inputs>, never, InputStores<Inputs>> =>
42
59
  // oxlint-disable-next-line reform-rules/no-type-assertion -- restate the precise InputStores<Inputs> union the signature promises; Effect.forEach over a heterogeneous tuple widens R (the single seam this cast lives at)
43
60
  Effect.gen(function* () {
44
- const sources: ReadonlyArray<Store<unknown>> = yield* Effect.forEach(
45
- inputs,
46
- (input) => input.store,
47
- )
61
+ const sources = yield* Effect.forEach(inputs, readStore)
48
62
 
49
63
  const snapshot = (): InputsObject<Inputs> => {
50
64
  const args: Record<string, unknown> = {}
@@ -1,7 +1,14 @@
1
1
  import { expect, test } from 'vitest'
2
2
  import { makeStore } from './store'
3
3
 
4
- const tick = () => new Promise<void>((resolve) => queueMicrotask(resolve))
4
+ // Notifications land on the microtask queue poll turns of it, never wall-clock.
5
+ const until = async (predicate: () => boolean, rounds = 1000): Promise<void> => {
6
+ if (predicate() || rounds <= 0) {
7
+ return
8
+ }
9
+ await new Promise<void>((resolve) => queueMicrotask(resolve))
10
+ return until(predicate, rounds - 1)
11
+ }
5
12
 
6
13
  const counter = () => ({ n: 0 })
7
14
 
@@ -14,19 +21,26 @@ test('subscribe observes changes; getSnapshot is always current', async () => {
14
21
 
15
22
  store.set(1)
16
23
  expect(store.getSnapshot()).toBe(1)
17
- await tick()
24
+ await until(() => notifications.n > 0)
18
25
  expect(notifications.n).toBe(1)
19
26
  })
20
27
 
21
28
  test('a no-op write notifies no one', async () => {
22
29
  const store = makeStore('same')
30
+ const witness = makeStore(0)
23
31
  const notifications = counter()
32
+ const witnessWakes = counter()
24
33
  store.subscribe(() => {
25
34
  notifications.n++
26
35
  })
36
+ witness.subscribe(() => {
37
+ witnessWakes.n++
38
+ })
27
39
 
28
40
  store.set('same')
29
- await tick()
41
+ // Both stores share the scheduler, so the witness flush drains anything the no-op could have queued.
42
+ witness.set(1)
43
+ await until(() => witnessWakes.n > 0)
30
44
  expect(notifications.n).toBe(0)
31
45
  expect(store.getSnapshot()).toBe('same')
32
46
  })
@@ -42,7 +56,8 @@ test('a batch of writes coalesces into a single notification per microtask', asy
42
56
  store.set(2)
43
57
  store.set(3)
44
58
  expect(store.getSnapshot()).toBe(3)
45
- await tick()
59
+ // The drain is synchronous: the first wake already carries every write the batch would have fired.
60
+ await until(() => notifications.n > 0)
46
61
  expect(notifications.n).toBe(1)
47
62
  })
48
63
 
@@ -59,20 +74,26 @@ test('stores are independent — one slice does not wake another', async () => {
59
74
  })
60
75
 
61
76
  a.set(1)
62
- await tick()
77
+ await until(() => aWakes.n > 0)
63
78
  expect(aWakes.n).toBe(1)
64
79
  expect(bWakes.n).toBe(0)
65
80
  })
66
81
 
67
82
  test('unsubscribe stops notifications', async () => {
68
83
  const store = makeStore(0)
84
+ const witness = makeStore(0)
69
85
  const notifications = counter()
86
+ const witnessWakes = counter()
70
87
  const unsubscribe = store.subscribe(() => {
71
88
  notifications.n++
72
89
  })
90
+ witness.subscribe(() => {
91
+ witnessWakes.n++
92
+ })
73
93
 
74
94
  unsubscribe()
75
95
  store.set(1)
76
- await tick()
96
+ witness.set(1)
97
+ await until(() => witnessWakes.n > 0)
77
98
  expect(notifications.n).toBe(0)
78
99
  })
@@ -10,8 +10,9 @@ export interface Tracker {
10
10
  add(source: Subscribable): void
11
11
  }
12
12
 
13
- const CurrentTrackerBase: Context.TagClass<CurrentTracker, 'reform/Tracker', Tracker> =
14
- Context.Tag('reform/Tracker')<CurrentTracker, Tracker>()
13
+ const CurrentTrackerBase: Context.TagClass<CurrentTracker, 'reform/Tracker', Tracker> = Context.Tag(
14
+ 'reform/Tracker',
15
+ )<CurrentTracker, Tracker>()
15
16
  export class CurrentTracker extends CurrentTrackerBase {}
16
17
 
17
18
  export const readTracked = <A>(store: Store<A>): Effect.Effect<A> =>