@playfast/reform 0.0.9 → 0.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/package.json +1 -1
  2. package/src/boundary/boundary.ts +16 -7
  3. package/src/calc/asyncCalc.invalidate.test.ts +166 -0
  4. package/src/calc/asyncCalc.ts +116 -25
  5. package/src/calc/asyncData.ts +8 -4
  6. package/src/calc/calc.ts +7 -3
  7. package/src/calc/calcFamily.ts +24 -10
  8. package/src/calc/compose.ts +1 -1
  9. package/src/calc/queryState.ts +52 -0
  10. package/src/channel/channel.ts +63 -37
  11. package/src/compose/composition.ts +20 -1
  12. package/src/compose/provide.ts +5 -1
  13. package/src/compose/slot.ts +4 -2
  14. package/src/compose/structure.ts +43 -19
  15. package/src/compose/ui.ts +21 -2
  16. package/src/compose/ui.typecheck.ts +4 -4
  17. package/src/definition/definition.ts +20 -7
  18. package/src/feature/feature.test.ts +4 -4
  19. package/src/feature/feature.ts +30 -16
  20. package/src/feature/feature.typecheck.ts +2 -2
  21. package/src/index.ts +21 -0
  22. package/src/internal/capture.ts +1 -0
  23. package/src/internal/errors.ts +9 -4
  24. package/src/internal/inspect.ts +4 -4
  25. package/src/internal/queryDriver.ts +273 -86
  26. package/src/internal/queryEvents.ts +34 -0
  27. package/src/internal/queryStore.ts +36 -0
  28. package/src/internal/reuse.ts +67 -30
  29. package/src/internal/scheduler.ts +35 -23
  30. package/src/internal/sources.ts +14 -8
  31. package/src/internal/stateRegistry.ts +3 -1
  32. package/src/internal/store.ts +10 -8
  33. package/src/internal/track.ts +3 -1
  34. package/src/procedure/procedure.ts +2 -2
  35. package/src/reducer/reducer.ts +17 -9
  36. package/src/remote/remoteState.test.ts +188 -1
  37. package/src/remote/remoteState.ts +112 -51
  38. package/src/remote/remoteState.typecheck.ts +4 -1
  39. package/src/runtime/bus.ts +3 -1
  40. package/src/runtime/hardening.test.ts +1 -1
  41. package/src/runtime/loop.ts +81 -53
  42. package/src/runtime/queries.ts +55 -0
  43. package/src/scene/scene.ts +16 -8
  44. package/src/state/state.ts +11 -10
  45. package/src/state/stateFamily.ts +27 -11
  46. package/src/state/stateGroup.ts +22 -9
  47. package/src/synced/syncedStore.ts +15 -9
  48. package/src/wire/tree.ts +66 -30
  49. package/src/wire/triggers.ts +9 -8
@@ -0,0 +1,52 @@
1
+ import { Option } from 'effect'
2
+ import { type AnyAsyncData, AsyncData } from './asyncData'
3
+
4
+ // `QueryState` is the data-oriented value an `AsyncCalc` is driven by: the four
5
+ // axes of a query, stored *flat and independent* so each can move on its own.
6
+ // `data`/`error` survive a refetch (stale-while-revalidate for free); `isFetching`
7
+ // (activity) and `isStale` (freshness) are orthogonal to both — which is exactly
8
+ // what lets `invalidate` (flip `isStale`) and `refetch` (drive `isFetching`) be
9
+ // separate operations. The matchable `AsyncData` union is *derived* from this
10
+ // (`toAsyncData`), so view code and `RemoteState` keep their arm dispatch.
11
+
12
+ export interface QueryState<A, E> {
13
+ /** Last resolved value — kept across a refetch. */
14
+ readonly data: Option.Option<A>
15
+ /** Last failure — kept across a refetch. */
16
+ readonly error: Option.Option<E>
17
+ /** Activity axis: a run is in flight. */
18
+ readonly isFetching: boolean
19
+ /** Freshness axis: the value has been marked invalid (drives auto-refetch). */
20
+ readonly isStale: boolean
21
+ }
22
+
23
+ /** The empty cell. `fetching` seeds the first-kick `Loading` for an enabled query. */
24
+ export const empty = (fetching: boolean): QueryState<never, never> => ({
25
+ data: Option.none(),
26
+ error: Option.none(),
27
+ isFetching: fetching,
28
+ isStale: false,
29
+ })
30
+
31
+ /**
32
+ * Project the flat state onto the public `AsyncData` union (the view contract) —
33
+ * the single `Match`-able view of a query, so call sites keep `Match.tag`-ing the
34
+ * arms. `error` wins over `data` so a failed refetch surfaces the `Error` arm,
35
+ * matching the pre-`QueryState` driver; `isFetching` becomes the arm's
36
+ * `refetching` flag. The data-less case is `Loading` while fetching (or for a
37
+ * non-gated query, which always fetches) and `Idle` only for a switched-off
38
+ * gateable query.
39
+ */
40
+ export const toAsyncData = <A, E>(state: QueryState<A, E>, gated: boolean): AnyAsyncData<A, E> =>
41
+ Option.match(state.error, {
42
+ onSome: (error) => AsyncData.error(error, state.isFetching),
43
+ onNone: () =>
44
+ Option.match(state.data, {
45
+ onSome: (payload) => AsyncData.success(payload, state.isFetching),
46
+ onNone: () => (state.isFetching || !gated ? AsyncData.loading : AsyncData.idle),
47
+ }),
48
+ })
49
+
50
+ /** `Loading` in the React-Query sense: fetching with nothing to show yet. */
51
+ export const isLoading = <A, E>(state: QueryState<A, E>): boolean =>
52
+ state.isFetching && Option.isNone(state.data)
@@ -1,6 +1,5 @@
1
- import { Chunk, Context, type Duration, Effect, Layer, Match, Queue, Stream } from 'effect'
1
+ import { Chunk, Context, type Duration, Effect, Layer, Match, Option, Queue, Stream } from 'effect'
2
2
  import { type Manifest, definitionClass } from '../definition/definition'
