@playfast/reform 0.0.10 → 0.0.11

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 (45) hide show
  1. package/package.json +1 -1
  2. package/src/boundary/boundary.ts +16 -7
  3. package/src/calc/asyncCalc.ts +84 -37
  4. package/src/calc/asyncData.ts +8 -4
  5. package/src/calc/calc.ts +7 -3
  6. package/src/calc/calcFamily.ts +24 -10
  7. package/src/calc/compose.ts +1 -1
  8. package/src/calc/queryState.ts +8 -8
  9. package/src/channel/channel.ts +63 -37
  10. package/src/compose/composition.ts +20 -1
  11. package/src/compose/provide.ts +5 -1
  12. package/src/compose/slot.ts +4 -2
  13. package/src/compose/structure.ts +43 -19
  14. package/src/compose/ui.ts +21 -2
  15. package/src/compose/ui.typecheck.ts +4 -4
  16. package/src/definition/definition.ts +20 -7
  17. package/src/feature/feature.test.ts +4 -4
  18. package/src/feature/feature.ts +30 -16
  19. package/src/feature/feature.typecheck.ts +2 -2
  20. package/src/internal/capture.ts +1 -0
  21. package/src/internal/errors.ts +9 -4
  22. package/src/internal/inspect.ts +4 -4
  23. package/src/internal/queryDriver.ts +102 -53
  24. package/src/internal/reuse.ts +67 -30
  25. package/src/internal/scheduler.ts +35 -23
  26. package/src/internal/sources.ts +14 -8
  27. package/src/internal/stateRegistry.ts +3 -1
  28. package/src/internal/store.ts +10 -8
  29. package/src/internal/track.ts +3 -1
  30. package/src/procedure/procedure.ts +2 -2
  31. package/src/reducer/reducer.ts +17 -9
  32. package/src/remote/remoteState.test.ts +188 -1
  33. package/src/remote/remoteState.ts +112 -51
  34. package/src/remote/remoteState.typecheck.ts +4 -1
  35. package/src/runtime/bus.ts +3 -1
  36. package/src/runtime/hardening.test.ts +1 -1
  37. package/src/runtime/loop.ts +67 -46
  38. package/src/runtime/queries.ts +3 -1
  39. package/src/scene/scene.ts +16 -8
  40. package/src/state/state.ts +11 -10
  41. package/src/state/stateFamily.ts +27 -11
  42. package/src/state/stateGroup.ts +22 -9
  43. package/src/synced/syncedStore.ts +15 -9
  44. package/src/wire/tree.ts +66 -30
  45. package/src/wire/triggers.ts +9 -8
@@ -79,7 +79,18 @@ export interface FailedIntent<I> {
79
79
  readonly error: unknown
80
80
  }
81
81
 
