@playfast/reform 1.0.1 → 1.2.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 +103 -56
- package/package.json +17 -17
- package/src/boundary/boundary.test.ts +63 -49
- package/src/boundary/boundary.ts +144 -113
- package/src/calc/asyncCalc.invalidate.test.ts +518 -32
- package/src/calc/asyncCalc.test.ts +207 -213
- package/src/calc/asyncCalc.ts +131 -154
- package/src/calc/asyncData.ts +0 -37
- package/src/calc/calc.test.ts +7 -33
- package/src/calc/calc.ts +44 -58
- package/src/calc/calcFamily.test.ts +80 -34
- package/src/calc/calcFamily.ts +54 -65
- package/src/calc/compose.test.ts +1 -12
- package/src/calc/compose.ts +1 -36
- package/src/calc/queryState.ts +0 -23
- package/src/channel/channel.ts +126 -111
- package/src/compose/composition.test.ts +19 -0
- package/src/compose/composition.ts +351 -127
- package/src/compose/host.ts +0 -6
- package/src/compose/props.ts +13 -12
- package/src/compose/provide.ts +189 -62
- package/src/compose/slot.ts +158 -49
- package/src/compose/structure.test.ts +6 -9
- package/src/compose/structure.ts +108 -79
- package/src/compose/ui.test.ts +19 -7
- package/src/compose/ui.ts +155 -156
- package/src/compose/ui.typecheck.ts +40 -18
- package/src/definition/definition.ts +22 -41
- package/src/event/event.fromSource.test.ts +172 -0
- package/src/event/event.test.ts +10 -3
- package/src/event/event.ts +102 -27
- package/src/event/eventGroup.ts +0 -1
- package/src/feature/feature.mount.test.ts +122 -43
- package/src/feature/feature.test.ts +46 -21
- package/src/feature/feature.ts +1347 -256
- package/src/feature/feature.typecheck.ts +273 -68
- package/src/graph/closure.ts +403 -0
- package/src/index.ts +68 -126
- package/src/internal/bucketCache.ts +39 -0
- package/src/internal/capture.ts +9 -24
- package/src/internal/env.ts +7 -0
- package/src/internal/errors.test.ts +0 -6
- package/src/internal/errors.ts +37 -60
- package/src/internal/inspect.test.ts +0 -6
- package/src/internal/inspect.ts +0 -12
- package/src/internal/queryDriver.ts +105 -201
- package/src/internal/queryDriverStore.ts +156 -0
- package/src/internal/queryDriverTypes.ts +59 -0
- package/src/internal/queryEvents.ts +0 -12
- package/src/internal/queryStore.ts +0 -14
- package/src/internal/reuse.test.ts +10 -14
- package/src/internal/reuse.ts +5 -30
- package/src/internal/scheduler.ts +4 -44
- package/src/internal/seeds.ts +0 -14
- package/src/internal/sources.ts +26 -49
- package/src/internal/stateRegistry.ts +0 -14
- package/src/internal/store.test.ts +1 -3
- package/src/internal/store.ts +0 -37
- package/src/internal/track.ts +3 -20
- package/src/internal/variance.ts +5 -0
- package/src/internal.ts +222 -0
- package/src/namespace/namespace.test.ts +46 -0
- package/src/namespace/namespace.ts +106 -0
- package/src/procedure/procedure.ts +40 -35
- package/src/reducer/reducer.ts +78 -52
- package/src/remote/remoteState.test.ts +590 -397
- package/src/remote/remoteState.ts +425 -347
- package/src/remote/remoteState.typecheck.ts +47 -56
- package/src/runtime/appRuntime.activation.test.ts +100 -0
- package/src/runtime/appRuntime.test.ts +249 -0
- package/src/runtime/appRuntime.ts +415 -97
- package/src/runtime/bus.ts +2 -15
- package/src/runtime/eventBudget.test.ts +152 -0
- package/src/runtime/eventBudget.ts +120 -0
- package/src/runtime/hardening.test.ts +73 -13
- package/src/runtime/instrumentation.test.ts +55 -52
- package/src/runtime/instrumentation.ts +6 -60
- package/src/runtime/loop.test.ts +36 -17
- package/src/runtime/loop.ts +87 -88
- package/src/runtime/queries.ts +0 -17
- package/src/scene/featureScene.test.ts +61 -0
- package/src/scene/scene.ts +247 -104
- package/src/scene/seedScene.test.ts +42 -79
- package/src/state/state.nominal.typecheck.ts +68 -0
- package/src/state/state.test.ts +17 -0
- package/src/state/state.ts +80 -46
- package/src/state/stateFamily.test.ts +16 -11
- package/src/state/stateFamily.ts +55 -65
- package/src/state/stateGroup.ts +53 -64
- package/src/state/token.ts +40 -23
- package/src/synced/syncedStore.ts +46 -58
- package/src/testkit/flight.testkit.ts +75 -0
- package/src/ui/node.ts +0 -6
- package/src/ui/trigger.ts +0 -5
- package/src/wire/tree.test.ts +8 -7
- package/src/wire/tree.ts +0 -39
- package/src/wire/triggers.test.ts +6 -6
- package/src/wire/triggers.ts +14 -32
- package/src/internal/ctx.ts +0 -16
package/src/compose/ui.ts
CHANGED
|
@@ -1,223 +1,222 @@
|
|
|
1
1
|
import type { FunctionComponent } from 'react'
|
|
2
2
|
import { Context, type Schema } from 'effect'
|
|
3
|
+
import type { AST } from 'effect/SchemaAST'
|
|
3
4
|
import { type Manifest, definitionClass } from '../definition/definition'
|
|
4
5
|
import type { Trigger } from '../ui/trigger'
|
|
5
|
-
import { type
|
|
6
|
+
import { type AnySlot, type AnySlotInstance, type Node, type SlotProps } from './slot'
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
// Field presence drives conditional slot/event inference; Option would erase it.
|
|
9
|
+
interface UiContractOptionalFields {
|
|
10
|
+
slots: Record<string, AnySlotInstance>
|
|
11
|
+
events: Record<string, Trigger<never>>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface UiContract extends Partial<UiContractOptionalFields> {
|
|
8
15
|
props: unknown
|
|
9
|
-
// `slots`/`events` are optional so a local contract literal (`ui('X')<{ props }>()`)
|
|
10
|
-
// stays terse AND so `C extends { slots: infer S }` / `{ events: infer E }`
|
|
11
|
-
// discriminates their presence (SlotsOf/EventsOf). Option<T> would break both the
|
|
12
|
-
// ergonomic authoring form and that conditional-type inference.
|
|
13
|
-
// oxlint-disable-next-line reform-rules/no-optional-fields -- presence drives `extends {...}` contract inference (see note above)
|
|
14
|
-
slots?: Record<string, SlotInstance<any>>
|
|
15
|
-
// oxlint-disable-next-line reform-rules/no-optional-fields -- presence drives `extends {...}` contract inference (see note above)
|
|
16
|
-
events?: Record<string, Trigger<any>>
|
|
17
16
|
}
|
|
18
17
|
|
|
19
18
|
type PropsOf<C extends UiContract> = C['props']
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
//
|
|
25
|
-
// drives a child supplies them (`<slots.Item id=… />` / `createElement(slots.Item, {…})`),
|
|
26
|
-
// but a placeholder render (the remote client, where the per-child props were captured
|
|
27
|
-
// server-side and the framework expands the children) omits them (`<slots.Item />`).
|
|
28
|
-
// One `Ui.make` view thus serves both renderers. The child still reads its own
|
|
29
|
-
// props/state — these are only the external props the parent feeds, advisory at the
|
|
30
|
-
// slot boundary. Typing it as `FunctionComponent` makes both JSX and `createElement` work.
|
|
31
|
-
//
|
|
32
|
-
// `slotKey` is the universal KEYED-SLOT selector (see `WireNode.key`): for a LIST slot
|
|
33
|
-
// invoked once per item (`<slots.Row slotKey={id} />`), the remote client renders only the
|
|
34
|
-
// one wire child whose React `key` matches, instead of every child of that slot at every
|
|
35
|
-
// call site (which duplicates the whole list under each call). It is framework-handled, not
|
|
36
|
-
// forwarded to the child, so it is allowed on EVERY slot and never collides with a child's
|
|
37
|
-
// own props. Omit it for singleton slots (`<slots.Create />`) — they render all children.
|
|
19
|
+
export type EventsOf<C extends UiContract> = C extends { events: infer E }
|
|
20
|
+
? E
|
|
21
|
+
: Readonly<Record<PropertyKey, never>>
|
|
22
|
+
|
|
23
|
+
// slotKey is framework-only selection; Partial also permits prop-less remote placeholders.
|
|
38
24
|
type SlotsOf<C extends UiContract> = C extends { slots: infer S }
|
|
39
25
|
? {
|
|
40
|
-
// `slotKey` goes INSIDE the `Partial<…>` (not intersected outside) so that when a slot's
|
|
41
|
-
// child props are `any` the whole thing still collapses to `any` (`any & X = any`), keeping
|
|
42
|
-
// the component assignable to a bare `(props: unknown) => Node` slot-renderer shape. An
|
|
43
|
-
// outer intersection would leave a required-shape object that `unknown` can't satisfy.
|
|
44
|
-
//
|
|
45
|
-
// A slot handle is BOTH renderable (the `FunctionComponent` — places the keyed children)
|
|
46
|
-
// AND inspectable (`.props` — the array of per-child fill props the structure carried, one
|
|
47
|
-
// entry per mounted child, typed by the child contract). A presentation can read the fills
|
|
48
|
-
// (`slots.Item.props.length`, a header, layout decisions) and still render them
|
|
49
|
-
// (`<slots.Item/>`); it never DERIVES multiplicity — the engine already fixed it. Read-only.
|
|
50
26
|
readonly [K in keyof S]: FunctionComponent<
|
|
51
|
-
|
|
52
|
-
Partial<SlotProps<S[K]> & { readonly slotKey?: string }>
|
|
27
|
+
Partial<SlotProps<S[K]> & { readonly slotKey: string }>
|
|
53
28
|
> & { readonly props: ReadonlyArray<SlotProps<S[K]>> }
|
|
54
29
|
}
|
|
55
|
-
: Record<
|
|
30
|
+
: Readonly<Record<PropertyKey, never>>
|
|
56
31
|
|
|
57
|
-
/** What `.make` authors: the pure presentation. Slots are injected by the renderer. */
|
|
58
32
|
export type ViewImpl<C extends UiContract> = (
|
|
59
33
|
props: PropsOf<C>,
|
|
60
34
|
slots: SlotsOf<C>,
|
|
61
35
|
events: EventsOf<C>,
|
|
62
36
|
) => Node
|
|
63
37
|
|
|
64
|
-
/** Nominal brand identifying a UI contract at runtime (Effect's `TypeId` idiom). */
|
|
65
38
|
export const UiTypeId: unique symbol = Symbol.for('reform/Ui')
|
|
66
39
|
export type UiTypeId = typeof UiTypeId
|
|
67
40
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
41
|
+
export const UiServiceTypeId: unique symbol = Symbol.for('reform/UiService')
|
|
42
|
+
export type UiServiceTypeId = typeof UiServiceTypeId
|
|
43
|
+
|
|
44
|
+
export interface UiService<N extends string> {
|
|
45
|
+
readonly [UiServiceTypeId]: N
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type CallableView = (...args: ReadonlyArray<never>) => unknown
|
|
49
|
+
|
|
50
|
+
export interface UiSchemaReflection {
|
|
51
|
+
readonly ast: AST
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface UiManifestOptionalFields {
|
|
55
|
+
readonly props: UiSchemaReflection
|
|
56
|
+
readonly events: Readonly<Record<string, UiSchemaReflection>>
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface UiManifest extends Manifest, Partial<UiManifestOptionalFields> {
|
|
74
60
|
readonly kind: 'Ui'
|
|
75
|
-
// `any` in the encoded/context slots is required by Schema's variance for
|
|
76
|
-
// branded/Class schemas; only the decoded type is ever read off these.
|
|
77
|
-
// Present only on the wired form, so `extends { props: … }` discriminates the two
|
|
78
|
-
// authoring forms — Option<T> would erase that wire/type-only distinction.
|
|
79
|
-
// oxlint-disable-next-line reform-rules/no-optional-fields -- wire manifest field present only on the wired form (see note above)
|
|
80
|
-
readonly props?: Schema.Schema<any, any>
|
|
81
|
-
// oxlint-disable-next-line reform-rules/no-optional-fields -- wire manifest field present only on the wired form (see note above)
|
|
82
|
-
readonly events?: Readonly<Record<string, Schema.Schema<any, any>>>
|
|
83
61
|
}
|
|
84
62
|
|
|
85
|
-
/** A `UiManifest` whose wire schema is present — the runtime brand of a remote-capable contract. */
|
|
86
63
|
export interface WiredUiManifest extends UiManifest {
|
|
87
|
-
readonly props:
|
|
64
|
+
readonly props: UiSchemaReflection
|
|
88
65
|
}
|
|
89
66
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
67
|
+
export type UiCapture<Result> = <C extends UiContract, N extends string>(
|
|
68
|
+
contract: UiClass<C, N>,
|
|
69
|
+
) => Result
|
|
70
|
+
|
|
71
|
+
export interface UiReflection<
|
|
72
|
+
out C extends UiContract = UiContract,
|
|
73
|
+
out N extends string = string,
|
|
74
|
+
> {
|
|
75
|
+
new (): {}
|
|
76
|
+
readonly [UiTypeId]: UiTypeId
|
|
77
|
+
readonly manifest: UiManifest & { readonly name: N }
|
|
78
|
+
readonly Contract: C
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface AnyUi {
|
|
93
82
|
new (): {}
|
|
94
83
|
readonly [UiTypeId]: UiTypeId
|
|
95
84
|
readonly manifest: UiManifest
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
export type Contract<U> = U extends
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
* props the client reads AND that renders slot children). */
|
|
120
|
-
export interface WireSchemas {
|
|
121
|
-
readonly props: Schema.Schema<any, any>
|
|
122
|
-
// `events`/`slots` optional so `Sch extends { events: … }` / `{ slots: … }`
|
|
123
|
-
// derives the contract's facade only when declared (DerivedEvents/DerivedSlots).
|
|
124
|
-
// oxlint-disable-next-line reform-rules/no-optional-fields -- presence drives `extends {...}` derivation (DerivedEvents)
|
|
125
|
-
readonly events?: Readonly<Record<string, Schema.Schema<any, any>>>
|
|
126
|
-
// Slots are declared with their slot CLASSES (`slot('X')<typeof Child>()`), the
|
|
127
|
-
// same values `Composition.make({ slots })` takes — NOT `SlotInstance`s. The
|
|
128
|
-
// contract's slot facade is derived from them below.
|
|
129
|
-
// oxlint-disable-next-line reform-rules/no-optional-fields -- presence drives `extends {...}` derivation (DerivedSlots)
|
|
130
|
-
readonly slots?: Readonly<Record<string, SlotClass>>
|
|
85
|
+
readonly capture: <Result>(visit: UiCapture<Result>) => Result
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface UiClass<C extends UiContract, N extends string> extends UiReflection<C, N> {
|
|
89
|
+
readonly capture: <Result>(visit: UiCapture<Result>) => Result
|
|
90
|
+
readonly impl: Context.Tag<UiService<N>, ViewImpl<C>>
|
|
91
|
+
readonly provider: Context.Tag<UiService<N>, CallableView>
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export type Contract<U> = U extends UiReflection<infer C, infer _N> ? C : never
|
|
95
|
+
export type UiName<U> = U extends UiReflection<infer _C, infer N> ? N : never
|
|
96
|
+
|
|
97
|
+
export type WiredUi<C extends UiContract, N extends string> = UiClass<C, N> & {
|
|
98
|
+
readonly manifest: WiredUiManifest
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
interface WireSchemasOptionalFields {
|
|
102
|
+
readonly events: Readonly<Record<string, Schema.Schema.AnyNoContext>>
|
|
103
|
+
readonly slots: Readonly<Record<string, AnySlot>>
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface WireSchemas extends Partial<WireSchemasOptionalFields> {
|
|
107
|
+
readonly props: Schema.Schema.AnyNoContext
|
|
131
108
|
}
|
|
132
109
|
|
|
133
110
|
type DerivedEvents<Sch extends WireSchemas> = Sch extends {
|
|
134
|
-
readonly events: infer E extends Record<string, Schema.Schema
|
|
111
|
+
readonly events: infer E extends Record<string, Schema.Schema.AnyNoContext>
|
|
135
112
|
}
|
|
136
113
|
? { readonly [K in keyof E]: Trigger<Schema.Schema.Type<E[K]>> }
|
|
137
|
-
: Record<
|
|
114
|
+
: Readonly<Record<PropertyKey, never>>
|
|
138
115
|
|
|
139
116
|
type DerivedSlots<Sch extends WireSchemas> = Sch extends {
|
|
140
|
-
readonly slots: infer S extends Record<string,
|
|
117
|
+
readonly slots: infer S extends Record<string, AnySlot>
|
|
141
118
|
}
|
|
142
119
|
? { readonly [K in keyof S]: InstanceType<S[K]> }
|
|
143
|
-
: Record<
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* The `UiContract` a set of wire schemas denotes — props and event-payload
|
|
147
|
-
* *types* derived from the schemas via `Schema.Type`, plus any declared slots. The
|
|
148
|
-
* schema is the single source of truth; the contract type is inferred, never written
|
|
149
|
-
* a second time.
|
|
150
|
-
*/
|
|
120
|
+
: Readonly<Record<PropertyKey, never>>
|
|
121
|
+
|
|
151
122
|
export type DerivedContract<Sch extends WireSchemas> = {
|
|
152
123
|
readonly props: Schema.Schema.Type<Sch['props']>
|
|
153
124
|
readonly slots: DerivedSlots<Sch>
|
|
154
125
|
readonly events: DerivedEvents<Sch>
|
|
155
126
|
}
|
|
156
127
|
|
|
157
|
-
|
|
158
|
-
export const isUi = (candidate: unknown): candidate is UiClass<UiContract> =>
|
|
128
|
+
export const isUi = (candidate: unknown): candidate is AnyUi =>
|
|
159
129
|
(typeof candidate === 'function' || typeof candidate === 'object') &&
|
|
160
130
|
candidate !== null &&
|
|
161
131
|
UiTypeId in candidate
|
|
162
132
|
|
|
163
|
-
|
|
164
|
-
|
|
133
|
+
type UiDefinitionStatics<
|
|
134
|
+
C extends UiContract,
|
|
135
|
+
N extends string,
|
|
136
|
+
M extends UiManifest & { readonly name: N },
|
|
137
|
+
> = Pick<
|
|
138
|
+
UiClass<C, N> & { readonly manifest: M },
|
|
139
|
+
typeof UiTypeId | 'manifest' | 'impl' | 'provider' | 'capture'
|
|
140
|
+
>
|
|
141
|
+
|
|
142
|
+
const buildUi = <
|
|
143
|
+
N extends string,
|
|
144
|
+
C extends UiContract,
|
|
145
|
+
M extends UiManifest & { readonly name: N },
|
|
146
|
+
>(
|
|
147
|
+
name: N,
|
|
165
148
|
manifest: M,
|
|
166
|
-
): UiClass<C> & { readonly manifest: M } => {
|
|
167
|
-
const impl = Context.GenericTag<
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
return definitionClass<UiClass<C> & { readonly manifest: M }>({
|
|
149
|
+
): UiClass<C, N> & { readonly manifest: M } => {
|
|
150
|
+
const impl = Context.GenericTag<UiService<N>, ViewImpl<C>>(`reform/ui/${name}`)
|
|
151
|
+
const provider = Context.GenericTag<UiService<N>, CallableView>(`reform/ui/${name}`)
|
|
152
|
+
const contract = definitionClass<UiClass<C, N> & { readonly manifest: M }>({
|
|
171
153
|
[UiTypeId]: UiTypeId,
|
|
172
154
|
manifest,
|
|
173
155
|
impl,
|
|
174
|
-
|
|
156
|
+
provider,
|
|
157
|
+
capture: <Result>(visit: UiCapture<Result>): Result => visit(contract),
|
|
158
|
+
} satisfies UiDefinitionStatics<C, N, M>)
|
|
159
|
+
return contract
|
|
175
160
|
}
|
|
176
161
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
* schema, no ceremony, exactly as before.
|
|
185
|
-
* - **Schema (wired):** `ui('Counter', { props: S.Struct({ count: S.Number }) })`
|
|
186
|
-
* — the schema *is* the definition; the contract type is derived via
|
|
187
|
-
* `Schema.Type` (never re-written), and the result is a `WiredUi` the remote
|
|
188
|
-
* seam accepts. Make a local contract remote by rewriting this one line.
|
|
189
|
-
*/
|
|
190
|
-
export function ui(name: string): <C extends UiContract>() => UiClass<C>
|
|
191
|
-
export function ui<const Sch extends WireSchemas>(name: string, schemas: Sch): WiredUi<DerivedContract<Sch>>
|
|
162
|
+
type UiDefinition<N extends string> = (<C extends UiContract>() => UiClass<C, N>) | AnyUi
|
|
163
|
+
|
|
164
|
+
export function ui<const N extends string>(name: N): <C extends UiContract>() => UiClass<C, N>
|
|
165
|
+
export function ui<const N extends string, const Sch extends WireSchemas>(
|
|
166
|
+
name: N,
|
|
167
|
+
schemas: Sch,
|
|
168
|
+
): WiredUi<DerivedContract<Sch>, N>
|
|
192
169
|
// oxlint-disable-next-line reform-rules/min-var-name -- `ui` is the public framework contract-authoring API; renaming would break every caller across the monorepo
|
|
193
|
-
export function ui(name:
|
|
170
|
+
export function ui<const N extends string>(name: N, schemas?: WireSchemas): UiDefinition<N> {
|
|
194
171
|
if (schemas === undefined) {
|
|
195
|
-
return <C extends UiContract>(): UiClass<C
|
|
172
|
+
return <C extends UiContract>(): UiClass<C, N> =>
|
|
173
|
+
buildUi<N, C, UiManifest & { readonly name: N }>(name, {
|
|
174
|
+
kind: 'Ui',
|
|
175
|
+
name,
|
|
176
|
+
})
|
|
196
177
|
}
|
|
197
178
|
const eventFields = schemas.events === undefined ? {} : { events: schemas.events }
|
|
198
|
-
const manifest: WiredUiManifest
|
|
199
|
-
|
|
179
|
+
const manifest: WiredUiManifest & { readonly name: N } = {
|
|
180
|
+
kind: 'Ui',
|
|
181
|
+
name,
|
|
182
|
+
props: schemas.props,
|
|
183
|
+
...eventFields,
|
|
184
|
+
}
|
|
185
|
+
return buildUi<N, DerivedContract<WireSchemas>, WiredUiManifest & { readonly name: N }>(
|
|
186
|
+
name,
|
|
187
|
+
manifest,
|
|
188
|
+
)
|
|
200
189
|
}
|
|
201
190
|
|
|
202
|
-
/** Brand under which a `Ui.make` view carries its own contract. */
|
|
203
191
|
export const UiViewContract: unique symbol = Symbol.for('reform/ui/view-contract')
|
|
204
192
|
export type UiViewContract = typeof UiViewContract
|
|
205
193
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
194
|
+
export type MadeViewCapture<Result> = <C extends UiContract, N extends string, U extends AnyUi>(
|
|
195
|
+
view: MadeView<C, N, U>,
|
|
196
|
+
) => Result
|
|
197
|
+
|
|
198
|
+
export type MadeView<
|
|
199
|
+
C extends UiContract,
|
|
200
|
+
N extends string,
|
|
201
|
+
U extends AnyUi = UiClass<C, N>,
|
|
202
|
+
> = ViewImpl<C> & {
|
|
203
|
+
readonly [UiViewContract]: U
|
|
204
|
+
readonly capture: <Result>(visit: MadeViewCapture<Result>) => Result
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export type AnyMadeView = CallableView & {
|
|
208
|
+
readonly [UiViewContract]: AnyUi
|
|
209
|
+
readonly capture: <Result>(visit: MadeViewCapture<Result>) => Result
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export const make = <U extends AnyUi>(
|
|
213
|
+
contract: U,
|
|
214
|
+
view: ViewImpl<Contract<U>>,
|
|
215
|
+
): MadeView<Contract<U>, UiName<U>, U> => {
|
|
216
|
+
// oxlint-disable-next-line reform-rules/no-object-assign -- `view` is callable; spread would drop callability, so its reflection statics are attached in place
|
|
217
|
+
const made: MadeView<Contract<U>, UiName<U>, U> = Object.assign(view, {
|
|
218
|
+
[UiViewContract]: contract,
|
|
219
|
+
capture: <Result>(visit: MadeViewCapture<Result>): Result => visit(made),
|
|
220
|
+
})
|
|
221
|
+
return made
|
|
222
|
+
}
|
|
@@ -1,12 +1,5 @@
|
|
|
1
|
-
// Type-level proofs enforced by `tsc --noEmit` over `src` (not a vitest file).
|
|
2
|
-
// These guard the schema-first seam: the wired form is the single source of
|
|
3
|
-
// truth (props/event types derived, never re-written), a wired contract is a
|
|
4
|
-
// plain `UiClass` (so `provide` / `scene` / `Composition.make` accept it), and
|
|
5
|
-
// the remote seam (`WiredUi`) rejects a type-only contract. If a guarantee
|
|
6
|
-
// regresses, a `@ts-expect-error` goes unused or an assignment fails, and `tsc`
|
|
7
|
-
// breaks.
|
|
8
|
-
|
|
9
1
|
import { Schema as S } from 'effect'
|
|
2
|
+
import { slot, type SlotService } from './slot'
|
|
10
3
|
import {
|
|
11
4
|
type Contract,
|
|
12
5
|
type DerivedContract,
|
|
@@ -15,13 +8,32 @@ import {
|
|
|
15
8
|
ui,
|
|
16
9
|
type UiClass,
|
|
17
10
|
type UiContract,
|
|
11
|
+
type UiService,
|
|
18
12
|
type WiredUi,
|
|
19
13
|
} from './ui'
|
|
20
14
|
|
|
21
|
-
|
|
22
|
-
|
|
15
|
+
type ExpectFalse<Value extends false> = Value
|
|
16
|
+
|
|
17
|
+
declare function acceptsAny<C extends UiContract, N extends string>(contract: UiClass<C, N>): void
|
|
18
|
+
declare function acceptsWired<C extends UiContract, N extends string>(contract: WiredUi<C, N>): void
|
|
23
19
|
|
|
24
20
|
class LocalUi extends ui('Local')<{ props: { n: number } }>() {}
|
|
21
|
+
class SameShapeUi extends ui('SameShape')<{ props: { n: number } }>() {}
|
|
22
|
+
class FirstSlot extends slot('FirstSlot')<FirstSlot>() {}
|
|
23
|
+
class SecondSlot extends slot('SecondSlot')<SecondSlot>() {}
|
|
24
|
+
|
|
25
|
+
type SameShapedUiDefinitionsRemainDistinct = ExpectFalse<
|
|
26
|
+
typeof LocalUi extends typeof SameShapeUi ? true : false
|
|
27
|
+
>
|
|
28
|
+
type SameShapedSlotDefinitionsRemainDistinct = ExpectFalse<
|
|
29
|
+
typeof FirstSlot extends typeof SecondSlot ? true : false
|
|
30
|
+
>
|
|
31
|
+
type WrongUiDoesNotCloseRequirement = ExpectFalse<
|
|
32
|
+
UiService<'SameShape'> extends UiService<'Local'> ? true : false
|
|
33
|
+
>
|
|
34
|
+
type WrongSlotDoesNotCloseRequirement = ExpectFalse<
|
|
35
|
+
SlotService<'SecondSlot', SecondSlot> extends SlotService<'FirstSlot', FirstSlot> ? true : false
|
|
36
|
+
>
|
|
25
37
|
const wiredSchemas: {
|
|
26
38
|
readonly props: S.Struct<{ n: typeof S.Number }>
|
|
27
39
|
readonly events: { readonly bump: S.Struct<{ by: typeof S.Number }> }
|
|
@@ -29,24 +41,21 @@ const wiredSchemas: {
|
|
|
29
41
|
props: S.Struct({ n: S.Number }),
|
|
30
42
|
events: { bump: S.Struct({ by: S.Number }) },
|
|
31
43
|
}
|
|
32
|
-
const WiredUiClassBase: WiredUi<DerivedContract<typeof wiredSchemas
|
|
44
|
+
const WiredUiClassBase: WiredUi<DerivedContract<typeof wiredSchemas>, 'Wired'> = ui(
|
|
45
|
+
'Wired',
|
|
46
|
+
wiredSchemas,
|
|
47
|
+
)
|
|
33
48
|
class WiredUiClass extends WiredUiClassBase {}
|
|
34
49
|
|
|
35
|
-
// POSITIVE: both forms are a `UiClass` — plain wiring (provide/scene/Composition) accepts either.
|
|
36
50
|
acceptsAny(LocalUi)
|
|
37
51
|
acceptsAny(WiredUiClass)
|
|
38
52
|
|
|
39
|
-
// POSITIVE: the schema form is a `WiredUi` — the remote seam accepts it.
|
|
40
53
|
acceptsWired(WiredUiClass)
|
|
41
54
|
|
|
42
|
-
// NEGATIVE: a type-only contract has no wire schema, so the remote seam rejects it.
|
|
43
55
|
// @ts-expect-error -- LocalUi is `UiClass`, not `WiredUi`
|
|
44
56
|
acceptsWired(LocalUi)
|
|
45
57
|
|
|
46
|
-
|
|
47
|
-
// against the wired contract sees `n: number` and `bump: Trigger<{ by: number }>`,
|
|
48
|
-
// none of it written a second time.
|
|
49
|
-
export const derivedView: MadeView<Contract<typeof WiredUiClass>> = make(
|
|
58
|
+
const derivedView: MadeView<Contract<typeof WiredUiClass>, 'Wired'> = make(
|
|
50
59
|
WiredUiClass,
|
|
51
60
|
({ n: count }, _slots, { bump }) => {
|
|
52
61
|
const numeric: number = count
|
|
@@ -54,3 +63,16 @@ export const derivedView: MadeView<Contract<typeof WiredUiClass>> = make(
|
|
|
54
63
|
return `${numeric}`
|
|
55
64
|
},
|
|
56
65
|
)
|
|
66
|
+
|
|
67
|
+
type UiTypeProofs = [
|
|
68
|
+
SameShapedUiDefinitionsRemainDistinct,
|
|
69
|
+
SameShapedSlotDefinitionsRemainDistinct,
|
|
70
|
+
WrongUiDoesNotCloseRequirement,
|
|
71
|
+
WrongSlotDoesNotCloseRequirement,
|
|
72
|
+
]
|
|
73
|
+
declare const uiTypeProofs: UiTypeProofs
|
|
74
|
+
|
|
75
|
+
void uiTypeProofs
|
|
76
|
+
void derivedView
|
|
77
|
+
|
|
78
|
+
export {}
|
|
@@ -1,14 +1,9 @@
|
|
|
1
1
|
import { Effect } from 'effect'
|
|
2
2
|
import { attachInspectable } from '../internal/inspect'
|
|
3
3
|
|
|
4
|
-
/** A definition's `toJSON`: its reflectable manifest, else its raw statics. */
|
|
5
4
|
const describe = (statics: object): unknown =>
|
|
6
5
|
('manifest' in statics ? Reflect.get(statics, 'manifest') : undefined) ?? statics
|
|
7
6
|
|
|
8
|
-
/**
|
|
9
|
-
* The reflectable descriptor every primitive carries (principle #6). The editor
|
|
10
|
-
* and proof packages read these without running any logic.
|
|
11
|
-
*/
|
|
12
7
|
export type Kind =
|
|
13
8
|
| 'State'
|
|
14
9
|
| 'StateGroup'
|
|
@@ -35,55 +30,41 @@ export interface Manifest {
|
|
|
35
30
|
readonly name: string
|
|
36
31
|
}
|
|
37
32
|
|
|
38
|
-
|
|
39
|
-
* Build a class whose *static* side is itself an Effect, so `yield* TheClass`
|
|
40
|
-
* runs `read` and returns its value (the same mechanism `Context.Tag` uses).
|
|
41
|
-
* Subclasses inherit the protocol through the prototype chain, so the user's
|
|
42
|
-
* `class FeedState extends State.make(...) {}` is yieldable too.
|
|
43
|
-
*
|
|
44
|
-
* `statics` (manifest, `.live`, tag, …) are attached as own properties
|
|
45
|
-
* and likewise inherited by the subclass.
|
|
46
|
-
*/
|
|
47
|
-
export const yieldableClass = <A, E, R, Statics extends object>(
|
|
33
|
+
export function yieldableClass<A, E, R, Statics extends object>(
|
|
48
34
|
read: Effect.Effect<A, E, R>,
|
|
49
35
|
statics: Statics,
|
|
50
|
-
): { new (): {} } & Effect.Effect<A, E, R> & Statics
|
|
36
|
+
): { new (): {} } & Effect.Effect<A, E, R> & Statics
|
|
37
|
+
export function yieldableClass(
|
|
38
|
+
read: Effect.Effect<unknown, unknown, unknown>,
|
|
39
|
+
statics: object,
|
|
40
|
+
): unknown {
|
|
51
41
|
class Base {}
|
|
52
42
|
Object.setPrototypeOf(Base, read)
|
|
53
|
-
// `defineProperty` over `Reflect.ownKeys`, not `Object.assign` over
|
|
54
|
-
// `Object.entries`: a static can shadow a non-writable own property of the
|
|
55
|
-
// class function (notably `name`, which a `Source`-shaped definition like a
|
|
56
|
-
// Calc carries) and `assign` would throw on those; and `ownKeys` (unlike
|
|
57
|
-
// `entries`) carries symbol-keyed statics — the `TypeId` brands.
|
|
58
43
|
Reflect.ownKeys(statics).forEach((key) => {
|
|
59
44
|
const staticValue = Reflect.get(statics, key)
|
|
60
|
-
Object.defineProperty(Base, key, {
|
|
45
|
+
Object.defineProperty(Base, key, {
|
|
46
|
+
value: staticValue,
|
|
47
|
+
writable: true,
|
|
48
|
+
enumerable: true,
|
|
49
|
+
configurable: true,
|
|
50
|
+
})
|
|
61
51
|
})
|
|
62
|
-
// Print like an Effect value (the manifest), not `[object Object]`.
|
|
63
52
|
attachInspectable(Base, () => describe(statics))
|
|
64
|
-
|
|
65
|
-
return Base as never
|
|
53
|
+
return Base
|
|
66
54
|
}
|
|
67
55
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
* (manifest, tag, policy, members, …) that the editor and proof packages reflect
|
|
71
|
-
* on. Definition interfaces declare phantom, type-only fields — a composition's
|
|
72
|
-
* `Props`, a family instance's `Key` — that exist purely for inference and have
|
|
73
|
-
* no runtime value, so the assembled object cannot *structurally* satisfy the
|
|
74
|
-
* interface. The one assertion to `Class` lives here, documented, instead of a
|
|
75
|
-
* scattered `as never` / `as unknown as` at every `*.make`.
|
|
76
|
-
*/
|
|
77
|
-
export const definitionClass = <Class>(statics: object): Class => {
|
|
78
|
-
// `defineProperty` over `Reflect.ownKeys`, not `Object.assign`: a static can
|
|
79
|
-
// shadow a non-writable own property of the class function (notably `name`)
|
|
80
|
-
// and `assign` would throw, and `ownKeys` carries the symbol-keyed `TypeId`.
|
|
56
|
+
export function definitionClass<Class>(statics: object): Class
|
|
57
|
+
export function definitionClass(statics: object): unknown {
|
|
81
58
|
const Base = class {}
|
|
82
59
|
Reflect.ownKeys(statics).forEach((key) => {
|
|
83
60
|
const staticValue = Reflect.get(statics, key)
|
|
84
|
-
Object.defineProperty(Base, key, {
|
|
61
|
+
Object.defineProperty(Base, key, {
|
|
62
|
+
value: staticValue,
|
|
63
|
+
writable: true,
|
|
64
|
+
enumerable: true,
|
|
65
|
+
configurable: true,
|
|
66
|
+
})
|
|
85
67
|
})
|
|
86
68
|
attachInspectable(Base, () => describe(statics))
|
|
87
|
-
|
|
88
|
-
return Base as unknown as Class
|
|
69
|
+
return Base
|
|
89
70
|
}
|