3
- import { DuplicateRegistration } from '../internal/errors'
4
3
  import type { Tagged } from '../runtime/bus'
5
4
 
6
5
  /**
@@ -22,9 +21,9 @@ export type ChannelPolicy =
22
21
  readonly _tag: 'throttle'
23
22
  readonly units: number
24
23
  readonly duration: Duration.DurationInput
25
- readonly cost?: number
26
- readonly burst?: number
27
- readonly strategy?: 'enforce' | 'shape'
24
+ readonly cost: Option.Option<number>
25
+ readonly burst: Option.Option<number>
26
+ readonly strategy: Option.Option<'enforce' | 'shape'>
28
27
  }
29
28
  | { readonly _tag: 'exclusive' }
30
29
 
@@ -91,56 +90,71 @@ export class Procedures extends ProceduresBase {}
91
90
  /** Get a map entry, creating and inserting it on first access (avoids `let`). */
92
91
  const getOrCreate = <K, V>(map: Map<K, V>, key: K, make: () => V): V => {
93
92
  const existing = map.get(key)
94
- if (existing !== undefined) return existing
93
+ if (existing !== undefined) {
94
+ return existing
95
+ }
95
96
  const created = make()
96
97
  map.set(key, created)
97
98
  return created
98
99
  }
99
100
 
100
101
  /** Drop an item from a `Map<K, Array>` bucket, pruning the key when it empties. */