82
- export interface RemoteStateManifest<N extends string, A, E> extends Manifest {
82
+ // The erased event envelope the queue reducer folds over (bus boundary shape ⇒
83
+ // `ExternalApi` postfix exempts the optional `intent` field). Only `Queued`
84
+ // carries an `intent`; ack/settle envelopes name an op only.
85
+ interface QueueFoldEventExternalApi<I> {
86
+ readonly _tag: string
87
+ readonly opId: string
88
+ readonly intent?: I
89
+ }
90
+
91
+ // Reflective metadata read by chrome/dev tools (boundary shape ⇒ `ExternalApi`
92
+ // postfix exempts it from `no-optional-fields`).
93
+ export interface RemoteStateManifestExternalApi<N extends string, A, E> extends Manifest {
83
94
  readonly kind: 'RemoteState'
84
95
  readonly name: N
85
96
  /** Schema of the server value (`Success` arm / `apply`'s domain). */
@@ -100,7 +111,7 @@ export interface RemoteStateClass<
100
111
  in out Gated extends boolean,
101
112
  > extends Effect.Effect<AsyncData<A, E, Gated>, never, Store<AsyncData<A, E, Gated>>> {
102
113
  new (): {}
103
- readonly manifest: RemoteStateManifest<N, A, E>
114
+ readonly manifest: RemoteStateManifestExternalApi<N, A, E>
104
115
  /** The OVERLAID view (`pending.reduce(apply, truth)`) — what `yield*` reads. */
105
116
  readonly store: Context.Tag<Store<AsyncData<A, E, Gated>>, Store<AsyncData<A, E, Gated>>>
106
117
  /** The name, so the class doubles as a `Source` input to another calc. */
@@ -126,7 +137,9 @@ export interface RemoteStateClass<
126
137
  readonly Failed: Event.EventClass<`${N}/Failed`, FailedIntent<Event.EventType<Intents[number]>>>
127
138
  }
128
139
 
129
- export interface RemoteStateConfig<
140
+ // Public definition-surface config (boundary shape ⇒ `ExternalApi` postfix
141
+ // exempts the optional option fields from `no-optional-fields`).
142
+ export interface RemoteStateConfigExternalApi<
130
143
  Inputs extends ReadonlyArray<AnySource>,
131
144
  Intents extends ReadonlyArray<Event.AnyEvent>,
132
145
  A,
@@ -167,7 +180,7 @@ export const make = <
167
180
  const AlwaysOn extends boolean = false,
168
181
  >(
169
182
  name: N,
170
- config: RemoteStateConfig<Inputs, Intents, A, E, AlwaysOn>,
183
+ config: RemoteStateConfigExternalApi<Inputs, Intents, A, E, AlwaysOn>,
171
184
  ): RemoteStateClass<N, Inputs, Intents, A, E, GatedOf<AlwaysOn>> => {
172
185
  type I = Event.EventType<Intents[number]>
173
186
  const store = Context.GenericTag<Store<AsyncData<A, E, GatedOf<AlwaysOn>>>>(
@@ -180,7 +193,7 @@ export const make = <
180
193
  `reform/remoteState/${name}/pending`,
181
194
  )
182
195
  const gated = gatedFlag(config.alwaysOn)
183
- const manifest: RemoteStateManifest<N, A, E> = {
196
+ const manifest: RemoteStateManifestExternalApi<N, A, E> = {
184
197
  kind: 'RemoteState',
185
198
  name,
186
199
  output: config.output,
@@ -216,7 +229,7 @@ export const make = <
216
229
  * The `.live` config: the query (same contract as `AsyncCalc.live`), the
217
230
  * delivery effect, and the pure per-intent fold.
218
231
  */
219
- export type RemoteStateLive<
232
+ export type RemoteStateLiveExternalApi<
220
233
  Inputs extends ReadonlyArray<AnySource>,
221
234
  Intents extends ReadonlyArray<Event.AnyEvent>,
222
235
  A,
@@ -247,10 +260,36 @@ export type RemoteStateLive<
247
260
  readonly coalesce?: 'switch' | 'trailing'
248
261
  /** Structural sharing across refetches AND overlay recomputes — see `AsyncCalc.live`. */
249
262
  readonly reuse?: boolean
263
+ /**
264
+ * Persist the converged `Success` truth through the optional host `QueryStore`
265
+ * — see `AsyncCalc.live`. Only the settled SERVER value is written: the driver's
266
+ * `persistWrite` runs in the success lane after `settleSuccess`, while the
267
+ * optimistic overlay lives in a separate downstream store the driver never
268
+ * sees — so a persisted value can never capture an un-acked mutation. `true`
269
+ * keys by the remote state's name; `{ key }` overrides (a function of the
270
+ * inputs for a keyed family). Values (de)serialize through the `output` schema.
271
+ */
272
+ readonly persist?:
273
+ | boolean
274
+ | { readonly key?: string | ((inputs: InputsObject<Inputs>) => string) }
250
275
  } & (Gated extends true
251
276
  ? { readonly disabled?: (inputs: InputsObject<Inputs>) => boolean }
252
277
  : { readonly disabled?: never })
253
278
 
279
+ // Loose runtime view: `disabled` is erased to `never` for non-gated definitions
280
+ // at the type level, so the driver reads config back through this (boundary ⇒
281
+ // `ExternalApi` postfix exempts its optional fields).
282
+ type RemoteStateLiveRuntimeExternalApi<Inputs extends ReadonlyArray<AnySource>, A, E, R> = {
283
+ readonly query: (inputs: InputsObject<Inputs>) => Effect.Effect<A, E, R>
284
+ readonly invalidateBy?: InvalidateBy<Inputs>
285
+ readonly disabled?: (inputs: InputsObject<Inputs>) => boolean
286
+ readonly coalesce?: 'switch' | 'trailing'
287
+ readonly reuse?: boolean
288
+ readonly persist?:
289
+ | boolean
290
+ | { readonly key?: string | ((inputs: InputsObject<Inputs>) => string) }
291
+ }
292
+
254
293
  /** The seam between the send procedure and the query driver (module-private). */
255
294
  interface SettleLink {
256
295
  /** The driver's highest REQUESTED run generation (see `QueryDriver.requested`). */
@@ -278,7 +317,7 @@ export const live = <
278
317
  R2,
279
318
  >(
280
319
  remote: RemoteStateClass<N, Inputs, Intents, A, E, Gated>,
281
- config: RemoteStateLive<Inputs, Intents, A, E, Gated, R, R2>,
320
+ config: RemoteStateLiveExternalApi<Inputs, Intents, A, E, Gated, R, R2>,
282
321
  ): Layer.Layer<
283
322
  | Store<AsyncData<A, E, Gated>>
284
323
  | Store<ReadonlyArray<PendingIntent<Event.EventType<Intents[number]>>>>,
@@ -312,9 +351,13 @@ export const live = <
312
351
  // The hidden revision pair (the `invalidateOn` machinery, unconditional here:
313
352
  // `Invalidated` always drives it; user `invalidateOn` events join the same fold).
314
353
  const revisionState = State.make(`${name}/revision`, RevisionSchema)
354
+ const extraInvalidateOn = Option.getOrElse(
355
+ Option.fromNullable(config.invalidateOn),
356
+ (): ReadonlyArray<Event.AnyEvent> => [],
357
+ )
315
358
  const revisionReducer = Reducer.make(`${name}/revision`, {
316
359
  states: [revisionState],
317
- events: [Invalidated, ...(config.invalidateOn ?? [])],
360
+ events: [Invalidated, ...extraInvalidateOn],
318
361
  })
319
362
 
320
363
  // The settle link, keyed per live so two remote states never cross-resolve.
@@ -325,13 +368,8 @@ export const live = <
325
368
  Effect.gen(function* () {
326
369
  // `disabled` is rejected at the type level for non-gated definitions
327
370
  // (erased to `never` there); read it through a loose view for the runtime.
328
- const cfg = config as {
329
- readonly query: (inputs: InputsObject<Inputs>) => Effect.Effect<A, E, R>
330
- readonly invalidateBy?: InvalidateBy<Inputs>
331
- readonly disabled?: (inputs: InputsObject<Inputs>) => boolean
332
- readonly coalesce?: 'switch' | 'trailing'
333
- readonly reuse?: boolean
334
- }
371
+ // 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
372
+ const cfg = config as RemoteStateLiveRuntimeExternalApi<Inputs, A, E, R>
335
373
  // Requirement-free read (`serviceOption`): the assembly below always
336
374
  // provides the revision store alongside this driver.
337
375
  const revision = Option.getOrUndefined(yield* Effect.serviceOption(revisionState.store))
@@ -345,17 +383,35 @@ export const live = <
345
383
  >([])
346
384
  const onSettled = (generation: number) => {
347
385
  const due = MutableRef.get(waiters).filter((waiter) => waiter.waitFor <= generation)
348
- if (due.length === 0) return
386
+ if (due.length === 0) {
387
+ return
388
+ }
349
389
  MutableRef.update(waiters, (all) => all.filter((waiter) => waiter.waitFor > generation))
350
390
  // Dispatch synchronously (`publish` only enqueues on the unbounded bus):
351
391
  // the settle is enqueued in the same flush as the converged value write.
352
- for (const waiter of due) {
392
+ due.forEach((waiter) =>
353
393
  Runtime.runSync(runtime)(
354
394
  publish('Normal', Event.construct(Settled, { opId: waiter.opId })),
355
- )
356
- }
395
+ ),
396
+ )
357
397
  }
358
398
 
399
+ // Persist mirrors `AsyncCalc.live`: only the settled truth reaches the
400
+ // store (see the `persist` doc above), so it needs no extra success/overlay
401
+ // guard. `true` keys by name; `{ key }` overrides (a function for a family).
402
+ const persistOption = cfg.persist
403
+ const persistKey =
404
+ persistOption === true || persistOption === undefined || persistOption === false
405
+ ? name
406
+ : persistOption.key ?? name
407
+ const persist =
408
+ persistOption === undefined || persistOption === false
409
+ ? undefined
410
+ : { key: persistKey, schema: remote.manifest.output }
411
+ const extraKey =
412
+ revision === undefined
413
+ ? undefined
414
+ : { read: () => revision.getSnapshot(), subscribe: (listener: () => void) => revision.subscribe(listener) }
359
415
  const driver = yield* makeQueryDriver({
360
416
  name,
361
417
  label: 'RemoteState',
@@ -366,13 +422,8 @@ export const live = <
366
422
  disabled: cfg.disabled,
367
423
  coalesce: cfg.coalesce,
368
424
  reuse: cfg.reuse,
369
- extraKey:
370
- revision === undefined
371
- ? undefined
372
- : {
373
- read: () => revision.getSnapshot(),
374
- subscribe: (listener) => revision.subscribe(listener),
375
- },
425
+ persist,
426
+ extraKey,
376
427
  onSettled,
377
428
  })
378
429
  const link: SettleLink = {
@@ -402,17 +453,16 @@ export const live = <
402
453
  const pending = queueStore.getSnapshot()
403
454
  const key = [feed, pending]
404
455
  const prev = MutableRef.get(memo)
405
- if (prev !== undefined && sameKey(key, prev.key)) return prev.output
456
+ if (prev !== undefined && sameKey(key, prev.key)) {
457
+ return prev.output
458
+ }
406
459
  // `apply` runs over every `Success` — including `refetching: true`, so
407
460
  // queued intents stay visible while the server converges; the other
408
461
  // arms pass through by reference.
462
+ const applyPending = (base: A): A =>
463
+ pending.reduce((applied, entry) => config.apply(applied, entry.intent), base)
409
464
  const overlaid: AnyAsyncData<A, E> =
410
- feed._tag === 'Success'
411
- ? AsyncData.success(
412
- pending.reduce((value, entry) => config.apply(value, entry.intent), feed.value),
413
- feed.refetching,
414
- )
415
- : feed
465
+ feed._tag === 'Success' ? AsyncData.success(applyPending(feed.value), feed.refetching) : feed
416
466
  // With `reuse`, also reconcile consecutive overlay outputs: the fold
417
467
  // rebuilds the value per recompute, so untouched subtrees would
418
468
  // otherwise lose identity every time the queue moves.
@@ -449,24 +499,35 @@ export const live = <
449
499
  const queueHandles: ReadonlySet<string> = new Set([Queued.tag, Acked.tag, Settled.tag])
450
500
  const foldQueue = (
451
501
  queue: ReadonlyArray<PendingIntent<I>>,
452
- event: { readonly _tag: string; readonly opId: string; readonly intent?: I },
453
- ): ReadonlyArray<PendingIntent<I>> =>
502
+ event: QueueFoldEventExternalApi<I>,
503
+ ): ReadonlyArray<PendingIntent<I>> => {
454
504
  // `Queued` is the arm carrying the intent (the `undefined` guard is for the
455
- // type only — every `Queued` carries one). An ack/settle for an id that is
505
+ // type only — every `Queued` carries one).
506
+ if (event._tag === Queued.tag) {
507
+ if (event.intent === undefined) {
508
+ return queue
509
+ }
510
+ return [...queue, { opId: event.opId, intent: event.intent, status: 'sending' }]
511
+ }
512
+ // An ack flips a still-sending op to confirmed; an ack for an id that is
456
513
  // absent (or already confirmed) keeps the same reference — a true no-op.
457
- event._tag === Queued.tag
458
- ? event.intent === undefined
459
- ? queue
460
- : [...queue, { opId: event.opId, intent: event.intent, status: 'sending' }]
461
- : event._tag === Acked.tag
462
- ? queue.some((entry) => entry.opId === event.opId && entry.status === 'sending')
463
- ? queue.map((entry) =>
464
- entry.opId === event.opId ? { ...entry, status: 'confirmed' as const } : entry,
465
- )
466
- : queue
467
- : queue.some((entry) => entry.opId === event.opId)
468
- ? queue.filter((entry) => entry.opId !== event.opId)
469
- : queue
514
+ if (event._tag === Acked.tag) {
515
+ const stillSending = queue.some(
516
+ (entry) => entry.opId === event.opId && entry.status === 'sending',
517
+ )
518
+ if (!stillSending) {
519
+ return queue
520
+ }
521
+ return queue.map((entry) =>
522
+ entry.opId === event.opId ? { ...entry, status: 'confirmed' as const } : entry,
523
+ )
524
+ }
525
+ // Settle drops the op if present; absent ⇒ same reference, a true no-op.
526
+ if (!queue.some((entry) => entry.opId === event.opId)) {
527
+ return queue
528
+ }
529
+ return queue.filter((entry) => entry.opId !== event.opId)
530
+ }
470
531
  const queueReducerLayer = Layer.scopedDiscard(
471
532
  Effect.gen(function* () {
472
533
  const reducers = yield* Reducers
@@ -502,8 +563,8 @@ export const live = <
502
563
  const link = yield* linkTag
503
564
  // Snapshot the body's full context (Bus + the send's R2) so `run` is total.
504
565
  const runtime = yield* Effect.runtime<Bus | R2>()
505
- const deliver = (intent: I): Effect.Effect<void, never, Bus | R2> =>
506
- Effect.gen(function* () {
566
+ const deliver = Effect.fn('deliver')(
567
+ function* (intent: I): Effect.fn.Return<void, never, Bus | R2> {
507
568
  const opId = yield* Effect.sync(() => crypto.randomUUID())
508
569
  yield* Event.dispatch(Queued, { opId, intent })
509
570
  const outcome = yield* config.send(intent).pipe(
@@ -25,6 +25,9 @@ const RenameIntentBase: Event.EventClass<
25
25
  > = Event.make('TRenameIntent', S.Struct({ id: S.String, name: S.String }))
26
26
  class RenameIntent extends RenameIntentBase {}
27
27
  type Intent = Event.EventType<typeof AddIntent> | Event.EventType<typeof RenameIntent>
28
+ // An undeclared intent tag, named so the negative `apply` below avoids an inline
29
+ // object-type parameter.
30
+ type DeleteIntentTag = { readonly _tag: 'TDeleteIntent' }
28
31
 
29
32
  const PageBase: State.StateClass<'tcPage', number> = State.make('tcPage', S.Number)
30
33
  class Page extends PageBase {}
@@ -94,7 +97,7 @@ export const badIntent: Layer.Layer<
94
97
  query: () => Effect.succeed<ReadonlyArray<Row>>([]),
95
98
  send: () => Effect.void,
96
99
  // @ts-expect-error 'TDeleteIntent' is not in the declared intent union
97
- apply: (rows: ReadonlyArray<Row>, intent: { readonly _tag: 'TDeleteIntent' }) => rows,
100
+ apply: (rows: ReadonlyArray<Row>, intent: DeleteIntentTag) => rows,
98
101
  })
99
102
 
100
103
  // --- layer requirement honesty: RIn is complete and exact ---
@@ -12,7 +12,9 @@ export interface Tagged {
12
12
  * generic `Tagged`, and this restores the typed view. The one documented home
13
13
  * for that boundary cast, shared by `Reducer.live` and `Procedure.live`.
14
14
  */
15
- export const narrowHandled = <E>(event: Tagged): E => event as never
15
+ export const narrowHandled = <E>(event: Tagged): E =>
16
+ // oxlint-disable-next-line reform-rules/no-type-assertion -- documented runtime re-narrow of a routed Tagged envelope to its handler's declared event union (see jsdoc above)
17
+ event as never
16
18
 
17
19
  /** UI-originated events are High; procedure-emitted follow-ups are Normal (DESIGN #46). */
18
20
  export type Priority = 'High' | 'Normal'
@@ -51,7 +51,7 @@ it.effect('two different channels registered under one name are rejected', () =>
51
51
  const exit = yield* Effect.scoped(Layer.build(layer)).pipe(Effect.exit)
52
52
  expect(Exit.isFailure(exit)).toBe(true)
53
53
  if (Exit.isFailure(exit)) {
54
- expect(Cause.pretty(exit.cause)).toContain('duplicate Channel')
54
+ expect(Cause.pretty(exit.cause)).toContain('already live with a different policy')
55
55
  }
56
56
  })
57
57
  })
@@ -1,4 +1,4 @@
1
- import { Chunk, Context, Effect, Layer, PubSub, Queue } from 'effect'
1
+ import { Array as Arr, Chunk, Context, Effect, Layer, Option, PubSub, Queue } from 'effect'
2
2
  import {
3
3
  Channels,
4
4
  channelsLayer,
@@ -44,37 +44,67 @@ const ReducersBase: Context.TagClass<Reducers, 'reform/Reducers', ReducerRegistr
44
44
  Context.Tag('reform/Reducers')<Reducers, ReducerRegistry>()
45
45
  export class Reducers extends ReducersBase {}
46
46
 
47
- /** Drop an item from a `Map<string, Array>` bucket, pruning the key when it empties. */
48
- const dropFromBuckets = <T>(map: Map<string, Array<T>>, key: string, item: T): void => {
47
+ /** Drop a target from a `Map<string, Array>` bucket, pruning the key when it empties. */
48
+ const dropFromBuckets = <T>(map: Map<string, Array<T>>, key: string, target: T): void => {
49
49
  const bucket = map.get(key)
50
- if (bucket === undefined) return
51
- const next = bucket.filter((entry) => entry !== item)
52
- if (next.length === 0) map.delete(key)
53
- else map.set(key, next)
50
+ if (bucket === undefined) {
51
+ return
52
+ }
53
+ const next = bucket.filter((entry) => entry !== target)
54
+ if (next.length === 0) {
55
+ map.delete(key)
56
+ } else {
57
+ map.set(key, next)
58
+ }
54
59
  }
55
60
 
56
61
  const makeReducerRegistry = (): ReducerRegistry => {
57
- const entries: Array<ReducerEntry> = []
58
62
  const byTag = new Map<string, Array<ReducerEntry>>()
63
+ // Single live array reference, replaced immutably on register/unregister and
64
+ // exposed through the `entries` getter so consumers always read the current set.
65
+ const state: { entries: Array<ReducerEntry> } = { entries: [] }
59
66
  return {
60
- entries,
67
+ get entries() {
68
+ return state.entries
69
+ },
61
70
  byTag,
62
71
  register: (entry) => {
63
- entries.push(entry)
64
- for (const tag of entry.handles) {
72
+ state.entries = [...state.entries, entry]
73
+ entry.handles.forEach((tag) => {
65
74
  const bucket = byTag.get(tag)
66
- if (bucket === undefined) byTag.set(tag, [entry])
67
- else bucket.push(entry)
68
- }
75
+ if (bucket === undefined) {
76
+ byTag.set(tag, [entry])
77
+ } else {
78
+ byTag.set(tag, [...bucket, entry])
79
+ }
80
+ })
69
81
  },
70
82
  unregister: (entry) => {
71
- const index = entries.indexOf(entry)
72
- if (index >= 0) entries.splice(index, 1)
73
- for (const tag of entry.handles) dropFromBuckets(byTag, tag, entry)
83
+ state.entries = state.entries.filter((existing) => existing !== entry)
84
+ entry.handles.forEach((tag) => dropFromBuckets(byTag, tag, entry))
74
85
  },
75
86
  }
76
87
  }
77
88
 
89
+ /** Read a bucket array, defaulting to empty without a falsy fallback. */
90
+ const bucketOf = <V>(map: Map<string, Array<V>>, tag: string): Array<V> =>
91
+ Option.getOrElse(Option.fromNullable(map.get(tag)), (): Array<V> => [])
92
+
93
+ /** Run one reducer in isolation; a synchronous throw becomes a collected failure. */
94
+ const isolateApply = (
95
+ reducer: ReducerEntry,
96
+ event: Tagged,
97
+ ): Option.Option<{ readonly event: Tagged; readonly error: unknown }> =>
98
+ Effect.runSync(
99
+ Effect.match(
100
+ Effect.try({ try: () => reducer.apply(event), catch: (error) => error }),
101
+ {
102
+ onFailure: (error) => Option.some({ event, error }),
103
+ onSuccess: () => Option.none(),
104
+ },
105
+ ),
106
+ )
107
+
78
108
  export const reducersLayer: Layer.Layer<Reducers> = Layer.sync(Reducers, makeReducerRegistry)
79
109
 
80
110
  /** High before Normal; within a priority class, dispatch order is preserved. */
@@ -101,42 +131,33 @@ const drain = Effect.gen(function* () {
101
131
 
102
132
  // Stable priority order: High-dispatched (UI) events fold before
103
133
  // Normal-dispatched (procedure follow-up) events in the same frame. A
104
- // two-bucket partition does this in O(m) with no comparator — push order
134
+ // two-bucket partition does this in O(m) with no comparator — partition
105
135
  // preserves intra-priority dispatch order — instead of sorting the frame.
106
- const high: Array<Envelope> = []
107
- const normal: Array<Envelope> = []
108
- for (const envelope of frame) {
109
- if (rank(envelope.priority) === 0) high.push(envelope)
110
- else normal.push(envelope)
111
- }
112
- const ordered = high.concat(normal)
136
+ const [high, normal] = Arr.partition(frame, (envelope) => rank(envelope.priority) !== 0)
137
+ const ordered = [...high, ...normal]
113
138
 
114
139
  // One synchronous block: every reducer write for the frame coalesces into
115
140
  // a single notification flush. Routing to channels happens here too, so
116
141
  // procedure bodies (run later, off their channel fibers) see post-batch
117
142
  // state. No `yield*` in between, or the flush could fire early.
118
- const failures: Array<{ readonly event: Tagged; readonly error: unknown }> = []
119
- yield* Effect.sync(() => {
120
- for (const envelope of ordered) {
121
- for (const reducer of reducers.byTag.get(envelope.event._tag) ?? []) {
122
- // Isolate each fold: a synchronous throw in one reducer must not
123
- // abandon the rest of the frame nor (via the `forever` below) kill
124
- // the drain fiber and freeze the whole app. Collect and log after.
125
- try {
126
- reducer.apply(envelope.event)
127
- } catch (error) {
128
- failures.push({ event: envelope.event, error })
129
- }
130
- }
131
- }
132
- for (const envelope of ordered) {
133
- // Offer to each DISTINCT channel once. Routing per-procedure would
134
- // offer a shared channel multiple times for one event, and each offer
135
- // re-runs every procedure on it — duplicate execution.
136
- for (const channelName of procedures.channelsByTag.get(envelope.event._tag) ?? []) {
143
+ const failures = yield* Effect.sync(() => {
144
+ // Isolate each fold: a synchronous throw in one reducer must not abandon
145
+ // the rest of the frame nor (via the `forever` below) kill the drain fiber
146
+ // and freeze the whole app. Collect failures here, log after the block.
147
+ const collected = Arr.flatMap(ordered, (envelope) =>
148
+ Arr.filterMap(bucketOf(reducers.byTag, envelope.event._tag), (reducer) =>
149
+ isolateApply(reducer, envelope.event),
150
+ ),
151
+ )
152
+ // Offer to each DISTINCT channel once. Routing per-procedure would offer a
153
+ // shared channel multiple times for one event, and each offer re-runs every
154
+ // procedure on it — duplicate execution.
155
+ ordered.forEach((envelope) => {
156
+ bucketOf(procedures.channelsByTag, envelope.event._tag).forEach((channelName) => {
137
157
  channels.get(channelName)?.offer(envelope.event)
138
- }
139
- }
158
+ })
159
+ })
160
+ return collected
140
161
  })
141
162
  yield* Effect.forEach(
142
163
  failures,
@@ -45,7 +45,9 @@ const makeQueryRegistry = (): QueryRegistry => {
45
45
  // re-registration (re-mount before the old scope's finalizer runs) must not
46
46
  // be clobbered by the stale handle's unregister.
47
47
  unregister: (handle) => {
48
- if (byName.get(handle.name) === handle) byName.delete(handle.name)
48
+ if (byName.get(handle.name) === handle) {
49
+ byName.delete(handle.name)
50
+ }
49
51
  },
50
52
  }
51
53
  }
@@ -1,4 +1,4 @@
1
- import { Layer } from 'effect'
1
+ import { Layer, Struct } from 'effect'
2
2
  import type { CompositionClass, CompositionService } from '../compose/composition'
3
3
  import type { UiContract } from '../compose/ui'
4
4
  import type { EventOf } from '../event/event'
@@ -41,6 +41,14 @@ export interface Scene<
41
41
  /** Closed wiring (logic + views), each layer seeding its own live state. */
42
42
  readonly provide: ReadonlyArray<Layer.Layer<MountedServices, never, never>>
43
43
  /** Events dispatched once the runtime is live (e.g. `RequestedTodos`). */
44
+ // oxlint-disable-next-line reform-rules/no-optional-fields -- presence-optional boot read as `scene.boot ?? []` across non-batch hosts (drive/proof/remote/react/editor); Option would break them
45
+ readonly boot?: ReadonlyArray<BootEvent>
46
+ }
47
+
48
+ /** Closed wiring + optional boot events handed to {@link scene}. */
49
+ export interface SceneConfig {
50
+ readonly provide: ReadonlyArray<Layer.Layer<MountedServices, never, never>>
51
+ // oxlint-disable-next-line reform-rules/no-optional-fields -- mirrors Scene.boot; presence-optional to keep the authoring call site terse and back-compatible
44
52
  readonly boot?: ReadonlyArray<BootEvent>
45
53
  }
46
54
 
@@ -51,10 +59,7 @@ export interface Scene<
51
59
  */
52
60
  export const scene = <C extends UiContract, S extends ReadonlyArray<unknown>>(
53
61
  composition: CompositionClass<unknown, C, S>,
54
- config: {
55
- readonly provide: ReadonlyArray<Layer.Layer<MountedServices, never, never>>
56
- readonly boot?: ReadonlyArray<BootEvent>
57
- },
62
+ config: SceneConfig,
58
63
  ): Scene<C, S> => ({
59
64
  kind: 'Scene',
60
65
  composition,
@@ -79,10 +84,13 @@ export const seedScene = <C extends UiContract, S extends ReadonlyArray<unknown>
79
84
  base: Scene<C, S>,
80
85
  seeds: SeedsOf<S>,
81
86
  ): Scene<C, S> =>
82
- Object.keys(seeds).length === 0
87
+ Struct.keys(seeds).length === 0
83
88
  ? base
84
89
  : { ...base, provide: base.provide.map(Layer.locally(CurrentSeedOverrides, seeds)) }
85
90
 
86
91
  /** Reflection guard: is this exported value a scene? */
87
- export const isScene = (v: unknown): v is Scene =>
88
- typeof v === 'object' && v !== null && (v as { readonly kind?: unknown }).kind === 'Scene'
92
+ export const isScene = (candidate: unknown): candidate is Scene =>
93
+ typeof candidate === 'object' &&
94
+ candidate !== null &&
95
+ 'kind' in candidate &&
96
+ candidate.kind === 'Scene'
@@ -6,18 +6,20 @@ import { claimStateTag } from '../internal/stateRegistry'
6
6
  import { makeStore, type Store } from '../internal/store'
7
7
  import { readTracked } from '../internal/track'
8
8
 
9
- export interface StateOptions {
9
+ export interface StateOptionsExternalApi {
10
10
  readonly title?: string
11
11
  readonly description?: string
12
12
  }
13
+ export type StateOptions = StateOptionsExternalApi
13
14
 
14
- export interface StateManifest<N extends string, A> extends Manifest {
15
+ export interface StateManifestExternalApi<N extends string, A> extends Manifest {
15
16
  readonly kind: 'State'
16
17
  readonly name: N
17
18
  readonly schema: Schema.Schema<A, any>
18
19
  readonly title?: string
19
20
  readonly description?: string
20
21
  }
22
+ export type StateManifest<N extends string, A> = StateManifestExternalApi<N, A>
21
23
 
22
24
  export interface StateClass<out N extends string, in out A> extends Effect.Effect<A, never, Store<A>> {
23
25
  new (): {}
@@ -68,14 +70,13 @@ const resolveSeed = <S extends AnyState>(
68
70
  state: S,
69
71
  initial: StateValue<S>,
70
72
  ): Effect.Effect<StateValue<S>> =>
71
- Effect.map(FiberRef.get(CurrentSeedOverrides), (overrides) =>
72
- state.manifest.name in overrides
73
- ? Option.getOrElse(
74
- Schema.decodeUnknownOption(state.manifest.schema)(overrides[state.manifest.name]),
75
- () => initial,
76
- )
77
- : initial,
78
- )
73
+ Effect.map(FiberRef.get(CurrentSeedOverrides), (overrides) => {
74
+ if (!(state.manifest.name in overrides)) {
75
+ return initial
76
+ }
77
+ const override = Schema.decodeUnknownOption(state.manifest.schema)(overrides[state.manifest.name])
78
+ return Option.getOrElse(override, () => initial)
79
+ })
79
80
 
80
81
  /**
81
82
  * Allocate a state's store, seeded with `initial` — `State.live(Feed, seed)`.