@bespokeagentics/microdots-host 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/package.json +25 -0
- package/src/index.ts +144 -0
- package/src/loader.ts +119 -0
- package/src/mounting.test.ts +214 -0
- package/src/mounting.ts +90 -0
- package/src/placementChecks.test.ts +593 -0
- package/src/placementChecks.ts +428 -0
- package/src/registry.ts +63 -0
- package/src/routes.ts +156 -0
- package/src/rules.test.ts +598 -0
- package/src/rules.ts +448 -0
- package/src/slots.test.ts +46 -0
- package/src/slots.ts +58 -0
- package/src/wire.test.ts +631 -0
- package/src/wire.ts +560 -0
- package/src/wireEngine.test.ts +810 -0
- package/src/wireEngine.ts +468 -0
package/src/wire.ts
ADDED
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
import { Array, Option, Schema as S } from 'effect'
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
ManifestAttribute,
|
|
5
|
+
ManifestEvent,
|
|
6
|
+
ManifestTag,
|
|
7
|
+
} from '@bespokeagentics/microdots-element'
|
|
8
|
+
|
|
9
|
+
import { type RouteTable, makeRouteTable } from './routes.ts'
|
|
10
|
+
// Runtime import of the matcher only — `rules.ts` imports this module
|
|
11
|
+
// type-only, so the emitted module graph stays acyclic.
|
|
12
|
+
import { ruleMatches } from './rules.ts'
|
|
13
|
+
import { HostSlotManifest } from './slots.ts'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The wire RECORD — the declarative half of the broker.
|
|
17
|
+
*
|
|
18
|
+
* A wire says "when this element's event fires, write this field onto that
|
|
19
|
+
* element's attribute", which is exactly what the hand-written listener blocks
|
|
20
|
+
* in `apps/host/src/entry.ts` have been saying in prose. Phase 4 turns the
|
|
21
|
+
* prose into data this schema decodes, so the same record can drive the DOM
|
|
22
|
+
* adapter in `./wireEngine`, the Wiring screen's read mode, and any future
|
|
23
|
+
* transport — a record describes WHAT connects to WHAT, never how the message
|
|
24
|
+
* arrives. See `wiki/framework/composition/wire-execution-and-transforms.md`.
|
|
25
|
+
*
|
|
26
|
+
* Deliberately NOT here: `status` and `lastFired`. A stored status is stale the
|
|
27
|
+
* moment a placement or a manifest changes without the wire being touched;
|
|
28
|
+
* `deriveWireState` below computes it fresh from the manifests and the route
|
|
29
|
+
* table every time it is asked. Canvas coordinates are presentation, not
|
|
30
|
+
* record, and live with the Wiring dot.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** The environments a wire may run in — the fixture's vocabulary, verbatim. */
|
|
34
|
+
export const WireEnv = S.Literals(['dev', 'preview', 'prod'])
|
|
35
|
+
export type WireEnv = typeof WireEnv.Type
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* What a wire claims a value's string encoding MEANS — the same vocabulary as
|
|
39
|
+
* the manifest's `AttributeType`, because the type-match check in
|
|
40
|
+
* `deriveWireState` compares a wire's declaration against a manifest's.
|
|
41
|
+
*/
|
|
42
|
+
export const WireValueType = S.Literals([
|
|
43
|
+
'string',
|
|
44
|
+
'number',
|
|
45
|
+
'boolean',
|
|
46
|
+
'json',
|
|
47
|
+
'enum',
|
|
48
|
+
])
|
|
49
|
+
export type WireValueType = typeof WireValueType.Type
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The three transforms, each a generalisation of code that already ran by
|
|
53
|
+
* hand in `apps/host/src/entry.ts`:
|
|
54
|
+
*
|
|
55
|
+
* - `direct` — straight across (wire 5's `id` → `refresh-token`)
|
|
56
|
+
* - `lookup` — map source values to target values with a fallback
|
|
57
|
+
* (wire 4's `REGION_BY_SYMBOL` + `DEFAULT_REGION`)
|
|
58
|
+
* - `condition` — fire only when a payload field `is` a value
|
|
59
|
+
* (wire 4's `if (linkToggle.checked)`)
|
|
60
|
+
*
|
|
61
|
+
* `op` is only ever `'is'` because the fixture shows only `'is'` — the
|
|
62
|
+
* vocabulary is a record of what exists, not an invitation to invent.
|
|
63
|
+
*/
|
|
64
|
+
export const WireTransform = S.Union([
|
|
65
|
+
S.Struct({ _tag: S.tag('direct') }),
|
|
66
|
+
S.Struct({
|
|
67
|
+
_tag: S.tag('lookup'),
|
|
68
|
+
rows: S.Record(S.String, S.String),
|
|
69
|
+
fallback: S.String,
|
|
70
|
+
}),
|
|
71
|
+
S.Struct({
|
|
72
|
+
_tag: S.tag('condition'),
|
|
73
|
+
field: S.String,
|
|
74
|
+
op: S.Literal('is'),
|
|
75
|
+
value: S.String,
|
|
76
|
+
}),
|
|
77
|
+
])
|
|
78
|
+
export type WireTransform = typeof WireTransform.Type
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* One wire, field-for-field with the prototype fixture's Wire entity minus the
|
|
82
|
+
* two derived fields (`status`, `lastFired`). `from` and `to` are element TAG
|
|
83
|
+
* names — the fixture used short dot ids; real records use real tags.
|
|
84
|
+
* `plain` carries the Plain-English toggle's sentence.
|
|
85
|
+
*/
|
|
86
|
+
export const Wire = S.Struct({
|
|
87
|
+
id: S.String,
|
|
88
|
+
from: S.String,
|
|
89
|
+
event: S.String,
|
|
90
|
+
field: S.String,
|
|
91
|
+
fieldType: WireValueType,
|
|
92
|
+
to: S.String,
|
|
93
|
+
input: S.String,
|
|
94
|
+
inputType: WireValueType,
|
|
95
|
+
transform: WireTransform,
|
|
96
|
+
envs: S.Array(WireEnv),
|
|
97
|
+
plain: S.String,
|
|
98
|
+
})
|
|
99
|
+
export type Wire = typeof Wire.Type
|
|
100
|
+
|
|
101
|
+
/* ============================================================
|
|
102
|
+
HostTopology — the per-host JSON record (`host-topology.json`) that both a
|
|
103
|
+
host's entry and the Wiring service decode. The route structs are shaped
|
|
104
|
+
exactly like `./routes`' `Placement`/`RouteDefinition`, so a decoded
|
|
105
|
+
topology feeds `makeRouteTable` with no assertion in between.
|
|
106
|
+
============================================================ */
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* A placement condition: WHO sees this placement, on what DEVICE, in what
|
|
110
|
+
* LOCALE. Three independent axes; an absent axis means `'any'` — the record
|
|
111
|
+
* stores only what narrows, and the resolution's `resolveCondition` makes the
|
|
112
|
+
* default explicit when it is asked (derive-fresh, never store). The literal
|
|
113
|
+
* string `'any'` is also admitted and means the same as absence, per the
|
|
114
|
+
* fixture's vocabulary. Two placements collide when ALL three axes overlap;
|
|
115
|
+
* see `./rules.ts` for the overlap rule.
|
|
116
|
+
*/
|
|
117
|
+
export const PlacementCondition = S.Struct({
|
|
118
|
+
who: S.optionalKey(S.String),
|
|
119
|
+
device: S.optionalKey(S.String),
|
|
120
|
+
locale: S.optionalKey(S.String),
|
|
121
|
+
})
|
|
122
|
+
export type PlacementCondition = typeof PlacementCondition.Type
|
|
123
|
+
|
|
124
|
+
/** The spans a grid placement may take — 12/8/6/4/3 of twelve columns, the
|
|
125
|
+
* spec's exact set. Meaningful in `grid` slots only. */
|
|
126
|
+
export const PlacementSpan = S.Literals([12, 8, 6, 4, 3])
|
|
127
|
+
export type PlacementSpan = typeof PlacementSpan.Type
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Where one MicroDot element goes — widened in Phase 5 from `{tag, slotId}`
|
|
131
|
+
* only as far as a shipping check or the reading-B resolution needs, per the
|
|
132
|
+
* phase design's table (work item 2). Every new field is `S.optionalKey`,
|
|
133
|
+
* because BOTH existing `host-topology.json` files must decode unchanged —
|
|
134
|
+
* backward compatibility is a hard requirement, pinned by the Phase-4-shaped
|
|
135
|
+
* decode test in `./wire.test.ts`.
|
|
136
|
+
*
|
|
137
|
+
* - `id` — override provenance and screen selection. Absent on the
|
|
138
|
+
* Phase-4 records; the resolution synthesizes a stable one.
|
|
139
|
+
* - `values` — attribute values this placement supplies (check 3). Absent
|
|
140
|
+
* means `{}`.
|
|
141
|
+
* - `condition` — who/device/locale (checks 4/7, the resolution). Absent
|
|
142
|
+
* means every axis `'any'`.
|
|
143
|
+
* - `envs` — the environments the placement exists in (check 1's
|
|
144
|
+
* vocabulary, the same `WireEnv` wires use). Absent means all
|
|
145
|
+
* three.
|
|
146
|
+
* - `span` — grid columns (grid slots only).
|
|
147
|
+
* - `order` — stacking order within the slot.
|
|
148
|
+
*
|
|
149
|
+
* Deliberately NOT here, with their checks: `'@anchor'` + `selector` +
|
|
150
|
+
* `resolved` (check 5 needs a crawl) and `loading` (no check needs it and the
|
|
151
|
+
* runtime has one strategy — a stored `loading` the loader ignores would be a
|
|
152
|
+
* lie).
|
|
153
|
+
*/
|
|
154
|
+
export const TopologyPlacement = S.Struct({
|
|
155
|
+
id: S.optionalKey(S.String),
|
|
156
|
+
tag: S.String,
|
|
157
|
+
slotId: S.String,
|
|
158
|
+
values: S.optionalKey(S.Record(S.String, S.String)),
|
|
159
|
+
condition: S.optionalKey(PlacementCondition),
|
|
160
|
+
envs: S.optionalKey(S.Array(WireEnv)),
|
|
161
|
+
span: S.optionalKey(PlacementSpan),
|
|
162
|
+
order: S.optionalKey(S.Number),
|
|
163
|
+
})
|
|
164
|
+
export type TopologyPlacement = typeof TopologyPlacement.Type
|
|
165
|
+
|
|
166
|
+
export const TopologyRoute = S.Struct({
|
|
167
|
+
path: S.String,
|
|
168
|
+
label: S.String,
|
|
169
|
+
title: S.String,
|
|
170
|
+
sectionIds: S.Array(S.String),
|
|
171
|
+
mounts: S.Array(TopologyPlacement),
|
|
172
|
+
})
|
|
173
|
+
export type TopologyRoute = typeof TopologyRoute.Type
|
|
174
|
+
|
|
175
|
+
/** The fields every rule kind shares. Spread rather than nested so `kind`
|
|
176
|
+
* stays a flat discriminant, matching the fixture's records. */
|
|
177
|
+
const routeRuleFields = {
|
|
178
|
+
id: S.String,
|
|
179
|
+
label: S.String,
|
|
180
|
+
placements: S.Array(TopologyPlacement),
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* A ROUTE RULE places dots on many routes at once. Three kinds, the spec's
|
|
185
|
+
* exact vocabulary, each with its own match data:
|
|
186
|
+
*
|
|
187
|
+
* - `pattern` — a path glob (`/*`, `/blog/*`); only the `/*` suffix wildcard
|
|
188
|
+
* exists.
|
|
189
|
+
* - `group` — an explicit path list (the spec's "named collection").
|
|
190
|
+
* - `dynamic` — a `:param` template (`/docs/:slug`), matched segment-wise.
|
|
191
|
+
*
|
|
192
|
+
* The record is SCHEMA here beside the other topology records (a rule is
|
|
193
|
+
* topology data); the matcher and the resolution that consume it live in
|
|
194
|
+
* `./rules.ts` — defining the schema there instead would put `wire.ts` and
|
|
195
|
+
* `rules.ts` in a runtime import cycle, since `HostTopology` below needs this
|
|
196
|
+
* const at module evaluation.
|
|
197
|
+
*
|
|
198
|
+
* `ruleMatches` in `./rules.ts` is THE matcher (the phase's ruling 1): the
|
|
199
|
+
* runtime resolution and the Pages screen's match counts both call it, and
|
|
200
|
+
* nothing else may reimplement it.
|
|
201
|
+
*/
|
|
202
|
+
export const RouteRule = S.Union([
|
|
203
|
+
S.Struct({
|
|
204
|
+
...routeRuleFields,
|
|
205
|
+
kind: S.Literal('pattern'),
|
|
206
|
+
pattern: S.String,
|
|
207
|
+
}),
|
|
208
|
+
S.Struct({
|
|
209
|
+
...routeRuleFields,
|
|
210
|
+
kind: S.Literal('group'),
|
|
211
|
+
paths: S.Array(S.String),
|
|
212
|
+
}),
|
|
213
|
+
S.Struct({
|
|
214
|
+
...routeRuleFields,
|
|
215
|
+
kind: S.Literal('dynamic'),
|
|
216
|
+
template: S.String,
|
|
217
|
+
}),
|
|
218
|
+
])
|
|
219
|
+
export type RouteRule = typeof RouteRule.Type
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* The descriptor `makeRouteTable` derives an aggregate landing route from.
|
|
223
|
+
* Stored as a DESCRIPTOR rather than as the derived route itself: a stored
|
|
224
|
+
* copy of the union of every component route rots the day a route changes.
|
|
225
|
+
* `optionalKey` (not `optional`) so the decoded Type is exact-optional and
|
|
226
|
+
* assignable to `makeRouteTable`'s overview parameter under
|
|
227
|
+
* `exactOptionalPropertyTypes`.
|
|
228
|
+
*/
|
|
229
|
+
export const TopologyOverview = S.Struct({
|
|
230
|
+
path: S.String,
|
|
231
|
+
label: S.String,
|
|
232
|
+
title: S.String,
|
|
233
|
+
leadingSectionIds: S.optionalKey(S.Array(S.String)),
|
|
234
|
+
})
|
|
235
|
+
export type TopologyOverview = typeof TopologyOverview.Type
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Inputs the HOST itself owns — the activity log, remembered interview ids.
|
|
239
|
+
* They render distinctly on the Wiring canvas because no manifest declares
|
|
240
|
+
* them; `type` is free-form (`'append'` in the prototype) rather than a
|
|
241
|
+
* `WireValueType`, for the same reason.
|
|
242
|
+
*/
|
|
243
|
+
export const TopologyHost = S.Struct({
|
|
244
|
+
id: S.String,
|
|
245
|
+
label: S.String,
|
|
246
|
+
ownedInputs: S.Array(S.Struct({ name: S.String, type: S.String })),
|
|
247
|
+
})
|
|
248
|
+
export type TopologyHost = typeof TopologyHost.Type
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* A whole host, as data: who it is, where its elements go, what is wired to
|
|
252
|
+
* what, and which events it merely watches (log-only taps — the demo host's
|
|
253
|
+
* wire-7 events). `routes` is non-empty by schema so `topologyRouteTable`
|
|
254
|
+
* below always has a fallback without an assertion.
|
|
255
|
+
*
|
|
256
|
+
* Phase 5 adds two keys, both `optionalKey` because a Phase-4 topology file
|
|
257
|
+
* must decode unchanged:
|
|
258
|
+
*
|
|
259
|
+
* - `slotManifest` — the theme's declared slots (see `./slots.ts`). Absent
|
|
260
|
+
* means the topology predates the manifest; slot checks degrade honestly.
|
|
261
|
+
* - `rules` — route rules resolved by `./rules.ts`. Absent means no rules,
|
|
262
|
+
* and `resolvePlacements` degrades to the identity: exactly the route's own
|
|
263
|
+
* mounts, byte-identical to Phase 4 behaviour.
|
|
264
|
+
*/
|
|
265
|
+
export const HostTopology = S.Struct({
|
|
266
|
+
host: TopologyHost,
|
|
267
|
+
routes: S.NonEmptyArray(TopologyRoute),
|
|
268
|
+
overview: S.optionalKey(TopologyOverview),
|
|
269
|
+
slotManifest: S.optionalKey(HostSlotManifest),
|
|
270
|
+
rules: S.optionalKey(S.Array(RouteRule)),
|
|
271
|
+
wires: S.Array(Wire),
|
|
272
|
+
watch: S.Array(S.Struct({ event: S.String })),
|
|
273
|
+
})
|
|
274
|
+
export type HostTopology = typeof HostTopology.Type
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The ONE way a route table is built from a topology — both the demo host's
|
|
278
|
+
* entry and the Wiring dot's client-side derivation go through here, which is
|
|
279
|
+
* what keeps a wire whose two ends only share the DERIVED overview route
|
|
280
|
+
* deriving `live` identically everywhere.
|
|
281
|
+
*
|
|
282
|
+
* With an overview descriptor the fallback is derived by `makeRouteTable`;
|
|
283
|
+
* without one the first route is the fallback (`routes` is schema-guaranteed
|
|
284
|
+
* non-empty, and index 0 of a non-empty tuple type needs no `Option`).
|
|
285
|
+
*
|
|
286
|
+
* Phase 5: RULE-CARRIED placements fold into each matching route's mounts,
|
|
287
|
+
* through `ruleMatches` — THE matcher, never a reimplementation. Every
|
|
288
|
+
* table-derived join reads `route.mounts`: `unroutedTags`' startup throw,
|
|
289
|
+
* `findPlacement`, and `deriveWireState`'s mounts-both-ends check. Without
|
|
290
|
+
* the fold, a wire whose two ends ride a group rule would derive
|
|
291
|
+
* `never-fires` on the Wiring screen while the runtime mounts both ends —
|
|
292
|
+
* the screen lying about the runtime, the exact failure ruling 1 of the
|
|
293
|
+
* phase design exists to prevent. Rule placements come first (broadest
|
|
294
|
+
* layer, the resolution's own ordering); the table stays env-agnostic, so
|
|
295
|
+
* the fold is the plain union — the env- and condition-aware contest is
|
|
296
|
+
* `resolvePlacements`' job. A topology with no rules keeps the exact route
|
|
297
|
+
* objects it decoded: byte-identical to Phase 4.
|
|
298
|
+
*/
|
|
299
|
+
export const topologyRouteTable = (topology: HostTopology): RouteTable => {
|
|
300
|
+
const rules = topology.rules ?? []
|
|
301
|
+
const widen = (route: TopologyRoute): TopologyRoute =>
|
|
302
|
+
rules.length === 0
|
|
303
|
+
? route
|
|
304
|
+
: {
|
|
305
|
+
...route,
|
|
306
|
+
mounts: [
|
|
307
|
+
...rules
|
|
308
|
+
.filter(rule => ruleMatches(rule, route.path))
|
|
309
|
+
.flatMap(rule => rule.placements),
|
|
310
|
+
...route.mounts,
|
|
311
|
+
],
|
|
312
|
+
}
|
|
313
|
+
return topology.overview === undefined
|
|
314
|
+
? {
|
|
315
|
+
routes: topology.routes.map(widen),
|
|
316
|
+
fallback: widen(topology.routes[0]),
|
|
317
|
+
}
|
|
318
|
+
: makeRouteTable(topology.routes.map(widen), topology.overview)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/* ============================================================
|
|
322
|
+
Derived wire state — computed, never stored.
|
|
323
|
+
============================================================ */
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* - `live` — verified against manifest data, types reconciled, and some
|
|
327
|
+
* route mounts both ends.
|
|
328
|
+
* - `never-fires` — well-formed, but no single route mounts both ends, so the
|
|
329
|
+
* event and its target are never on screen together.
|
|
330
|
+
* - `draft` — a referenced tag/event/field/input is missing from the
|
|
331
|
+
* manifest data, the manifest for a referenced tag is absent
|
|
332
|
+
* (unverifiable ⇒ draft), or the type pairing is not
|
|
333
|
+
* reconciled by the transform.
|
|
334
|
+
*/
|
|
335
|
+
export type WireState = 'live' | 'never-fires' | 'draft'
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* The slice of an emitted JSON Schema document this module reads: the
|
|
339
|
+
* top-level object schema's property map. Everything else in the document
|
|
340
|
+
* (`dialect`, `required`, `definitions`, …) is irrelevant to "does this field
|
|
341
|
+
* exist, and what primitive does it carry".
|
|
342
|
+
*/
|
|
343
|
+
const JsonSchemaDocument = S.Struct({
|
|
344
|
+
schema: S.Struct({
|
|
345
|
+
properties: S.optionalKey(S.Record(S.String, S.Unknown)),
|
|
346
|
+
}),
|
|
347
|
+
})
|
|
348
|
+
const decodeJsonSchemaDocument = S.decodeUnknownOption(JsonSchemaDocument)
|
|
349
|
+
|
|
350
|
+
const JsonSchemaDirectType = S.Struct({ type: S.String })
|
|
351
|
+
const decodeJsonSchemaDirectType = S.decodeUnknownOption(JsonSchemaDirectType)
|
|
352
|
+
|
|
353
|
+
const JsonSchemaAnyOf = S.Struct({
|
|
354
|
+
anyOf: S.Array(
|
|
355
|
+
S.Struct({
|
|
356
|
+
type: S.optionalKey(S.String),
|
|
357
|
+
enum: S.optionalKey(S.Unknown),
|
|
358
|
+
}),
|
|
359
|
+
),
|
|
360
|
+
})
|
|
361
|
+
const decodeJsonSchemaAnyOf = S.decodeUnknownOption(JsonSchemaAnyOf)
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* The JSON Schema `type`s a property may carry, best effort. The emitter
|
|
365
|
+
* renders `S.Number` as an `anyOf` whose EXTRA branches are literal string
|
|
366
|
+
* encodings — `{type: 'string', enum: ['NaN']}`, `'Infinity'`, `'-Infinity'`.
|
|
367
|
+
* Counting those branch types would let `'string'` corroborate every numeric
|
|
368
|
+
* field, deriving a mistyped wire `live` when it can never fire (the exact
|
|
369
|
+
* "draft shown as live ⇒ silently dead broker" failure), so a branch carrying
|
|
370
|
+
* an `enum` key is an encoding artifact and its `type` does not count. An
|
|
371
|
+
* empty result means "could not tell" — the caller trusts the wire's
|
|
372
|
+
* declaration rather than failing a wire the tooling cannot see into.
|
|
373
|
+
*/
|
|
374
|
+
const jsonSchemaTypesOf = (property: unknown): ReadonlyArray<string> =>
|
|
375
|
+
Option.match(decodeJsonSchemaDirectType(property), {
|
|
376
|
+
onSome: direct => [direct.type],
|
|
377
|
+
onNone: () =>
|
|
378
|
+
Option.match(decodeJsonSchemaAnyOf(property), {
|
|
379
|
+
onSome: union =>
|
|
380
|
+
Array.getSomes(
|
|
381
|
+
union.anyOf.map(branch =>
|
|
382
|
+
branch.enum === undefined
|
|
383
|
+
? Option.fromNullishOr(branch.type)
|
|
384
|
+
: Option.none(),
|
|
385
|
+
),
|
|
386
|
+
),
|
|
387
|
+
onNone: () => [],
|
|
388
|
+
}),
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
/** Which JSON Schema `type`s corroborate each declared wire value type. */
|
|
392
|
+
const JSON_TYPES_BY_VALUE_TYPE: Record<WireValueType, ReadonlyArray<string>> = {
|
|
393
|
+
string: ['string'],
|
|
394
|
+
enum: ['string'],
|
|
395
|
+
number: ['number', 'integer'],
|
|
396
|
+
boolean: ['boolean'],
|
|
397
|
+
// `json` encodes anything, so no JSON Schema type can contradict it.
|
|
398
|
+
json: [],
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Verifies a payload field against the manifest event's `jsonSchema`.
|
|
403
|
+
*
|
|
404
|
+
* `jsonSchema` is OPTIONAL in manifests — the emitter omits it when a payload
|
|
405
|
+
* schema cannot be rendered. When it is absent (or carries no property map),
|
|
406
|
+
* there is nothing to verify against, so the wire's DECLARED `fieldType` is
|
|
407
|
+
* trusted rather than the wire being condemned to draft: an unverifiable
|
|
408
|
+
* declaration on the SOURCE side is the manifest's gap, not the wire's. A
|
|
409
|
+
* missing manifest or event, by contrast, means the referenced thing may not
|
|
410
|
+
* exist at all — that is draft, handled by the caller.
|
|
411
|
+
*/
|
|
412
|
+
const fieldChecksOut = (
|
|
413
|
+
event: ManifestEvent,
|
|
414
|
+
field: string,
|
|
415
|
+
declared: Option.Option<WireValueType>,
|
|
416
|
+
): boolean =>
|
|
417
|
+
Option.match(decodeJsonSchemaDocument(event.payload.jsonSchema), {
|
|
418
|
+
onNone: () => true,
|
|
419
|
+
onSome: document => {
|
|
420
|
+
const properties = document.schema.properties
|
|
421
|
+
if (properties === undefined) {
|
|
422
|
+
return true
|
|
423
|
+
}
|
|
424
|
+
if (!Object.hasOwn(properties, field)) {
|
|
425
|
+
return false
|
|
426
|
+
}
|
|
427
|
+
return Option.match(declared, {
|
|
428
|
+
onNone: () => true,
|
|
429
|
+
onSome: valueType => {
|
|
430
|
+
const jsonTypes = jsonSchemaTypesOf(properties[field])
|
|
431
|
+
const corroborating = JSON_TYPES_BY_VALUE_TYPE[valueType]
|
|
432
|
+
if (jsonTypes.length === 0 || corroborating.length === 0) {
|
|
433
|
+
return true
|
|
434
|
+
}
|
|
435
|
+
return Array.some(corroborating, jsonType =>
|
|
436
|
+
Array.contains(jsonTypes, jsonType),
|
|
437
|
+
)
|
|
438
|
+
},
|
|
439
|
+
})
|
|
440
|
+
},
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
const manifestFor = (
|
|
444
|
+
manifests: ReadonlyArray<ManifestTag>,
|
|
445
|
+
tag: string,
|
|
446
|
+
): Option.Option<ManifestTag> =>
|
|
447
|
+
Array.findFirst(manifests, manifest => manifest.tag === tag)
|
|
448
|
+
|
|
449
|
+
const eventFor = (
|
|
450
|
+
manifest: ManifestTag,
|
|
451
|
+
name: string,
|
|
452
|
+
): Option.Option<ManifestEvent> =>
|
|
453
|
+
Array.findFirst(manifest.events, event => event.name === name)
|
|
454
|
+
|
|
455
|
+
const attributeFor = (
|
|
456
|
+
manifest: ManifestTag,
|
|
457
|
+
name: string,
|
|
458
|
+
): Option.Option<ManifestAttribute> =>
|
|
459
|
+
Array.findFirst(manifest.attributes, attribute => attribute.name === name)
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Whether the transform reconciles the declared type pairing:
|
|
463
|
+
*
|
|
464
|
+
* - `direct` and `condition` copy the field straight across, so
|
|
465
|
+
* `fieldType === inputType`.
|
|
466
|
+
* - `lookup` re-maps the value entirely: valid into a `string` input
|
|
467
|
+
* regardless of the source type, or into an `enum` input iff every row
|
|
468
|
+
* value AND the fallback are members of the target attribute's declared
|
|
469
|
+
* values (an undeclared value set cannot be verified ⇒ not reconciled).
|
|
470
|
+
*/
|
|
471
|
+
const transformReconciles = (
|
|
472
|
+
wire: Wire,
|
|
473
|
+
target: ManifestAttribute,
|
|
474
|
+
): boolean => {
|
|
475
|
+
switch (wire.transform._tag) {
|
|
476
|
+
case 'direct':
|
|
477
|
+
case 'condition':
|
|
478
|
+
return wire.fieldType === wire.inputType
|
|
479
|
+
case 'lookup': {
|
|
480
|
+
if (wire.inputType === 'string') {
|
|
481
|
+
return true
|
|
482
|
+
}
|
|
483
|
+
if (wire.inputType !== 'enum') {
|
|
484
|
+
return false
|
|
485
|
+
}
|
|
486
|
+
const declaredValues = target.values
|
|
487
|
+
if (declaredValues === undefined) {
|
|
488
|
+
return false
|
|
489
|
+
}
|
|
490
|
+
const outputs = [
|
|
491
|
+
...Object.values(wire.transform.rows),
|
|
492
|
+
wire.transform.fallback,
|
|
493
|
+
]
|
|
494
|
+
return Array.every(outputs, value =>
|
|
495
|
+
Array.contains(declaredValues, value),
|
|
496
|
+
)
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const routeMountsBothEnds = (table: RouteTable, wire: Wire): boolean =>
|
|
502
|
+
Array.some(
|
|
503
|
+
table.routes,
|
|
504
|
+
route =>
|
|
505
|
+
Array.some(route.mounts, mount => mount.tag === wire.from) &&
|
|
506
|
+
Array.some(route.mounts, mount => mount.tag === wire.to),
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Derives a wire's state fresh from the manifests and the route table —
|
|
511
|
+
* see `WireState` for what each state means and why none of this is stored.
|
|
512
|
+
*
|
|
513
|
+
* Draft on ANY unverifiable reference, including a wire whose declared
|
|
514
|
+
* `inputType` disagrees with the target attribute's manifest type: a stale
|
|
515
|
+
* declaration is indistinguishable from a wrong one, and the whole point of
|
|
516
|
+
* draft is "this record cannot be trusted to fire correctly".
|
|
517
|
+
*/
|
|
518
|
+
export const deriveWireState = (
|
|
519
|
+
wire: Wire,
|
|
520
|
+
context: {
|
|
521
|
+
readonly manifests: ReadonlyArray<ManifestTag>
|
|
522
|
+
readonly table: RouteTable
|
|
523
|
+
},
|
|
524
|
+
): WireState => {
|
|
525
|
+
const verified = Option.match(manifestFor(context.manifests, wire.from), {
|
|
526
|
+
onNone: () => false,
|
|
527
|
+
onSome: fromManifest =>
|
|
528
|
+
Option.match(eventFor(fromManifest, wire.event), {
|
|
529
|
+
onNone: () => false,
|
|
530
|
+
onSome: event => {
|
|
531
|
+
if (!fieldChecksOut(event, wire.field, Option.some(wire.fieldType))) {
|
|
532
|
+
return false
|
|
533
|
+
}
|
|
534
|
+
// A condition's gate field is a payload reference too — existence
|
|
535
|
+
// only, because the wire declares no type for it.
|
|
536
|
+
if (
|
|
537
|
+
wire.transform._tag === 'condition' &&
|
|
538
|
+
!fieldChecksOut(event, wire.transform.field, Option.none())
|
|
539
|
+
) {
|
|
540
|
+
return false
|
|
541
|
+
}
|
|
542
|
+
return Option.match(manifestFor(context.manifests, wire.to), {
|
|
543
|
+
onNone: () => false,
|
|
544
|
+
onSome: toManifest =>
|
|
545
|
+
Option.match(attributeFor(toManifest, wire.input), {
|
|
546
|
+
onNone: () => false,
|
|
547
|
+
onSome: target =>
|
|
548
|
+
target.type === wire.inputType &&
|
|
549
|
+
transformReconciles(wire, target),
|
|
550
|
+
}),
|
|
551
|
+
})
|
|
552
|
+
},
|
|
553
|
+
}),
|
|
554
|
+
})
|
|
555
|
+
|
|
556
|
+
if (!verified) {
|
|
557
|
+
return 'draft'
|
|
558
|
+
}
|
|
559
|
+
return routeMountsBothEnds(context.table, wire) ? 'live' : 'never-fires'
|
|
560
|
+
}
|