101
- const dropFrom = <K, T>(map: Map<K, Array<T>>, key: K, item: T): void => {
102
+ const dropFrom = <K, T>(map: Map<K, Array<T>>, key: K, target: T): void => {
102
103
  const bucket = map.get(key)
103
- if (bucket === undefined) return
104
- const next = bucket.filter((value) => value !== item)
105
- if (next.length === 0) map.delete(key)
106
- else map.set(key, next)
104
+ if (bucket === undefined) {
105
+ return
106
+ }
107
+ const next = bucket.filter((candidate) => candidate !== target)
108
+ if (next.length === 0) {
109
+ map.delete(key)
110
+ } else {
111
+ map.set(key, next)
112
+ }
107
113
  }
108
114
 
109
115
  const makeProcedureRegistry = (): ProcedureRegistry => {
110
- const entries: Array<ProcedureEntry> = []
116
+ // `entries` is reassigned immutably (new array per change) rather than mutated
117
+ // in place, so the holder keeps a stable reference the getter reads through.
118
+ const state: { entries: Array<ProcedureEntry> } = { entries: [] }
111
119
  const byTag = new Map<string, Array<ProcedureEntry>>()
112
120
  const channelsByTag = new Map<string, Array<string>>()
113
121
  const byChannelTag = new Map<string, Map<string, Array<ProcedureEntry>>>()
114
122
  return {
115
- entries,
123
+ get entries() {
124
+ return state.entries
125
+ },
116
126
  byTag,
117
127
  channelsByTag,
118
128
  byChannelTag,
119
129
  register: (entry) => {
120
- entries.push(entry)
121
- for (const tag of entry.handles) {
122
- getOrCreate(byTag, tag, () => []).push(entry)
130
+ state.entries = [...state.entries, entry]
131
+ entry.handles.forEach((tag) => {
132
+ byTag.set(tag, [...getOrCreate(byTag, tag, () => []), entry])
123
133
  const channels = getOrCreate(channelsByTag, tag, () => [])
124
- if (!channels.includes(entry.channelName)) channels.push(entry.channelName)
125
- getOrCreate(getOrCreate(byChannelTag, entry.channelName, () => new Map()), tag, () => []).push(
126
- entry,
127
- )
128
- }
134
+ if (!channels.includes(entry.channelName)) {
135
+ channelsByTag.set(tag, [...channels, entry.channelName])
136
+ }
137
+ const channelMap = getOrCreate(byChannelTag, entry.channelName, () => new Map())
138
+ channelMap.set(tag, [...getOrCreate(channelMap, tag, () => []), entry])
139
+ })
129
140
  },
130
141
  unregister: (entry) => {
131
- const index = entries.indexOf(entry)
132
- if (index >= 0) entries.splice(index, 1)
133
- for (const tag of entry.handles) {
142
+ state.entries = state.entries.filter((existing) => existing !== entry)
143
+ entry.handles.forEach((tag) => {
134
144
  dropFrom(byTag, tag, entry)
135
145
  const channelMap = byChannelTag.get(entry.channelName)
136
146
  if (channelMap !== undefined) {
137
147
  dropFrom(channelMap, tag, entry)
138
148
  // If no procedure on this channel still handles the tag, drop the
139
149
  // channel from `channelsByTag[tag]` so the loop stops offering to it.
140
- if (channelMap.get(tag) === undefined) dropFrom(channelsByTag, tag, entry.channelName)
141
- if (channelMap.size === 0) byChannelTag.delete(entry.channelName)
150
+ if (channelMap.get(tag) === undefined) {
151
+ dropFrom(channelsByTag, tag, entry.channelName)
152
+ }
153
+ if (channelMap.size === 0) {
154
+ byChannelTag.delete(entry.channelName)
155
+ }
142
156
  }
143
- }
157
+ })
144
158
  },
145
159
  }
146
160
  }
@@ -194,7 +208,11 @@ export const live = (channel: ChannelClass): Layer.Layer<never, never, Channels
194
208
  // name is a silent footgun — the second policy would be dropped — so
195
209
  // reject it loudly instead.
196
210
  if (existing.policy !== channel.policy) {
197
- throw new DuplicateRegistration({ kind: 'Channel', name })
211
+ // A different channel reusing a live name is a programmer footgun, not a
212
+ // recoverable failure — surface it as a defect to keep the layer E = never.
213
+ yield* Effect.dieMessage(
214
+ `reform: channel '${name}' is already live with a different policy`,
215
+ )
198
216
  }
199
217
  return
200
218
  }
@@ -206,11 +224,15 @@ export const live = (channel: ChannelClass): Layer.Layer<never, never, Channels
206
224
  // A procedure is expected to model expected failures as events; a leaked
207
225
  // defect (a bug in the body) is logged before being isolated, so one
208
226
  // procedure's crash never tears down the channel fiber — but is observable.
