@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.
@@ -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
+ })
@@ -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
+ })