@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
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { mountOverridesFor, pendingMounts } from './mounting.ts'
|
|
4
|
+
import { resolvePlacements } from './rules.ts'
|
|
5
|
+
import type {
|
|
6
|
+
HostTopology,
|
|
7
|
+
RouteRule,
|
|
8
|
+
TopologyPlacement,
|
|
9
|
+
TopologyRoute,
|
|
10
|
+
} from './wire.ts'
|
|
11
|
+
|
|
12
|
+
/* ============================================================
|
|
13
|
+
Fixtures. `pendingMounts` is fed from `resolvePlacements` in both shells,
|
|
14
|
+
so it is fed from `resolvePlacements` here too — a hand-built
|
|
15
|
+
`ResolvedPlacement[]` would let the two drift and would not reproduce the
|
|
16
|
+
shapes that actually collide.
|
|
17
|
+
============================================================ */
|
|
18
|
+
|
|
19
|
+
const route = (
|
|
20
|
+
path: string,
|
|
21
|
+
mounts: ReadonlyArray<TopologyPlacement>,
|
|
22
|
+
): TopologyRoute => ({
|
|
23
|
+
path,
|
|
24
|
+
label: path,
|
|
25
|
+
title: path,
|
|
26
|
+
sectionIds: [],
|
|
27
|
+
mounts,
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
const topologyOf = (
|
|
31
|
+
routes: readonly [TopologyRoute, ...Array<TopologyRoute>],
|
|
32
|
+
rules?: ReadonlyArray<RouteRule>,
|
|
33
|
+
): HostTopology => ({
|
|
34
|
+
host: { id: 'test-host', label: 'Test host', ownedInputs: [] },
|
|
35
|
+
routes,
|
|
36
|
+
...(rules === undefined ? {} : { rules }),
|
|
37
|
+
wires: [],
|
|
38
|
+
watch: [],
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const patternRule = (
|
|
42
|
+
id: string,
|
|
43
|
+
pattern: string,
|
|
44
|
+
placements: ReadonlyArray<TopologyPlacement>,
|
|
45
|
+
): RouteRule => ({
|
|
46
|
+
id,
|
|
47
|
+
kind: 'pattern',
|
|
48
|
+
label: `Rule ${id}`,
|
|
49
|
+
pattern,
|
|
50
|
+
placements,
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
/** What the demo host's `resolvedMountsFor` does for its DERIVED overview
|
|
54
|
+
* route: the route has no topology entry, so its resolution is the union of
|
|
55
|
+
* every component route's resolution. */
|
|
56
|
+
const overviewUnion = (topology: HostTopology) =>
|
|
57
|
+
topology.routes.flatMap(candidate =>
|
|
58
|
+
resolvePlacements(topology, candidate.path, 'dev'),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
// The failure mode: both shells used to write
|
|
62
|
+
// `Array.filter(resolved, p => !mountedTags.has(p.tag))` and latch the tags in
|
|
63
|
+
// a SEPARATE `forEach` afterwards. `filter` finishes its whole pass before
|
|
64
|
+
// `forEach` runs, so nothing a filtered entry latches can be seen by its own
|
|
65
|
+
// twin — one tag resolved twice survives twice and is appended to its slot
|
|
66
|
+
// twice. Two live elements on one page, both polling, both answering brokered
|
|
67
|
+
// writes, and a console with nothing in it.
|
|
68
|
+
describe('pendingMounts', () => {
|
|
69
|
+
test('a tag resolved twice in one list is pending ONCE', () => {
|
|
70
|
+
const topology = topologyOf([
|
|
71
|
+
route('/price', [
|
|
72
|
+
{ tag: 'price-ticker', slotId: 'price-slot' },
|
|
73
|
+
{ tag: 'price-ticker', slotId: 'compare-slot' },
|
|
74
|
+
]),
|
|
75
|
+
])
|
|
76
|
+
const resolved = resolvePlacements(topology, '/price', 'dev')
|
|
77
|
+
|
|
78
|
+
expect(resolved.length).toBe(2)
|
|
79
|
+
expect(pendingMounts(resolved, new Set()).map(item => item.slotId)).toEqual(
|
|
80
|
+
['price-slot'],
|
|
81
|
+
)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('the demo-host overview union mounts a rule-carried tag once, not once per route', () => {
|
|
85
|
+
// The reproduction named in the review: add a `/*` rule to a six-route
|
|
86
|
+
// host and the derived overview resolves that placement six times.
|
|
87
|
+
const topology = topologyOf(
|
|
88
|
+
[
|
|
89
|
+
route('/price', []),
|
|
90
|
+
route('/fleet', []),
|
|
91
|
+
route('/contact', []),
|
|
92
|
+
route('/chat', []),
|
|
93
|
+
route('/functional-spec', []),
|
|
94
|
+
route('/pages', []),
|
|
95
|
+
],
|
|
96
|
+
[
|
|
97
|
+
patternRule('r1', '/*', [
|
|
98
|
+
{ tag: 'price-ticker', slotId: 'price-slot' },
|
|
99
|
+
]),
|
|
100
|
+
],
|
|
101
|
+
)
|
|
102
|
+
const resolved = overviewUnion(topology)
|
|
103
|
+
|
|
104
|
+
expect(resolved.length).toBe(6)
|
|
105
|
+
expect(pendingMounts(resolved, new Set()).map(item => item.tag)).toEqual([
|
|
106
|
+
'price-ticker',
|
|
107
|
+
])
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('a tag placed on two component routes mounts once on the overview — no rules involved', () => {
|
|
111
|
+
// Live today without any rules: the union is over routes, so one tag on
|
|
112
|
+
// two of them is in it twice.
|
|
113
|
+
const topology = topologyOf([
|
|
114
|
+
route('/price', [{ tag: 'price-ticker', slotId: 'price-slot' }]),
|
|
115
|
+
route('/all', [{ tag: 'price-ticker', slotId: 'price-slot' }]),
|
|
116
|
+
])
|
|
117
|
+
const resolved = overviewUnion(topology)
|
|
118
|
+
|
|
119
|
+
expect(resolved.length).toBe(2)
|
|
120
|
+
expect(pendingMounts(resolved, new Set()).length).toBe(1)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test('keeps the FIRST resolved placement for a tag — the order the shells append in', () => {
|
|
124
|
+
const topology = topologyOf(
|
|
125
|
+
[route('/apps', [{ tag: 'workbench-brief', slotId: 'route-slot' }])],
|
|
126
|
+
[
|
|
127
|
+
patternRule('r1', '/*', [
|
|
128
|
+
{ tag: 'workbench-brief', slotId: 'rule-slot' },
|
|
129
|
+
]),
|
|
130
|
+
],
|
|
131
|
+
)
|
|
132
|
+
// Layers are broadest-first, so the rule's placement is first — and it is
|
|
133
|
+
// the one whose `values` and slot the mount uses.
|
|
134
|
+
expect(
|
|
135
|
+
pendingMounts(resolvePlacements(topology, '/apps', 'dev'), new Set()).map(
|
|
136
|
+
item => item.slotId,
|
|
137
|
+
),
|
|
138
|
+
).toEqual(['rule-slot'])
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
test('skips a tag already latched by an earlier activation', () => {
|
|
142
|
+
const topology = topologyOf([
|
|
143
|
+
route('/price', [
|
|
144
|
+
{ tag: 'price-ticker', slotId: 'price-slot' },
|
|
145
|
+
{ tag: 'fleet-health', slotId: 'fleet-slot' },
|
|
146
|
+
]),
|
|
147
|
+
])
|
|
148
|
+
const resolved = resolvePlacements(topology, '/price', 'dev')
|
|
149
|
+
|
|
150
|
+
expect(
|
|
151
|
+
pendingMounts(resolved, new Set(['price-ticker'])).map(item => item.tag),
|
|
152
|
+
).toEqual(['fleet-health'])
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
test('drops a fully overridden placement but keeps a partially overridden one', () => {
|
|
156
|
+
const topology = topologyOf(
|
|
157
|
+
[
|
|
158
|
+
route('/home', [
|
|
159
|
+
{ id: 'winner', tag: 'promo-banner', slotId: 'hero-slot' },
|
|
160
|
+
{
|
|
161
|
+
id: 'partial-winner',
|
|
162
|
+
tag: 'nav-crumbs',
|
|
163
|
+
slotId: 'top-bar',
|
|
164
|
+
condition: { locale: 'fr' },
|
|
165
|
+
},
|
|
166
|
+
]),
|
|
167
|
+
],
|
|
168
|
+
[
|
|
169
|
+
patternRule('r1', '/*', [
|
|
170
|
+
{ id: 'loser', tag: 'promo-banner', slotId: 'hero-slot' },
|
|
171
|
+
{ id: 'partial-loser', tag: 'nav-crumbs', slotId: 'top-bar' },
|
|
172
|
+
]),
|
|
173
|
+
],
|
|
174
|
+
)
|
|
175
|
+
const resolved = resolvePlacements(topology, '/home', 'dev')
|
|
176
|
+
|
|
177
|
+
// `loser` renders for nobody, so it is not pending. `partial-loser` still
|
|
178
|
+
// renders for everyone outside `fr`, so it is — and it is the FIRST
|
|
179
|
+
// nav-crumbs entry, so it is the one that mounts.
|
|
180
|
+
expect(pendingMounts(resolved, new Set()).map(item => item.id)).toEqual([
|
|
181
|
+
'partial-loser',
|
|
182
|
+
'winner',
|
|
183
|
+
])
|
|
184
|
+
})
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
// The failure mode this function exists for, verbatim from the D1 extraction:
|
|
188
|
+
// an empty override merged FIRST and filtered later deletes the placement's
|
|
189
|
+
// value instead of leaving it standing. `createMicroDotFromEntry`'s own
|
|
190
|
+
// drop-empty filter runs over the already-merged record and cannot tell an
|
|
191
|
+
// intentional `''` from "I looked and found nothing".
|
|
192
|
+
describe('mountOverridesFor', () => {
|
|
193
|
+
test('a resume override wins over the placement value', () => {
|
|
194
|
+
expect(
|
|
195
|
+
mountOverridesFor({ symbol: 'FOLD' }, { symbol: 'RESUMED' }),
|
|
196
|
+
).toEqual({ symbol: 'RESUMED' })
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
test('an EMPTY resume override is dropped, leaving the placement value standing', () => {
|
|
200
|
+
expect(mountOverridesFor({ symbol: 'FOLD' }, { symbol: '' })).toEqual({
|
|
201
|
+
symbol: 'FOLD',
|
|
202
|
+
})
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
test('an empty placement value is kept — the drop-empty rule is about OVERRIDES only', () => {
|
|
206
|
+
expect(mountOverridesFor({ symbol: '' }, {})).toEqual({ symbol: '' })
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
test('a resume key the placement does not have is added', () => {
|
|
210
|
+
expect(
|
|
211
|
+
mountOverridesFor({ 'component-id': 'demo' }, { 'interview-id': 'iv-1' }),
|
|
212
|
+
).toEqual({ 'component-id': 'demo', 'interview-id': 'iv-1' })
|
|
213
|
+
})
|
|
214
|
+
})
|
package/src/mounting.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { Array } from 'effect'
|
|
2
|
+
|
|
3
|
+
import type { ResolvedPlacement } from './rules.ts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What a shell does with a resolution's output on the way to the DOM — the
|
|
7
|
+
* two steps between `resolvePlacements` and `createMicroDotFromEntry`.
|
|
8
|
+
*
|
|
9
|
+
* Both live here rather than in either shell because both shells need both and
|
|
10
|
+
* **apps must not import each other**; `apps/host/src/mounting.ts` held
|
|
11
|
+
* `mountOverridesFor` alone until the 2026-08-18 review found the platform
|
|
12
|
+
* re-implementing its merge inline, incorrectly (finding 5). Neither function
|
|
13
|
+
* touches the DOM, so both are testable — which is the whole point: an
|
|
14
|
+
* `entry.ts` runs `start()` at module scope and nothing in it can be tested.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The placements this shell must still mount, given the tags it already has.
|
|
19
|
+
*
|
|
20
|
+
* **Deduped by tag as the list is built, not after** — that ordering is the
|
|
21
|
+
* defect this function exists to make impossible (2026-08-18 review, finding
|
|
22
|
+
* 1). Both shells wrote
|
|
23
|
+
*
|
|
24
|
+
* ```ts
|
|
25
|
+
* const pending = Array.filter(resolved, p => !mountedTags.has(p.tag))
|
|
26
|
+
* pending.forEach(p => mountedTags.add(p.tag))
|
|
27
|
+
* ```
|
|
28
|
+
*
|
|
29
|
+
* and `filter` runs the WHOLE predicate pass before `forEach` latches
|
|
30
|
+
* anything, so one tag resolved twice survived twice and was appended to its
|
|
31
|
+
* slot twice. It resolves twice for two live reasons: the demo host's derived
|
|
32
|
+
* overview route resolves as the union of every component route (so a `/*`
|
|
33
|
+
* rule placement is in that union once per route), and a single tag
|
|
34
|
+
* legitimately placed on two component routes is in it twice with no rules at
|
|
35
|
+
* all.
|
|
36
|
+
*
|
|
37
|
+
* A tag is loaded once and mounted once — `loadMicroDot` dedupes the fetch and
|
|
38
|
+
* a slot holds one instance — so the FIRST resolved placement for a tag wins,
|
|
39
|
+
* matching the order the shells append in.
|
|
40
|
+
*
|
|
41
|
+
* Fully overridden placements are dropped first: they render for nobody.
|
|
42
|
+
*/
|
|
43
|
+
export const pendingMounts = (
|
|
44
|
+
resolved: ReadonlyArray<ResolvedPlacement>,
|
|
45
|
+
mountedTags: ReadonlySet<string>,
|
|
46
|
+
): ReadonlyArray<ResolvedPlacement> =>
|
|
47
|
+
Array.dedupeWith(
|
|
48
|
+
Array.filter(
|
|
49
|
+
resolved,
|
|
50
|
+
placement =>
|
|
51
|
+
placement.state !== 'overridden' && !mountedTags.has(placement.tag),
|
|
52
|
+
),
|
|
53
|
+
(a, b) => a.tag === b.tag,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The mount-time merge, after Phase 5's values migration (work item 2 of
|
|
58
|
+
* `wiki/plans/shipped/microdots-platform-phase-5-pages-design.md`):
|
|
59
|
+
* attribute data moved out of the registry into the topology placements'
|
|
60
|
+
* `values`, so what reaches `createMicroDotFromEntry`'s `overrides` is
|
|
61
|
+
*
|
|
62
|
+
* placement.values → resume overrides (the browser's memory), which win.
|
|
63
|
+
*
|
|
64
|
+
* The registry's `attributes` still spread FIRST inside
|
|
65
|
+
* `createMicroDotFromEntry` — the API stays for foreign hosts — but this
|
|
66
|
+
* repo's registry data is empty since the migration.
|
|
67
|
+
*
|
|
68
|
+
* Resume entries are dropped when EMPTY here, BEFORE the merge: "I looked and
|
|
69
|
+
* found nothing" must leave the placement's value standing, and an empty
|
|
70
|
+
* override merged first and filtered later would delete it instead. That is
|
|
71
|
+
* the exact lesson `createMicroDotFromEntry` learned in the D1 extraction
|
|
72
|
+
* (`loader.ts`), replayed one layer up now that placement values travel
|
|
73
|
+
* through its `overrides` parameter — its own drop-empty filter runs over the
|
|
74
|
+
* ALREADY-merged record and cannot make this distinction.
|
|
75
|
+
*
|
|
76
|
+
* **Call this even when the resume source provably never returns `''`.** The
|
|
77
|
+
* platform spread the two records inline on exactly that reasoning and was
|
|
78
|
+
* therefore one new resume source away from silently deleting a placement
|
|
79
|
+
* value — a correctness argument that has to be re-proved by every future
|
|
80
|
+
* reader is not a safeguard.
|
|
81
|
+
*/
|
|
82
|
+
export const mountOverridesFor = (
|
|
83
|
+
values: Readonly<Record<string, string>>,
|
|
84
|
+
resume: Readonly<Record<string, string>>,
|
|
85
|
+
): Readonly<Record<string, string>> => ({
|
|
86
|
+
...values,
|
|
87
|
+
...Object.fromEntries(
|
|
88
|
+
Object.entries(resume).filter(([, value]) => value !== ''),
|
|
89
|
+
),
|
|
90
|
+
})
|