@playfast/reform 0.0.10 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -11
- package/package.json +1 -1
- package/src/boundary/boundary.ts +16 -7
- package/src/calc/asyncCalc.ts +84 -37
- package/src/calc/asyncData.ts +8 -4
- package/src/calc/calc.ts +7 -3
- package/src/calc/calcFamily.ts +24 -10
- package/src/calc/compose.ts +1 -1
- package/src/calc/queryState.ts +8 -8
- package/src/channel/channel.ts +63 -37
- package/src/compose/composition.ts +20 -1
- package/src/compose/provide.ts +5 -1
- package/src/compose/slot.ts +4 -2
- package/src/compose/structure.ts +43 -19
- package/src/compose/ui.ts +21 -2
- package/src/compose/ui.typecheck.ts +4 -4
- package/src/definition/definition.ts +20 -7
- package/src/feature/feature.test.ts +4 -4
- package/src/feature/feature.ts +30 -16
- package/src/feature/feature.typecheck.ts +2 -2
- package/src/internal/capture.ts +1 -0
- package/src/internal/errors.ts +9 -4
- package/src/internal/inspect.ts +4 -4
- package/src/internal/queryDriver.ts +102 -53
- package/src/internal/reuse.ts +67 -30
- package/src/internal/scheduler.ts +35 -23
- package/src/internal/sources.ts +14 -8
- package/src/internal/stateRegistry.ts +3 -1
- package/src/internal/store.ts +10 -8
- package/src/internal/track.ts +3 -1
- package/src/procedure/procedure.ts +2 -2
- package/src/reducer/reducer.ts +17 -9
- package/src/remote/remoteState.test.ts +188 -1
- package/src/remote/remoteState.ts +112 -51
- package/src/remote/remoteState.typecheck.ts +4 -1
- package/src/runtime/bus.ts +3 -1
- package/src/runtime/hardening.test.ts +1 -1
- package/src/runtime/loop.ts +67 -46
- package/src/runtime/queries.ts +3 -1
- package/src/scene/scene.ts +16 -8
- package/src/state/state.ts +11 -10
- package/src/state/stateFamily.ts +27 -11
- package/src/state/stateGroup.ts +22 -9
- package/src/synced/syncedStore.ts +15 -9
- package/src/wire/tree.ts +66 -30
- 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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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:
|
|
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, ...
|
|
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
|
-
|
|
329
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
370
|
-
|
|
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))
|
|
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:
|
|
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).
|
|
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 ===
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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 = (
|
|
506
|
-
|
|
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:
|
|
100
|
+
apply: (rows: ReadonlyArray<Row>, intent: DeleteIntentTag) => rows,
|
|
98
101
|
})
|
|
99
102
|
|
|
100
103
|
// --- layer requirement honesty: RIn is complete and exact ---
|
package/src/runtime/bus.ts
CHANGED
|
@@ -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 =>
|
|
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('
|
|
54
|
+
expect(Cause.pretty(exit.cause)).toContain('already live with a different policy')
|
|
55
55
|
}
|
|
56
56
|
})
|
|
57
57
|
})
|
package/src/runtime/loop.ts
CHANGED
|
@@ -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
|
|
48
|
-
const dropFromBuckets = <T>(map: Map<string, Array<T>>, key: string,
|
|
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)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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.
|
|
64
|
-
|
|
72
|
+
state.entries = [...state.entries, entry]
|
|
73
|
+
entry.handles.forEach((tag) => {
|
|
65
74
|
const bucket = byTag.get(tag)
|
|
66
|
-
if (bucket === undefined)
|
|
67
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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 —
|
|
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
|
|
107
|
-
const
|
|
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
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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,
|
package/src/runtime/queries.ts
CHANGED
|
@@ -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)
|
|
48
|
+
if (byName.get(handle.name) === handle) {
|
|
49
|
+
byName.delete(handle.name)
|
|
50
|
+
}
|
|
49
51
|
},
|
|
50
52
|
}
|
|
51
53
|
}
|
package/src/scene/scene.ts
CHANGED
|
@@ -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
|
-
|
|
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 = (
|
|
88
|
-
typeof
|
|
92
|
+
export const isScene = (candidate: unknown): candidate is Scene =>
|
|
93
|
+
typeof candidate === 'object' &&
|
|
94
|
+
candidate !== null &&
|
|
95
|
+
'kind' in candidate &&
|
|
96
|
+
candidate.kind === 'Scene'
|
package/src/state/state.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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)`.
|