@playfast/reform 0.1.0 → 1.1.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.
- package/README.md +18 -1
- package/package.json +1 -1
- package/src/boundary/boundary.test.ts +1 -33
- package/src/boundary/boundary.ts +4 -82
- package/src/calc/asyncCalc.invalidate.test.ts +2 -14
- package/src/calc/asyncCalc.test.ts +0 -38
- package/src/calc/asyncCalc.ts +11 -163
- package/src/calc/asyncCalcDefinitions.ts +48 -0
- package/src/calc/asyncData.ts +0 -37
- package/src/calc/calc.test.ts +4 -28
- package/src/calc/calc.ts +16 -44
- package/src/calc/calcFamily.test.ts +0 -12
- package/src/calc/calcFamily.ts +12 -49
- package/src/calc/compose.test.ts +1 -12
- package/src/calc/compose.ts +0 -22
- package/src/calc/queryState.ts +0 -23
- package/src/channel/channel.ts +78 -173
- package/src/channel/procedureRegistry.ts +90 -0
- package/src/compose/composition.ts +7 -67
- package/src/compose/host.ts +0 -6
- package/src/compose/props.ts +3 -9
- package/src/compose/provide.ts +19 -33
- package/src/compose/slot.ts +2 -30
- package/src/compose/structure.test.ts +0 -6
- package/src/compose/structure.ts +3 -67
- package/src/compose/ui.test.ts +0 -4
- package/src/compose/ui.ts +17 -111
- package/src/compose/ui.typecheck.ts +0 -14
- package/src/definition/definition.ts +0 -32
- package/src/event/event.test.ts +0 -3
- package/src/event/event.ts +5 -14
- package/src/event/eventGroup.ts +0 -1
- package/src/feature/feature.mount.test.ts +66 -54
- package/src/feature/feature.test.ts +67 -46
- package/src/feature/feature.ts +185 -214
- package/src/feature/feature.typecheck.ts +29 -16
- package/src/feature/featureBinding.ts +48 -0
- package/src/index.ts +245 -202
- package/src/internal/capture.ts +0 -20
- package/src/internal/ctx.ts +2 -9
- package/src/internal/env.ts +9 -0
- package/src/internal/errors.test.ts +0 -6
- package/src/internal/errors.ts +14 -48
- package/src/internal/inspect.test.ts +0 -6
- package/src/internal/inspect.ts +0 -12
- package/src/internal/queryDriver.ts +48 -243
- package/src/internal/queryDriverStore.ts +153 -0
- package/src/internal/queryDriverTypes.ts +57 -0
- package/src/internal/queryEvents.ts +0 -12
- package/src/internal/queryStore.ts +0 -14
- package/src/internal/reuse.test.ts +0 -11
- package/src/internal/reuse.ts +0 -28
- package/src/internal/scheduler.ts +0 -43
- package/src/internal/seeds.ts +0 -14
- package/src/internal/sources.ts +4 -41
- package/src/internal/stateRegistry.ts +0 -14
- package/src/internal/store.test.ts +1 -3
- package/src/internal/store.ts +10 -37
- package/src/internal/track.ts +0 -18
- package/src/internal/variance.ts +5 -0
- package/src/namespace/namespace.test.ts +46 -0
- package/src/namespace/namespace.ts +85 -0
- package/src/procedure/procedure.ts +2 -24
- package/src/reducer/reducer.ts +3 -21
- package/src/remote/remoteState.test.ts +92 -75
- package/src/remote/remoteState.ts +99 -486
- package/src/remote/remoteState.typecheck.ts +0 -33
- package/src/remote/remoteStateDefinition.ts +135 -0
- package/src/remote/remoteStateLayers.ts +118 -0
- package/src/remote/remoteStateLiveTypes.ts +59 -0
- package/src/remote/remoteStateSend.ts +64 -0
- package/src/runtime/appRuntime.test.ts +197 -0
- package/src/runtime/appRuntime.ts +131 -0
- package/src/runtime/bus.ts +2 -15
- package/src/runtime/eventBudget.test.ts +155 -0
- package/src/runtime/eventBudget.ts +119 -0
- package/src/runtime/hardening.test.ts +74 -9
- package/src/runtime/instrumentation.test.ts +324 -0
- package/src/runtime/instrumentation.ts +44 -0
- package/src/runtime/loop.test.ts +0 -17
- package/src/runtime/loop.ts +41 -58
- package/src/runtime/queries.ts +0 -17
- package/src/scene/featureScene.test.ts +59 -0
- package/src/scene/scene.ts +51 -44
- package/src/scene/seedScene.test.ts +96 -116
- package/src/state/state.ts +12 -28
- package/src/state/stateFamily.test.ts +2 -11
- package/src/state/stateFamily.ts +20 -54
- package/src/state/stateGroup.ts +2 -49
- package/src/state/token.ts +4 -17
- package/src/synced/syncedStore.ts +7 -42
- package/src/ui/node.ts +0 -6
- package/src/ui/trigger.ts +0 -5
- package/src/wire/tree.test.ts +0 -3
- package/src/wire/tree.ts +0 -39
- package/src/wire/triggers.test.ts +0 -4
- package/src/wire/triggers.ts +5 -27
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { Effect, MutableRef, Option, Schema } from 'effect'
|
|
2
|
+
import type { AnyAsyncData } from '../calc/asyncData'
|
|
3
|
+
import { type QueryState, toAsyncData } from '../calc/queryState'
|
|
4
|
+
import type { QueryStoreApi } from './queryStore'
|
|
5
|
+
import type { Scheduler } from './scheduler'
|
|
6
|
+
import { makeDerivedStore, type Store } from './store'
|
|
7
|
+
import type { AnySchema } from './variance'
|
|
8
|
+
import type { InputsObject } from './sources'
|
|
9
|
+
import type { AnySource } from '../state/token'
|
|
10
|
+
|
|
11
|
+
interface HydrateQueryOptions<A, E> {
|
|
12
|
+
readonly cacheKey: string
|
|
13
|
+
readonly queryStore: QueryStoreApi
|
|
14
|
+
readonly schema: AnySchema<A>
|
|
15
|
+
readonly stateStore: Store<QueryState<A, E>>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const hydrateQuery = <A, E>(
|
|
19
|
+
options: HydrateQueryOptions<A, E>,
|
|
20
|
+
): Effect.Effect<void> =>
|
|
21
|
+
Effect.flatMap(options.queryStore.get(options.cacheKey), (cached) =>
|
|
22
|
+
Option.match(cached, {
|
|
23
|
+
onNone: () => Effect.void,
|
|
24
|
+
onSome: (raw) =>
|
|
25
|
+
Schema.decodeUnknown(options.schema)(raw).pipe(
|
|
26
|
+
Effect.match({
|
|
27
|
+
onFailure: () => undefined,
|
|
28
|
+
onSuccess: (decoded) =>
|
|
29
|
+
options.stateStore.set({
|
|
30
|
+
data: Option.some(decoded),
|
|
31
|
+
error: Option.none(),
|
|
32
|
+
isFetching: false,
|
|
33
|
+
isStale: true,
|
|
34
|
+
}),
|
|
35
|
+
}),
|
|
36
|
+
),
|
|
37
|
+
}),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
interface PersistQueryValueOptions<A> {
|
|
41
|
+
readonly cacheKey: string
|
|
42
|
+
readonly produced: A
|
|
43
|
+
readonly queryStore: QueryStoreApi
|
|
44
|
+
readonly schema: AnySchema<A>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const persistQueryValue = <A>(options: PersistQueryValueOptions<A>): Effect.Effect<void> =>
|
|
48
|
+
Schema.encode(options.schema)(options.produced).pipe(
|
|
49
|
+
Effect.flatMap((encoded) => options.queryStore.set(options.cacheKey, encoded)),
|
|
50
|
+
Effect.ignore,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
interface PersistWriterOptions<Inputs extends ReadonlyArray<AnySource>, A> {
|
|
54
|
+
readonly persist:
|
|
55
|
+
| { readonly key: string | ((inputs: InputsObject<Inputs>) => string); readonly schema: AnySchema<A> }
|
|
56
|
+
| undefined
|
|
57
|
+
readonly queryStore: QueryStoreApi
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const makePersistWriter = <Inputs extends ReadonlyArray<AnySource>, A>(
|
|
61
|
+
options: PersistWriterOptions<Inputs, A>,
|
|
62
|
+
): ((produced: A, inputs: InputsObject<Inputs>) => Effect.Effect<void>) => {
|
|
63
|
+
if (options.persist === undefined) {
|
|
64
|
+
return () => Effect.void
|
|
65
|
+
}
|
|
66
|
+
const persist = options.persist
|
|
67
|
+
return (produced, inputs) =>
|
|
68
|
+
persistQueryValue({
|
|
69
|
+
cacheKey: typeof persist.key === 'function' ? persist.key(inputs) : persist.key,
|
|
70
|
+
produced,
|
|
71
|
+
queryStore: options.queryStore,
|
|
72
|
+
schema: persist.schema,
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface SubscribableSource {
|
|
77
|
+
readonly subscribe: (listener: () => void) => () => void
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const subscribeQuerySources = (
|
|
81
|
+
sources: SubscribableSource,
|
|
82
|
+
extraKey: SubscribableSource | undefined,
|
|
83
|
+
listener: () => void,
|
|
84
|
+
): (() => void) => {
|
|
85
|
+
const offSources = sources.subscribe(listener)
|
|
86
|
+
const offExtra = extraKey?.subscribe(listener)
|
|
87
|
+
return () => {
|
|
88
|
+
offSources()
|
|
89
|
+
offExtra?.()
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export const settleQueryError = <A, E>(stateStore: Store<QueryState<A, E>>, error: E): void => {
|
|
94
|
+
const previous = stateStore.get()
|
|
95
|
+
stateStore.set({ ...previous, error: Option.some(error), isFetching: false })
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export const incrementGeneration = (generation: MutableRef.MutableRef<number>): number => {
|
|
99
|
+
MutableRef.update(generation, (current) => current + 1)
|
|
100
|
+
return MutableRef.get(generation)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
interface QueryProjectionOptions<A, E> {
|
|
104
|
+
readonly gated: boolean
|
|
105
|
+
readonly scheduler: Scheduler
|
|
106
|
+
readonly stateStore: Store<QueryState<A, E>>
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export const makeQueryProjection = <A, E>(
|
|
110
|
+
options: QueryProjectionOptions<A, E>,
|
|
111
|
+
): { readonly store: Store<AnyAsyncData<A, E>>; readonly unsubscribe: () => void } => {
|
|
112
|
+
const projection = MutableRef.make<
|
|
113
|
+
{ readonly state: QueryState<A, E>; readonly view: AnyAsyncData<A, E> } | undefined
|
|
114
|
+
>(undefined)
|
|
115
|
+
const project = (): AnyAsyncData<A, E> => {
|
|
116
|
+
const state = options.stateStore.get()
|
|
117
|
+
const previous = MutableRef.get(projection)
|
|
118
|
+
if (
|
|
119
|
+
previous !== undefined &&
|
|
120
|
+
previous.state.data === state.data &&
|
|
121
|
+
previous.state.error === state.error &&
|
|
122
|
+
previous.state.isFetching === state.isFetching
|
|
123
|
+
) {
|
|
124
|
+
return previous.view
|
|
125
|
+
}
|
|
126
|
+
const view = toAsyncData(state, options.gated)
|
|
127
|
+
MutableRef.set(projection, { state, view })
|
|
128
|
+
return view
|
|
129
|
+
}
|
|
130
|
+
return makeDerivedStore(project, options.stateStore.subscribe, options.scheduler)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
interface ReaderStoreOptions<A, E> {
|
|
134
|
+
readonly store: Store<AnyAsyncData<A, E>>
|
|
135
|
+
readonly readers: MutableRef.MutableRef<number>
|
|
136
|
+
readonly onFirstReader: () => void
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export const withReaderTracking = <A, E>(options: ReaderStoreOptions<A, E>): Store<AnyAsyncData<A, E>> => ({
|
|
140
|
+
...options.store,
|
|
141
|
+
subscribe: (listener) => {
|
|
142
|
+
const before = MutableRef.get(options.readers)
|
|
143
|
+
MutableRef.set(options.readers, before + 1)
|
|
144
|
+
const off = options.store.subscribe(listener)
|
|
145
|
+
if (before === 0) {
|
|
146
|
+
options.onFirstReader()
|
|
147
|
+
}
|
|
148
|
+
return () => {
|
|
149
|
+
off()
|
|
150
|
+
MutableRef.update(options.readers, (count) => Math.max(0, count - 1))
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
})
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Brand, Effect, Schema } from 'effect'
|
|
2
|
+
import type { AnyAsyncData } from '../calc/asyncData'
|
|
3
|
+
import type { InputsObject, InvalidateBy } from './sources'
|
|
4
|
+
import type { Store } from './store'
|
|
5
|
+
import type { AnySource } from '../state/token'
|
|
6
|
+
import type { AnySchema } from './variance'
|
|
7
|
+
|
|
8
|
+
export type GatedOf<AlwaysOn extends boolean> = AlwaysOn extends true ? false : true
|
|
9
|
+
|
|
10
|
+
export const gatedFlag = <AlwaysOn extends boolean>(alwaysOn: AlwaysOn | undefined): GatedOf<AlwaysOn> =>
|
|
11
|
+
// 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
|
+
(alwaysOn !== true) as GatedOf<AlwaysOn>
|
|
13
|
+
|
|
14
|
+
export type Revision = number & Brand.Brand<'reform/Revision'>
|
|
15
|
+
export const Revision: Brand.Brand.Constructor<Revision> = Brand.nominal<Revision>()
|
|
16
|
+
export const RevisionSchema: Schema.Schema<Revision, number> = Schema.Number.pipe(
|
|
17
|
+
Schema.brand('reform/Revision'),
|
|
18
|
+
)
|
|
19
|
+
export const revisionZero: Revision = Revision(0)
|
|
20
|
+
export const bumpRevision = (revision: Revision): Revision => Revision(revision + 1)
|
|
21
|
+
|
|
22
|
+
export interface QueryDriverOptions<Inputs extends ReadonlyArray<AnySource>, A, E, R> {
|
|
23
|
+
readonly name: string
|
|
24
|
+
readonly label: string
|
|
25
|
+
readonly gated: boolean
|
|
26
|
+
readonly inputs: Inputs
|
|
27
|
+
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
|
+
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
|
+
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
|
+
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
|
+
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
|
+
readonly extraKey?:
|
|
38
|
+
| {
|
|
39
|
+
readonly read: () => unknown
|
|
40
|
+
readonly subscribe: (listener: () => void) => () => void
|
|
41
|
+
}
|
|
42
|
+
| 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
|
+
readonly persist?:
|
|
45
|
+
| {
|
|
46
|
+
readonly key: string | ((inputs: InputsObject<Inputs>) => string)
|
|
47
|
+
readonly schema: AnySchema<A>
|
|
48
|
+
}
|
|
49
|
+
| 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
|
+
readonly onSettled?: ((generation: number) => void) | undefined
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface QueryDriver<A, E> {
|
|
55
|
+
readonly store: Store<AnyAsyncData<A, E>>
|
|
56
|
+
readonly requested: () => number
|
|
57
|
+
}
|
|
@@ -1,17 +1,7 @@
|
|
|
1
1
|
import { Context, Effect, Option } from 'effect'
|
|
2
2
|
|
|
3
|
-
// `QueryEvents` is the optional host-signal seam invalidation *sources* subscribe
|
|
4
|
-
// to — window focus/visibility and network reconnect. It carries no DOM itself:
|
|
5
|
-
// the concrete `window.addEventListener` implementation lives in an edge package
|
|
6
|
-
// (`@playfast/reform-query-browser`); core only declares the shape. Resolved via
|
|
7
|
-
// `serviceOption` with a no-op fallback, so its absence (SSR, native, proofs) is
|
|
8
|
-
// inert. The subscriptions return an unsubscribe thunk — the synchronous,
|
|
9
|
-
// React-facing shape the rest of reform uses (see `Store.subscribe`).
|
|
10
|
-
|
|
11
3
|
export interface QueryEventsApi {
|
|
12
|
-
/** Fires when the window/tab regains focus or visibility. */
|
|
13
4
|
readonly subscribeFocus: (listener: () => void) => () => void
|
|
14
|
-
/** Fires when the network comes back online. */
|
|
15
5
|
readonly subscribeOnline: (listener: () => void) => () => void
|
|
16
6
|
}
|
|
17
7
|
|
|
@@ -21,13 +11,11 @@ export class QueryEvents extends QueryEventsBase {}
|
|
|
21
11
|
|
|
22
12
|
const noop = (): (() => void) => () => {}
|
|
23
13
|
|
|
24
|
-
/** A source that never fires — the fallback when no host provides `QueryEvents`. */
|
|
25
14
|
export const noopQueryEvents: QueryEventsApi = {
|
|
26
15
|
subscribeFocus: noop,
|
|
27
16
|
subscribeOnline: noop,
|
|
28
17
|
}
|
|
29
18
|
|
|
30
|
-
/** Resolve the host's `QueryEvents`, or the no-op source when none is in context. */
|
|
31
19
|
export const resolveQueryEvents: Effect.Effect<QueryEventsApi> = Effect.map(
|
|
32
20
|
Effect.serviceOption(QueryEvents),
|
|
33
21
|
Option.getOrElse(() => noopQueryEvents),
|
|
@@ -1,13 +1,5 @@
|
|
|
1
1
|
import { Context, Effect, Option } from 'effect'
|
|
2
2
|
|
|
3
|
-
// `QueryStore` is the optional persistence seam an `AsyncCalc` writes through and
|
|
4
|
-
// hydrates from — a key→value cache provided by the host (localStorage,
|
|
5
|
-
// IndexedDB, AsyncStorage, …). Like `Notifications`, it is resolved through
|
|
6
|
-
// `serviceOption`, so a calc that opts into `persist` imposes NO hard requirement:
|
|
7
|
-
// absent host ⇒ the no-op store ⇒ the feature is simply inert (SSR, native,
|
|
8
|
-
// proofs). Values cross the boundary as `unknown` and are (de)serialized through
|
|
9
|
-
// the calc's own `output` schema by the driver, never trusted structurally.
|
|
10
|
-
|
|
11
3
|
export interface QueryStoreApi {
|
|
12
4
|
readonly get: (key: string) => Effect.Effect<Option.Option<unknown>>
|
|
13
5
|
readonly set: (key: string, value: unknown) => Effect.Effect<void>
|
|
@@ -18,18 +10,12 @@ const QueryStoreBase: Context.TagClass<QueryStore, 'reform/QueryStore', QuerySto
|
|
|
18
10
|
Context.Tag('reform/QueryStore')<QueryStore, QueryStoreApi>()
|
|
19
11
|
export class QueryStore extends QueryStoreBase {}
|
|
20
12
|
|
|
21
|
-
/** A store that persists nothing — the fallback when no host provides `QueryStore`. */
|
|
22
13
|
export const noopQueryStore: QueryStoreApi = {
|
|
23
14
|
get: () => Effect.succeedNone,
|
|
24
15
|
set: () => Effect.void,
|
|
25
16
|
remove: () => Effect.void,
|
|
26
17
|
}
|
|
27
18
|
|
|
28
|
-
/**
|
|
29
|
-
* Resolve the host's `QueryStore`, or the no-op store when none is in context.
|
|
30
|
-
* `serviceOption` keeps it requirement-free — a calc wired as a sibling of the
|
|
31
|
-
* runtime that provides no persistence simply gets the inert store.
|
|
32
|
-
*/
|
|
33
19
|
export const resolveQueryStore: Effect.Effect<QueryStoreApi> = Effect.map(
|
|
34
20
|
Effect.serviceOption(QueryStore),
|
|
35
21
|
Option.getOrElse(() => noopQueryStore),
|
|
@@ -2,9 +2,6 @@ import { describe, expect, it } from '@effect/vitest'
|
|
|
2
2
|
import { Data, Schema as S } from 'effect'
|
|
3
3
|
import { reuse } from './reuse'
|
|
4
4
|
|
|
5
|
-
// The structural-sharing pass behind `reuse: true`: identities move only where
|
|
6
|
-
// values moved, and the result is always value-equal to the fresh output.
|
|
7
|
-
|
|
8
5
|
describe('reuse', () => {
|
|
9
6
|
it('returns the previous value when the next is deeply value-equal', () => {
|
|
10
7
|
const prev = { a: [1, 2, { b: 'x' }], c: { d: true } }
|
|
@@ -54,9 +51,6 @@ describe('reuse', () => {
|
|
|
54
51
|
const prev = new View({ title: 't', rows: [keep, new Row({ id: 2, name: 'b' })] })
|
|
55
52
|
const next = new View({ title: 't', rows: [new Row({ id: 1, name: 'a' }), new Row({ id: 2, name: 'B' })] })
|
|
56
53
|
const out = reuse(prev, next)
|
|
57
|
-
// The container changed (row 2 renamed) so a fresh View comes back — but it
|
|
58
|
-
// is still a View (prototype preserved) and the untouched row keeps its
|
|
59
|
-
// exact previous identity.
|
|
60
54
|
expect(out).not.toBe(prev)
|
|
61
55
|
expect(out).toBeInstanceOf(View)
|
|
62
56
|
expect(out.rows[0]).toBe(keep)
|
|
@@ -66,14 +60,10 @@ describe('reuse', () => {
|
|
|
66
60
|
})
|
|
67
61
|
|
|
68
62
|
it('compares Dates by instant (Effect Equal) and treats other class instances as opaque leaves', () => {
|
|
69
|
-
// Effect's `Equal.equals` compares Dates structurally: same instant ⇒ the
|
|
70
|
-
// previous identity is kept.
|
|
71
63
|
const prev = { at: new Date(0) }
|
|
72
64
|
const out = reuse(prev, { at: new Date(0) })
|
|
73
65
|
expect(out.at).toBe(prev.at)
|
|
74
66
|
|
|
75
|
-
// A class without Equal: never recursed into, the fresh leaf wins even
|
|
76
|
-
// when its fields look identical.
|
|
77
67
|
class Opaque {
|
|
78
68
|
readonly v: number
|
|
79
69
|
constructor(v: number) {
|
|
@@ -91,7 +81,6 @@ describe('reuse', () => {
|
|
|
91
81
|
const prev = deep(20)
|
|
92
82
|
const next = deep(20)
|
|
93
83
|
const out = reuse(prev, next)
|
|
94
|
-
// Value-equal throughout — shared down to the bound, fresh below it.
|
|
95
84
|
expect(out).toEqual(next)
|
|
96
85
|
})
|
|
97
86
|
|
package/src/internal/reuse.ts
CHANGED
|
@@ -1,14 +1,5 @@
|
|
|
1
1
|
import { Equal, Record as Rec } from 'effect'
|
|
2
2
|
|
|
3
|
-
// Structural sharing for recomputed calc outputs: reconcile a fresh output
|
|
4
|
-
// against the previous one, substituting previous nodes wherever value equality
|
|
5
|
-
// holds, so identities only move where values moved. Downstream memo boundaries
|
|
6
|
-
// (React subtrees keyed on object identity) then skip everything that didn't
|
|
7
|
-
// change. Referentially transparent: the result is value-equal to `next` — only
|
|
8
|
-
// identities shift toward `previous` — and neither argument is mutated, so the
|
|
9
|
-
// pass composes with the framework's Equal-based invalidation untouched.
|
|
10
|
-
|
|
11
|
-
// Bounds the walk; calc outputs are shallow plain data by framework convention.
|
|
12
3
|
const MAX_DEPTH = 16
|
|
13
4
|
|
|
14
5
|
const isPlainRecord = (candidate: unknown): candidate is Record<string, unknown> =>
|
|
@@ -17,7 +8,6 @@ const isPlainRecord = (candidate: unknown): candidate is Record<string, unknown>
|
|
|
17
8
|
(Object.getPrototypeOf(candidate) === Object.prototype ||
|
|
18
9
|
Object.getPrototypeOf(candidate) === null)
|
|
19
10
|
|
|
20
|
-
/** Own enumerable string-keyed fields of an object, as a plain record. */
|
|
21
11
|
const ownRecord = (source: object): Record<string, unknown> =>
|
|
22
12
|
Rec.fromEntries(
|
|
23
13
|
Reflect.ownKeys(source)
|
|
@@ -34,7 +24,6 @@ interface ReconcileArgs {
|
|
|
34
24
|
readonly depth: number
|
|
35
25
|
}
|
|
36
26
|
|
|
37
|
-
/** Reconcile two records field-by-field; reports whether every field stayed `prev`. */
|
|
38
27
|
const reconcileRecord = (
|
|
39
28
|
prev: Record<string, unknown>,
|
|
40
29
|
next: Record<string, unknown>,
|
|
@@ -57,8 +46,6 @@ const reconcile = ({ prev, next, depth }: ReconcileArgs): unknown => {
|
|
|
57
46
|
if (Object.is(prev, next)) {
|
|
58
47
|
return prev
|
|
59
48
|
}
|
|
60
|
-
// Data/Schema classes implement Equal+Hash: substitute wholesale on value
|
|
61
|
-
// equality. Plain objects/arrays fall through (their Equal is referential).
|
|
62
49
|
if (Equal.equals(prev, next)) {
|
|
63
50
|
return prev
|
|
64
51
|
}
|
|
@@ -77,12 +64,6 @@ const reconcile = ({ prev, next, depth }: ReconcileArgs): unknown => {
|
|
|
77
64
|
const { out, allPrev } = reconcileRecord(prev, next, depth)
|
|
78
65
|
return allPrev ? prev : out
|
|
79
66
|
}
|
|
80
|
-
// Data/Schema class instances that DIFFER still get walked: their Equal is
|
|
81
|
-
// fieldwise over own enumerable fields, so a reconstruction over reconciled
|
|
82
|
-
// fields (same prototype; the constructor is bypassed, but every leaf is a
|
|
83
|
-
// validated value out of `prev` or `next`) stays value-equal to `next` while
|
|
84
|
-
// unchanged children — e.g. one untouched element inside a class-typed
|
|
85
|
-
// container — keep their `prev` identity.
|
|
86
67
|
if (
|
|
87
68
|
Equal.isEqual(prev) &&
|
|
88
69
|
Equal.isEqual(next) &&
|
|
@@ -93,18 +74,9 @@ const reconcile = ({ prev, next, depth }: ReconcileArgs): unknown => {
|
|
|
93
74
|
? prev
|
|
94
75
|
: Object.create(Object.getPrototypeOf(next), Object.getOwnPropertyDescriptors(out))
|
|
95
76
|
}
|
|
96
|
-
// Class instances without Equal (Date, Map, …) are opaque leaves.
|
|
97
77
|
return next
|
|
98
78
|
}
|
|
99
79
|
|
|
100
|
-
/**
|
|
101
|
-
* Substitute `previous` nodes into `next` wherever they are value-equal. The
|
|
102
|
-
* walker only ever returns `previous`, `next`, or a key/index-wise
|
|
103
|
-
* reconstruction of `next` whose every leaf came from one of them, so the
|
|
104
|
-
* result is value-equal to `next` and the type is preserved by construction —
|
|
105
|
-
* the single cast below is that argument, in the style of `wireSources` /
|
|
106
|
-
* `narrowStore`.
|
|
107
|
-
*/
|
|
108
80
|
export const reuse = <A>(previous: A, next: A): A =>
|
|
109
81
|
// oxlint-disable-next-line reform-rules/no-type-assertion -- the walker returns a value-equal reconstruction of `next`, type-preserved by construction (the single seam this cast lives at)
|
|
110
82
|
reconcile({ prev: previous, next, depth: MAX_DEPTH }) as A
|
|
@@ -1,16 +1,5 @@
|
|
|
1
1
|
import { Context, Effect, Layer, MutableRef, Option } from 'effect'
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
* Coalesces store notifications into a single microtask flush so a batch of
|
|
5
|
-
* writes wakes each subscriber at most once (RESEARCH #3). React owns frame
|
|
6
|
-
* timing; we only collapse the notifications.
|
|
7
|
-
*
|
|
8
|
-
* One scheduler is shared by every store in a runtime, so writes across
|
|
9
|
-
* different stores in the same tick batch into one flush. The instance is
|
|
10
|
-
* resolved per store at creation: `Notifications` when a runtime provides it
|
|
11
|
-
* (per-runtime isolation — see `resolveScheduler`), else the process-wide
|
|
12
|
-
* `defaultScheduler`.
|
|
13
|
-
*/
|
|
14
3
|
export interface Scheduler {
|
|
15
4
|
readonly schedule: (listeners: Iterable<() => void>) => void
|
|
16
5
|
}
|
|
@@ -19,9 +8,6 @@ export const makeScheduler = (): Scheduler => {
|
|
|
19
8
|
const pending = new Set<() => void>()
|
|
20
9
|
const armed = MutableRef.make(false)
|
|
21
10
|
|
|
22
|
-
// One listener's throw (a defect in a user calc body) must not abandon the
|
|
23
|
-
// rest of the flush; surface it to the host's global handler on a fresh
|
|
24
|
-
// microtask instead of unwinding the drain.
|
|
25
11
|
const runListener = (listener: () => void): void =>
|
|
26
12
|
Effect.runSync(
|
|
27
13
|
Effect.try({ try: listener, catch: (error) => error }).pipe(
|
|
@@ -36,12 +22,6 @@ export const makeScheduler = (): Scheduler => {
|
|
|
36
22
|
),
|
|
37
23
|
)
|
|
38
24
|
|
|
39
|
-
// Drain to a fixpoint within ONE microtask. A derived store's `onChange`
|
|
40
|
-
// re-schedules during the pass (it is itself a listener of its upstream), so
|
|
41
|
-
// resolving the whole dependency graph here — instead of re-arming a fresh
|
|
42
|
-
// microtask per layer — collapses a depth-N propagation into a single flush
|
|
43
|
-
// and wakes each leaf subscriber once at its final value. Converges because
|
|
44
|
-
// a derived store schedules only when its output actually moves (Equal).
|
|
45
25
|
const drain = (): void => {
|
|
46
26
|
if (pending.size === 0) {
|
|
47
27
|
return
|
|
@@ -68,37 +48,14 @@ export const makeScheduler = (): Scheduler => {
|
|
|
68
48
|
return { schedule }
|
|
69
49
|
}
|
|
70
50
|
|
|
71
|
-
/**
|
|
72
|
-
* The process-wide scheduler, used by any store created outside a runtime that
|
|
73
|
-
* provides its own `Notifications`. Fine for a single client app (microtask
|
|
74
|
-
* ordering is global anyway); a runtime that needs isolation — concurrent SSR,
|
|
75
|
-
* multiple mounted roots — provides `Notifications` upstream of its stores.
|
|
76
|
-
*/
|
|
77
51
|
export const defaultScheduler: Scheduler = makeScheduler()
|
|
78
52
|
|
|
79
|
-
/**
|
|
80
|
-
* Optional per-runtime scheduler. When a runtime provides it upstream of its
|
|
81
|
-
* state/calc layers, those stores coalesce on it instead of the global default,
|
|
82
|
-
* isolating their notification timing from other runtimes in the same process.
|
|
83
|
-
*/
|
|
84
53
|
const NotificationsBase: Context.TagClass<Notifications, 'reform/Notifications', Scheduler> =
|
|
85
54
|
Context.Tag('reform/Notifications')<Notifications, Scheduler>()
|
|
86
55
|
export class Notifications extends NotificationsBase {}
|
|
87
56
|
|
|
88
|
-
/**
|
|
89
|
-
* A fresh per-runtime scheduler. Merged into `Engine`, so a runtime that wires
|
|
90
|
-
* its state/calc layers *downstream* of `Engine` gets isolated notification
|
|
91
|
-
* timing; the common sibling wiring falls back to `defaultScheduler` (still
|
|
92
|
-
* correct, just process-shared).
|
|
93
|
-
*/
|
|
94
57
|
export const notificationsLayer: Layer.Layer<Notifications> = Layer.sync(Notifications, makeScheduler)
|
|
95
58
|
|
|
96
|
-
/**
|
|
97
|
-
* Resolve the scheduler a store should use: the runtime's `Notifications` if one
|
|
98
|
-
* is in context, else the global default. Uses `serviceOption` so it imposes no
|
|
99
|
-
* hard requirement — a store layer wired as a sibling of the runtime (the common
|
|
100
|
-
* pattern) simply falls back to the default.
|
|
101
|
-
*/
|
|
102
59
|
export const resolveScheduler: Effect.Effect<Scheduler> = Effect.map(
|
|
103
60
|
Effect.serviceOption(Notifications),
|
|
104
61
|
Option.getOrElse(() => defaultScheduler),
|
package/src/internal/seeds.ts
CHANGED
|
@@ -1,19 +1,5 @@
|
|
|
1
1
|
import { FiberRef, GlobalValue } from 'effect'
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
* The sanctioned tooling/test seam for seeding CLOSED scenes. A scene's layers
|
|
5
|
-
* are pre-composed (each state group seeds its own live store), so an outer
|
|
6
|
-
* layer cannot override an inner store — but a FiberRef set via
|
|
7
|
-
* `Layer.locally(ref, value)(layer)` IS visible inside the construction effects
|
|
8
|
-
* of nested layers. `State.live` consults this ref at store-construction time:
|
|
9
|
-
* if the state's member name is present, the (schema-validated) value replaces
|
|
10
|
-
* the authored seed; otherwise the authored seed wins.
|
|
11
|
-
*
|
|
12
|
-
* Empty by default — production wiring never touches it. The dev-tool inspector
|
|
13
|
-
* (`Scene.seedScene`) and proofs are the only intended writers. `globalValue`
|
|
14
|
-
* keeps a single ref instance even if the module is loaded twice (duplicated
|
|
15
|
-
* bundles, HMR), so the writer and the reader always agree.
|
|
16
|
-
*/
|
|
17
3
|
export const CurrentSeedOverrides: FiberRef.FiberRef<Readonly<Record<string, unknown>>> =
|
|
18
4
|
GlobalValue.globalValue(Symbol.for('reform/CurrentSeedOverrides'), () =>
|
|
19
5
|
FiberRef.unsafeMake<Readonly<Record<string, unknown>>>({}),
|
package/src/internal/sources.ts
CHANGED
|
@@ -1,49 +1,24 @@
|
|
|
1
1
|
import { type Context, Effect, Equal, Predicate } from 'effect'
|
|
2
2
|
import { type AnySource, type SourceName, type SourceValue } from '../state/token'
|
|
3
3
|
import { type Store } from './store'
|
|
4
|
+
import type { AnyValue } from './variance'
|
|
4
5
|
|
|
5
|
-
// The reactive plumbing shared by `Calc` and `AsyncCalc`: read a list of input
|
|
6
|
-
// `Source`s, snapshot them by name, project a comparable invalidation key, and
|
|
7
|
-
// subscribe to changes. Both derived primitives sense their inputs the same way
|
|
8
|
-
// and differ only in what they do with a change (recompute vs. re-run a query).
|
|
9
|
-
|
|
10
|
-
/** The decoded inputs handed to a calc body, keyed by each source's name. */
|
|
11
6
|
export type InputsObject<Inputs extends ReadonlyArray<AnySource>> = {
|
|
12
7
|
readonly [S in Inputs[number] as SourceName<S>]: SourceValue<S>
|
|
13
8
|
}
|
|
14
9
|
|
|
15
|
-
/**
|
|
16
|
-
* The store services a list of store-carriers requires, as a *union of distinct*
|
|
17
|
-
* stores (each carrier has its own backing tag), not a single store of a union.
|
|
18
|
-
* Structural over the `store` tag, so it serves both calc inputs (`Source`s) and
|
|
19
|
-
* state-group members (`StateClass`es) — anything carrying a `store` tag.
|
|
20
|
-
*/
|
|
21
10
|
export type StoresOf<Items extends ReadonlyArray<unknown>> = {
|
|
22
|
-
[I in keyof Items]: Items[I] extends { readonly store: Context.Tag<infer Svc,
|
|
11
|
+
[I in keyof Items]: Items[I] extends { readonly store: Context.Tag<infer Svc, AnyValue> }
|
|
12
|
+
? Svc
|
|
13
|
+
: never
|
|
23
14
|
}[number]
|
|
24
15
|
|
|
25
|
-
/** The store requirement a calc's inputs impose. */
|
|
26
16
|
export type InputStores<Inputs extends ReadonlyArray<AnySource>> = StoresOf<Inputs>
|
|
27
17
|
|
|
28
|
-
/** Project inputs into a comparable key; the calc reacts only when it moves. */
|
|
29
18
|
export type InvalidateBy<Inputs extends ReadonlyArray<AnySource>> = (
|
|
30
19
|
inputs: InputsObject<Inputs>,
|
|
31
20
|
) => ReadonlyArray<unknown>
|
|
32
21
|
|
|
33
|
-
/**
|
|
34
|
-
* The runtime key an input contributes to the inputs object. Prefer the
|
|
35
|
-
* explicit `manifest.name` — the string handed to `make` — over the structural
|
|
36
|
-
* `name`: a `Calc`/`AsyncCalc` *class* satisfies `Source` through its static
|
|
37
|
-
* `name`, and a user subclass declaration (`class Feed extends Calc.make(…)`)
|
|
38
|
-
* defines its OWN `name` from the class binding, shadowing the explicit one.
|
|
39
|
-
* That binding is what minifiers rename, so keying the snapshot by `input.name`
|
|
40
|
-
* made every subclassed calc input `undefined` in production builds (and would
|
|
41
|
-
* silently mis-key in dev whenever the binding differs from the `make` name —
|
|
42
|
-
* the type-level key, `SourceName<S>`, is always the `make` string). The
|
|
43
|
-
* manifest is inherited through the static prototype chain and never shadowed.
|
|
44
|
-
* `StateToken`s carry no manifest; their `name` is an instance field holding
|
|
45
|
-
* the explicit member key, so the fallback is always the declared string.
|
|
46
|
-
*/
|
|
47
22
|
const inputKey = (input: AnySource): string =>
|
|
48
23
|
Predicate.hasProperty(input, 'manifest') &&
|
|
49
24
|
Predicate.isRecord(input.manifest) &&
|
|
@@ -51,25 +26,15 @@ const inputKey = (input: AnySource): string =>
|
|
|
51
26
|
? input.manifest['name']
|
|
52
27
|
: input.name
|
|
53
28
|
|
|
54
|
-
/** Element-wise value equality of two invalidation keys. */
|
|
55
29
|
export const sameKey = (left: ReadonlyArray<unknown>, right: ReadonlyArray<unknown>): boolean =>
|
|
56
30
|
left.length === right.length && left.every((element, index) => Equal.equals(element, right[index]))
|
|
57
31
|
|
|
58
|
-
/** The sensing surface both `Calc.live` and `AsyncCalc.live` build on. */
|
|
59
32
|
export interface WiredSources<Inputs extends ReadonlyArray<AnySource>> {
|
|
60
|
-
/** Snapshot every input by name — the decoded inputs object a calc body sees. */
|
|
61
33
|
readonly snapshot: () => InputsObject<Inputs>
|
|
62
|
-
/** The invalidation key for an inputs object: the projection, or all input values. */
|
|
63
34
|
readonly keyOf: (inputs: InputsObject<Inputs>) => ReadonlyArray<unknown>
|
|
64
|
-
/** Subscribe to every source at once; the returned thunk unsubscribes from all. */
|
|
65
35
|
readonly subscribe: (listener: () => void) => () => void
|
|
66
36
|
}
|
|
67
37
|
|
|
68
|
-
/**
|
|
69
|
-
* Read each input's backing store and return the shared sensing surface. The
|
|
70
|
-
* caller owns lifecycle: register the `subscribe` result's unsubscribe as a
|
|
71
|
-
* scope finalizer (both live bodies run under `Layer.scoped`).
|
|
72
|
-
*/
|
|
73
38
|
export const wireSources = <Inputs extends ReadonlyArray<AnySource>>(
|
|
74
39
|
inputs: Inputs,
|
|
75
40
|
invalidateBy?: InvalidateBy<Inputs>,
|
|
@@ -84,8 +49,6 @@ export const wireSources = <Inputs extends ReadonlyArray<AnySource>>(
|
|
|
84
49
|
const snapshot = (): InputsObject<Inputs> => {
|
|
85
50
|
const args: Record<string, unknown> = {}
|
|
86
51
|
inputs.forEach((input, index) => {
|
|
87
|
-
// `noUncheckedIndexedAccess`: `sources` is built 1:1 with `inputs`, but
|
|
88
|
-
// guard the index so the read is honestly total.
|
|
89
52
|
const source = sources[index]
|
|
90
53
|
if (source !== undefined) {
|
|
91
54
|
args[inputKey(input)] = source.getSnapshot()
|
|
@@ -1,19 +1,5 @@
|
|
|
1
1
|
import { Effect } from 'effect'
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
* Module-load registry of the DI tag identifiers minted by `State.make` /
|
|
5
|
-
* `StateFamily.make`. Two distinct definitions that claim one identifier (e.g.
|
|
6
|
-
* `State.make('open')` in two different features) otherwise collapse silently —
|
|
7
|
-
* `Layer.merge` resolves identical tags to the rightmost, with no error. Here we
|
|
8
|
-
* surface the clash.
|
|
9
|
-
*
|
|
10
|
-
* Warned, not thrown — mirroring duplicate reducer/procedure names: a cross-
|
|
11
|
-
* feature clash is a footgun, but a module may legitimately be re-evaluated under
|
|
12
|
-
* the same identifier (HMR), so a hard throw would punish dev reloads. The
|
|
13
|
-
* `warned` set keeps that to one warning per identifier regardless of reloads.
|
|
14
|
-
* In-group clashes (where every member is addressable by name) are a harder error
|
|
15
|
-
* — see `StateGroup.make`, which throws.
|
|
16
|
-
*/
|
|
17
3
|
const claimed = new Set<string>()
|
|
18
4
|
const warned = new Set<string>()
|
|
19
5
|
|
|
@@ -3,8 +3,6 @@ import { makeStore } from './store'
|
|
|
3
3
|
|
|
4
4
|
const tick = () => new Promise<void>((resolve) => queueMicrotask(resolve))
|
|
5
5
|
|
|
6
|
-
// A const counter we increment in place — subscriber callbacks need somewhere to
|
|
7
|
-
// tally without a reassigned `let`.
|
|
8
6
|
const counter = () => ({ n: 0 })
|
|
9
7
|
|
|
10
8
|
test('subscribe observes changes; getSnapshot is always current', async () => {
|
|
@@ -15,7 +13,7 @@ test('subscribe observes changes; getSnapshot is always current', async () => {
|
|
|
15
13
|
})
|
|
16
14
|
|
|
17
15
|
store.set(1)
|
|
18
|
-
expect(store.getSnapshot()).toBe(1)
|
|
16
|
+
expect(store.getSnapshot()).toBe(1)
|
|
19
17
|
await tick()
|
|
20
18
|
expect(notifications.n).toBe(1)
|
|
21
19
|
})
|