209
- const runMatching = (event: Tagged): Effect.Effect<void> =>
210
- Effect.forEach(
211
- procedures.byChannelTag.get(name)?.get(event._tag) ?? [],
212
- (e) =>
213
- e.run(event).pipe(
227
+ const runMatching = (event: Tagged): Effect.Effect<void> => {
228
+ const matching = Option.fromNullable(procedures.byChannelTag.get(name)).pipe(
229
+ Option.flatMapNullable((byTag) => byTag.get(event._tag)),
230
+ Option.getOrElse((): ReadonlyArray<ProcedureEntry> => []),
231
+ )
232
+ return Effect.forEach(
233
+ matching,
234
+ (procedure) =>
235
+ procedure.run(event).pipe(
214
236
  Effect.tapErrorCause((cause) =>
215
237
  Effect.logError(`reform: procedure on channel '${name}' failed`, cause),
216
238
  ),
@@ -218,6 +240,7 @@ export const live = (channel: ChannelClass): Layer.Layer<never, never, Channels
218
240
  ),
219
241
  { discard: true, concurrency: 'unbounded' },
220
242
  )
243
+ }
221
244
 
222
245
  // Map the policy to its scheduling stream. `Match.exhaustive` makes a new
223
246
  // policy variant a compile error, and each arm is a self-contained const.
@@ -225,7 +248,7 @@ export const live = (channel: ChannelClass): Layer.Layer<never, never, Channels
225
248
  const driven: Stream.Stream<unknown> = Match.value(channel.policy).pipe(
226
249
  Match.tag('merge', () => Stream.mapEffect(events, runMatching, { concurrency: 'unbounded' })),
227
250
  Match.tag('latest', () =>
228
- Stream.flatMap(events, (e) => Stream.fromEffect(runMatching(e)), { switch: true }),
251
+ Stream.flatMap(events, (event) => Stream.fromEffect(runMatching(event)), { switch: true }),
229
252
  ),
230
253
  Match.tag('debounce', (policy) =>
231
254
  events.pipe(
@@ -236,11 +259,14 @@ export const live = (channel: ChannelClass): Layer.Layer<never, never, Channels
236
259
  Match.tag('throttle', (policy) =>
237
260
  events.pipe(
238
261
  Stream.throttle({
239
- cost: (chunk) => Chunk.size(chunk) * (policy.cost ?? 1),
262
+ cost: (chunk) => Chunk.size(chunk) * Option.getOrElse(policy.cost, () => 1),
240
263
  units: policy.units,
241
264
  duration: policy.duration,
242
- ...(policy.burst !== undefined ? { burst: policy.burst } : {}),
243
- strategy: policy.strategy ?? 'shape',
265
+ ...Option.match(policy.burst, {
266
+ onNone: () => ({}),
267
+ onSome: (burst) => ({ burst }),
268
+ }),
269
+ strategy: Option.getOrElse(policy.strategy, () => 'shape' as const),
244
270
  }),
245
271
  Stream.mapEffect(runMatching, { concurrency: 'unbounded' }),
246
272
  ),
@@ -30,15 +30,26 @@ export interface CompositionService {
30
30
  readonly render: (env: RenderEnv) => Effect.Effect<Frame, never, never>
31
31
  }
32
32
 
33
+ // Reflectable manifest/config carrier: only `title` + `ui` are required, and the
34
+ // optional members drive `Config extends { props: … }` / `{ states: … }` inference
35
+ // (PropsOf/StatesOf) plus the ergonomic `Composition.make({ title, ui })` form, so
36
+ // they cannot become `Option<T>` (would break inference and every call site) or be
37
+ // renamed `ExternalApi` (would break external importers of `CompositionConfig`).
33
38
  export interface CompositionConfig {
34
39
  readonly title: string
40
+ // oxlint-disable-next-line reform-rules/no-optional-fields -- optional config field on a reflectable manifest carrier (see note above)
35
41
  readonly description?: string
36
42
  // `any` in the encoded/context slots is required by Schema's variance for
37
43
  // branded/Class schemas (e.g. `Todo`); only the decoded type `P` is used.
44
+ // oxlint-disable-next-line reform-rules/no-optional-fields -- drives `Config extends { props: … }` inference (PropsOf)
38
45
  readonly props?: Schema.Schema<any, any>
46
+ // oxlint-disable-next-line reform-rules/no-optional-fields -- drives `Config extends { states: … }` inference (StatesOf)
39
47
  readonly states?: ReadonlyArray<unknown>
48
+ // oxlint-disable-next-line reform-rules/no-optional-fields -- optional config field on a reflectable manifest carrier (see note above)
40
49
  readonly calcs?: ReadonlyArray<unknown>
50
+ // oxlint-disable-next-line reform-rules/no-optional-fields -- optional config field on a reflectable manifest carrier (see note above)
41
51
  readonly events?: ReadonlyArray<unknown>
52
+ // oxlint-disable-next-line reform-rules/no-optional-fields -- optional config field on a reflectable manifest carrier (see note above)
42
53
  readonly slots?: Record<string, SlotClass>
43
54
  // The UI contract this composition resolves. Typed as a manifest carrier (not
44
55
  // the full `UiClass`, whose invariant `impl` tag would force the contract type
@@ -87,12 +98,19 @@ export interface CompositionClass<
87
98
  * (`Composition.live`) is the synchronous body, provided separately and
88
99
  * checked against it.
89
100
  */
101
+ /** The contract-carrying overlay on a composition config — keeps `C` inferable at
102
+ * the `Composition.make` call site while the stored manifest sees only the erased
103
+ * `{ manifest }` carrier. */
104
+ interface UiCarrier<C extends UiContract> {
105
+ readonly ui: UiClass<C>
106
+ }
107
+
90
108
  export const make = <const Config extends CompositionConfig, C extends UiContract>(
91
109
  name: string,
92
110
  // Constrain `ui` to the full `UiClass<C>` so `C` is inferred here; the stored
93
111
  // manifest still sees only the erased `{ manifest }` carrier (CompositionConfig),
94
112
  // so the manifest type — and its variance — is unchanged.
95
- config: Config & { readonly ui: UiClass<C> },
113
+ config: Config & UiCarrier<C>,
96
114
  ): CompositionClass<PropsOf<Config>, C, StatesOf<Config>> => {
97
115
  const tag = Context.GenericTag<CompositionService, CompositionService>(
98
116
  `reform/composition/${name}`,
@@ -131,6 +149,7 @@ export const live = <
131
149
  // widens the error channel to the body's inferred `E` for a generic body, so
132
150
  // narrowing it (and the contract) back to the erased service type needs the
133
151
  // `unknown` bridge — the one cast this render boundary has always required.
152
+ // oxlint-disable-next-line reform-rules/no-type-assertion -- the one documented render-boundary erasure: a fully-provided generic body's Structure<C> narrowed back to the contract-agnostic Frame service type
134
153
  logic.pipe(
135
154
  Effect.provideService(Props, env.props),
136
155
  Effect.provideService(CurrentTracker, env.tracker),
@@ -45,16 +45,20 @@ export function provide<
45
45
  Requires
46
46
  >,
47
47
  ): Layer.Layer<SlotChild, never, EngineServices | ProvidedBy<Requires>>
48
+ // oxlint-disable-next-line reform-rules/no-multiple-primitive-params -- erased implementation signature of the 2-arg overloaded `provide`; the typed public API is the overloads above
48
49
  export function provide(target: unknown, impl: unknown): Layer.Layer<never, never, unknown> {
49
50
  // Discriminate the target by its nominal brand, not by which DI-hole property
50
51
  // happens to exist: a UI contract provides under its `impl` tag, a slot under
51
52
  // its `tag`. Anything else is a wiring mistake. The overloads above pair
52
53
  // `impl` with the chosen tag; this implementation sees both erased, so view
53
54
  // the tag loosely (the one documented cast) to provide the service under it.
55
+ // oxlint-disable-next-line reform-rules/no-type-assertion -- erased DI tag widening at the provide seam (Context.Tag is invariant); overloads pair impl with the right tag
54
56
  const tag = (isUi(target) ? target.impl : isSlot(target) ? target.tag : undefined) as
55
57
  | Context.Tag<unknown, unknown>
56
58
  | undefined
57
- if (tag === undefined) throw new InvalidProvideTarget()
59
+ if (tag === undefined) {
60
+ return Layer.die(new InvalidProvideTarget())
61
+ }
58
62
  if (isFeature(impl)) {
59
63
  const eager = impl.eagerModule
60
64
  // Lazy: bind the `FeatureBinding`; the host drives load → mount, painting the
@@ -50,8 +50,10 @@ export interface SlotClass<Comp extends AnyComposition = AnyComposition> {
50
50
  }
51
51
 
52
52
  /** Whether a value is a slot — discriminates a `provide` target by brand. */
53
- export const isSlot = (u: unknown): u is SlotClass =>
54
- (typeof u === 'function' || typeof u === 'object') && u !== null && SlotTypeId in u
53
+ export const isSlot = (candidate: unknown): candidate is SlotClass =>
54
+ (typeof candidate === 'function' || typeof candidate === 'object') &&
55
+ candidate !== null &&
56
+ SlotTypeId in candidate
55
57
 
56
58
  /** The props a slot renders with — the child composition's external props. */
57
59
  export type SlotProps<S> = S extends SlotInstance<infer Comp> ? Comp['Props'] : never
@@ -1,3 +1,4 @@
1
+ import { Struct } from 'effect'
1
2
  import type { SlotProps } from './slot'
2
3
  import type { EventsOf, UiContract } from './ui'
3
4
 
@@ -62,6 +63,7 @@ export type StructureSlots<C extends UiContract> = C extends {
62
63
  export interface Structure<C extends UiContract> {
63
64
  readonly props: C['props']
64
65
  readonly slots: StructureSlots<C>
66
+ // oxlint-disable-next-line reform-rules/no-optional-fields -- serializable frame shape; triggers ride optionally and the field is intentionally omitted from structureEquals
65
67
  readonly events?: EventsOf<C>
66
68
  }
67
69
 
@@ -80,6 +82,7 @@ export type StructureTypeId = typeof StructureTypeId
80
82
  * empty map when omitted, so a frame always carries an `events` record for hosts.
81
83
  */
82
84
  export const mount = <C extends UiContract>(structure: Structure<C>): Structure<C> =>
85
+ // oxlint-disable-next-line reform-rules/no-object-assign -- merge infers the empty `events` default as EventsOf<C>; a spread would force an `as` cast instead
83
86
  Object.assign({ [StructureTypeId]: StructureTypeId, events: {} }, structure)
84
87
 
85
88
  /**
@@ -87,12 +90,17 @@ export const mount = <C extends UiContract>(structure: Structure<C>): Structure<
87
90
  * the per-item state family — passing the same id source to both is no longer
88
91
  * possible to get wrong. `props` maps each item to the child's external props.
89
92
  */
90
- export const each = <T, P>(
91
- collection: Iterable<T>,
92
- options: { readonly key: (item: T) => string; readonly props: (item: T) => P },
93
- ): EachFill<P> => ({
93
+ export interface EachOptions<T, P> {
94
+ readonly key: (item: T) => string
95
+ readonly props: (item: T) => P
96
+ }
97
+
98
+ export const each = <T, P>(collection: Iterable<T>, options: EachOptions<T, P>): EachFill<P> => ({
94
99
  _tag: 'Each',
95
- items: Array.from(collection, (item) => ({ key: options.key(item), props: options.props(item) })),
100
+ items: Array.from(collection, (element) => ({
101
+ key: options.key(element),
102
+ props: options.props(element),
103
+ })),
96
104
  })
97
105
 
98
106
  /** A singleton slot fill — render one child with these external props. */
@@ -112,23 +120,35 @@ export const isStructure = (frame: unknown): frame is Structure<UiContract> =>
112
120
 
113
121
  /** Whether a value is a plain record (not an array) — narrows for the deep compare
114
122
  * below without a cast. */
115
- const isRecord = (u: unknown): u is Record<string, unknown> =>
116
- typeof u === 'object' && u !== null && !Array.isArray(u)
123
+ const isRecord = (candidate: unknown): candidate is Record<string, unknown> =>
124
+ typeof candidate === 'object' && candidate !== null && !Array.isArray(candidate)
125
+
126
+ interface ValuePair {
127
+ readonly left: unknown
128
+ readonly right: unknown
129
+ }
117
130
 
118
131
  /** Minimal structural deep-equality for serializable shapes (plain JSON — the
119
132
  * UI-contract guarantee). Avoids a heavyweight dep for the settle comparison. */
120
- const deepEquals = (a: unknown, b: unknown): boolean => {
121
- if (Object.is(a, b)) return true
122
- if (Array.isArray(a) && Array.isArray(b)) {
123
- return a.length === b.length && a.every((item, index) => deepEquals(item, b[index]))
133
+ const deepEquals = ({ left, right }: ValuePair): boolean => {
134
+ if (Object.is(left, right)) {
135
+ return true
136
+ }
137
+ if (Array.isArray(left) && Array.isArray(right)) {
138
+ return (
139
+ left.length === right.length &&
140
+ left.every((element, index) => deepEquals({ left: element, right: right[index] }))
141
+ )
124
142
  }
125
- if (isRecord(a) && isRecord(b)) {
126
- const aKeys = Object.keys(a)
127
- const bKeys = Object.keys(b)
143
+ if (isRecord(left) && isRecord(right)) {
144
+ const leftKeys = Struct.keys(left)
145
+ const rightKeys = Struct.keys(right)
128
146
  return (
129
- aKeys.length === bKeys.length &&
130
- aKeys.every(
131
- (key) => Object.prototype.hasOwnProperty.call(b, key) && deepEquals(a[key], b[key]),
147
+ leftKeys.length === rightKeys.length &&
148
+ leftKeys.every(
149
+ (key) =>
150
+ Object.prototype.hasOwnProperty.call(right, key) &&
151
+ deepEquals({ left: left[key], right: right[key] }),
132
152
  )
133
153
  )
134
154
  }
@@ -141,5 +161,9 @@ const deepEquals = (a: unknown, b: unknown): boolean => {
141
161
  * break the settle fixpoint. The `StructureTypeId` brand is a symbol key, so
142
162
  * `Object.keys` skips it. Used by the proof/server settle loop to detect a
143
163
  * two-frame fixpoint. */
144
- export const structureEquals = (a: Structure<UiContract>, b: Structure<UiContract>): boolean =>
145
- deepEquals(a.props, b.props) && deepEquals(a.slots, b.slots)
164
+ export const structureEquals = (
165
+ left: Structure<UiContract>,
166
+ right: Structure<UiContract>,
167
+ ): boolean =>
168
+ deepEquals({ left: left.props, right: right.props }) &&
169
+ deepEquals({ left: left.slots, right: right.slots })
package/src/compose/ui.ts CHANGED
@@ -6,7 +6,13 @@ import { type Node, type SlotClass, type SlotInstance, type SlotProps } from './
6
6
 
7
7
  export interface UiContract {
8
8
  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)
9
14
  slots?: Record<string, SlotInstance<any>>
15
+ // oxlint-disable-next-line reform-rules/no-optional-fields -- presence drives `extends {...}` contract inference (see note above)
10
16
  events?: Record<string, Trigger<any>>
11
17
  }
12
18
 
@@ -42,6 +48,7 @@ type SlotsOf<C extends UiContract> = C extends { slots: infer S }
42
48
  // (`slots.Item.props.length`, a header, layout decisions) and still render them
43
49
  // (`<slots.Item/>`); it never DERIVES multiplicity — the engine already fixed it. Read-only.
44
50
  readonly [K in keyof S]: FunctionComponent<
51
+ // oxlint-disable-next-line reform-rules/no-optional-fields -- React/JSX component-prop shape (`<slots.Row slotKey={id} />`); singleton slots omit it, framework-handled
45
52
  Partial<SlotProps<S[K]> & { readonly slotKey?: string }>
46
53
  > & { readonly props: ReadonlyArray<SlotProps<S[K]>> }
47
54
  }
@@ -67,7 +74,11 @@ export interface UiManifest extends Manifest {
67
74
  readonly kind: 'Ui'
68
75
  // `any` in the encoded/context slots is required by Schema's variance for
69
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)
70
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)
71
82
  readonly events?: Readonly<Record<string, Schema.Schema<any, any>>>
72
83
  }
73
84
 
@@ -108,10 +119,14 @@ export type WiredUi<C extends UiContract> = UiClass<C> & { readonly manifest: Wi
108
119
  * props the client reads AND that renders slot children). */
109
120
  export interface WireSchemas {
110
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)
111
125
  readonly events?: Readonly<Record<string, Schema.Schema<any, any>>>
112
126
  // Slots are declared with their slot CLASSES (`slot('X')<typeof Child>()`), the
113
127
  // same values `Composition.make({ slots })` takes — NOT `SlotInstance`s. The
114
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)
115
130
  readonly slots?: Readonly<Record<string, SlotClass>>
116
131
  }
117
132
 
@@ -140,8 +155,10 @@ export type DerivedContract<Sch extends WireSchemas> = {
140
155
  }
141
156
 
142
157
  /** Whether a value is a UI contract — discriminates a `provide` target by brand. */
143
- export const isUi = (u: unknown): u is UiClass<UiContract> =>
144
- (typeof u === 'function' || typeof u === 'object') && u !== null && UiTypeId in u
158
+ export const isUi = (candidate: unknown): candidate is UiClass<UiContract> =>
159
+ (typeof candidate === 'function' || typeof candidate === 'object') &&
160
+ candidate !== null &&
161
+ UiTypeId in candidate
145
162
 
146
163
  const buildUi = <C extends UiContract, M extends UiManifest>(
147
164
  name: string,
@@ -172,6 +189,7 @@ const buildUi = <C extends UiContract, M extends UiManifest>(
172
189
  */
173
190
  export function ui(name: string): <C extends UiContract>() => UiClass<C>
174
191
  export function ui<const Sch extends WireSchemas>(name: string, schemas: Sch): WiredUi<DerivedContract<Sch>>
192
+ // 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
175
193
  export function ui(name: string, schemas?: WireSchemas): unknown {
176
194
  if (schemas === undefined) {
177
195
  return <C extends UiContract>(): UiClass<C> => buildUi<C, UiManifest>(name, { kind: 'Ui', name })
@@ -201,4 +219,5 @@ export type MadeView<C extends UiContract> = ViewImpl<C> & { readonly [UiViewCon
201
219
  * remote client presentation. Wire the result with `provide(contract, view)`.
202
220
  */
203
221
  export const make = <C extends UiContract>(contract: UiClass<C>, view: ViewImpl<C>): MadeView<C> =>
222
+ // oxlint-disable-next-line reform-rules/no-object-assign -- `view` is a callable function; spread would drop callability, so the brand is attached in place
204
223
  Object.assign(view, { [UiViewContract]: contract })
@@ -48,9 +48,9 @@ acceptsWired(LocalUi)
48
48
  // none of it written a second time.
49
49
  export const derivedView: MadeView<Contract<typeof WiredUiClass>> = make(
50
50
  WiredUiClass,
51
- ({ n }, _slots, { bump }) => {
52
- const _n: number = n
53
- bump({ by: _n })
54
- return `${_n}`
51
+ ({ n: count }, _slots, { bump }) => {
52
+ const numeric: number = count
53
+ bump({ by: numeric })
54
+ return `${numeric}`
55
55
  },
56
56
  )
@@ -2,7 +2,8 @@ import { Effect } from 'effect'
2
2
  import { attachInspectable } from '../internal/inspect'
3
3
 
4
4
  /** A definition's `toJSON`: its reflectable manifest, else its raw statics. */
5
- const describe = (statics: object): unknown => (statics as { manifest?: unknown }).manifest ?? statics
5
+ const describe = (statics: object): unknown =>
6
+ ('manifest' in statics ? Reflect.get(statics, 'manifest') : undefined) ?? statics
6
7
 
7
8
  /**
8
9
  * The reflectable descriptor every primitive carries (principle #6). The editor
@@ -54,12 +55,13 @@ export const yieldableClass = <A, E, R, Statics extends object>(
54
55
  // class function (notably `name`, which a `Source`-shaped definition like a
55
56
  // Calc carries) and `assign` would throw on those; and `ownKeys` (unlike
56
57
  // `entries`) carries symbol-keyed statics — the `TypeId` brands.
57
- for (const key of Reflect.ownKeys(statics)) {
58
- const value = (statics as Record<PropertyKey, unknown>)[key]
59
- Object.defineProperty(Base, key, { value, writable: true, enumerable: true, configurable: true })
60
- }
58
+ Reflect.ownKeys(statics).forEach((key) => {
59
+ const staticValue = Reflect.get(statics, key)
60
+ Object.defineProperty(Base, key, { value: staticValue, writable: true, enumerable: true, configurable: true })
61
+ })
61
62
  // Print like an Effect value (the manifest), not `[object Object]`.
62
63
  attachInspectable(Base, () => describe(statics))
64
+ // oxlint-disable-next-line reform-rules/no-type-assertion -- phantom-shaped static side cannot structurally satisfy the yieldable Effect & Statics type
63
65
  return Base as never
64
66
  }
65
67
 
@@ -72,5 +74,16 @@ export const yieldableClass = <A, E, R, Statics extends object>(
72
74
  * interface. The one assertion to `Class` lives here, documented, instead of a
73
75
  * scattered `as never` / `as unknown as` at every `*.make`.
74
76
  */
75
- export const definitionClass = <Class>(statics: object): Class =>
76
- attachInspectable(Object.assign(class {}, statics), () => describe(statics)) as unknown as Class
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`.
81
+ const Base = class {}
82
+ Reflect.ownKeys(statics).forEach((key) => {
83
+ const staticValue = Reflect.get(statics, key)
84
+ Object.defineProperty(Base, key, { value: staticValue, writable: true, enumerable: true, configurable: true })
85
+ })
86
+ attachInspectable(Base, () => describe(statics))
87
+ // oxlint-disable-next-line reform-rules/no-type-assertion -- the one documented seam: phantom type-only fields mean the assembled class cannot structurally satisfy Class
88
+ return Base as unknown as Class
89
+ }
@@ -1,4 +1,4 @@
1
- import { Effect, Layer } from 'effect'
1
+ import { Effect, Layer, Option } from 'effect'
2
2
  import { expect, test } from 'vitest'
3
3
  import * as Composition from '../compose/composition'
4
4
  import { ui } from '../compose/ui'
@@ -30,14 +30,14 @@ test('a lazy feature reflects as a Feature manifest with placeholders, no chunk
30
30
  expect(LazyFeature.manifest.name).toBe('lazyFeature')
31
31
  expect(LazyFeature.manifest.strategy).toBe('lazy')
32
32
  expect(LazyFeature.manifest.composition.name).toBe('TestComp')
33
- expect(LazyFeature.manifest.placeholder?.loading.name).toBe('TestComp')
34
- expect(LazyFeature.manifest.placeholder?.failed.name).toBe('FailComp')
33
+ expect(Option.getOrThrow(LazyFeature.manifest.placeholder).loading.name).toBe('TestComp')
34
+ expect(Option.getOrThrow(LazyFeature.manifest.placeholder).failed.name).toBe('FailComp')
35
35
  expect(LazyFeature.eagerModule).toBeUndefined()
36
36
  })
37
37
 
38
38
  test('an eager feature reflects as default strategy with its module available', () => {
39
39
  expect(EagerFeature.manifest.strategy).toBe('default')
40
- expect(EagerFeature.manifest.placeholder).toBeUndefined()
40
+ expect(Option.isNone(EagerFeature.manifest.placeholder)).toBe(true)
41
41
  expect(EagerFeature.eagerModule).toBe(mod)
42
42
  })
43
43