mellos-mapping 0.19.0 → 0.20.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 +17 -4
- package/README.zh-CN.md +16 -4
- package/dist/server.mjs +147 -112
- package/dist/watch.mjs +238 -159
- package/lib/domain/ops.d.ts +112 -0
- package/lib/domain/ops.js +253 -0
- package/lib/domain/types.d.ts +242 -0
- package/lib/domain/types.js +122 -0
- package/lib/render/render.d.ts +102 -0
- package/lib/render/render.js +859 -0
- package/lib/semantics/semantics.d.ts +120 -0
- package/lib/semantics/semantics.js +256 -0
- package/lib/store/format.d.ts +50 -0
- package/lib/store/format.js +215 -0
- package/lib/store/store.d.ts +96 -0
- package/lib/store/store.js +281 -0
- package/package.json +30 -2
- package/scripts/codex-register.mjs +1 -1
- package/scripts/open-pane.mjs +1 -1
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layer 1c — medium-neutral VIEW SEMANTICS of a MellosMap.
|
|
3
|
+
*
|
|
4
|
+
* Every renderer (the terminal pane, a web panel, a future editor view) must
|
|
5
|
+
* agree on what a zoom step MEANS, when a map aggregates into its groups,
|
|
6
|
+
* how sequence time is oriented, and which map kinds render neutrally.
|
|
7
|
+
* Those rules live here, pure of any medium: no cells, no glyphs, no DOM,
|
|
8
|
+
* no I/O. Geometry — how a mode maps onto character cells or pixels — stays
|
|
9
|
+
* private to each renderer.
|
|
10
|
+
*/
|
|
11
|
+
import type { MapGroup, MapNode, MellosMap, NodeStatus } from '../domain/types.js';
|
|
12
|
+
/** One wheel tick on the zoom ladder. */
|
|
13
|
+
export type ZoomStep = -4 | -3 | -2 | -1 | 0 | 1 | 2;
|
|
14
|
+
export declare const ZOOM_MIN: ZoomStep;
|
|
15
|
+
export declare const ZOOM_MAX: ZoomStep;
|
|
16
|
+
export declare const ZOOM_DEFAULT: ZoomStep;
|
|
17
|
+
export declare function clampZoom(n: number): ZoomStep;
|
|
18
|
+
/**
|
|
19
|
+
* What a zoom step MEANS, before any renderer decides what it looks like:
|
|
20
|
+
* scaling only compresses, the ends of the ladder switch mode. 'detail'
|
|
21
|
+
* unfolds evidence and design notes; 'overview' switches to the aggregated
|
|
22
|
+
* far view (groups become the nodes — see aggregateMap); 'boxes' is every
|
|
23
|
+
* step in between, where boxes stay boxes and only whitespace and label
|
|
24
|
+
* budgets change.
|
|
25
|
+
*/
|
|
26
|
+
export declare function zoomMode(zoom: ZoomStep): 'detail' | 'boxes' | 'overview';
|
|
27
|
+
/** What a footer shows: a percentage while scaling, a mode name at the ends. */
|
|
28
|
+
export declare function zoomLabel(zoom: ZoomStep): string;
|
|
29
|
+
/** Documentation kinds render neutrally: no status skins, no progress counts. */
|
|
30
|
+
export declare function isNeutralKind(map: MellosMap): boolean;
|
|
31
|
+
/**
|
|
32
|
+
* The derived coarse picture the far zoom renders when the map declares
|
|
33
|
+
* groups: each group becomes ONE labeled node (status derived from members,
|
|
34
|
+
* label carrying done/total), ungrouped nodes stay themselves, and edges
|
|
35
|
+
* collapse onto representatives (intra-group wiring disappears into the
|
|
36
|
+
* box). Derived for rendering only — never persisted. A map without groups
|
|
37
|
+
* returns undefined and falls back to whatever anonymous overview the
|
|
38
|
+
* renderer draws.
|
|
39
|
+
*/
|
|
40
|
+
export declare function aggregateMap(map: MellosMap): MellosMap | undefined;
|
|
41
|
+
/** One neighbour across a dependency edge, with what flows along it. */
|
|
42
|
+
export interface NeighborRef {
|
|
43
|
+
readonly id: string;
|
|
44
|
+
readonly label: string;
|
|
45
|
+
readonly status: NodeStatus;
|
|
46
|
+
readonly edgeLabel?: string;
|
|
47
|
+
}
|
|
48
|
+
/** Everything a detail panel derives for a focused NODE. */
|
|
49
|
+
export interface NodeFocus {
|
|
50
|
+
readonly kind: 'node';
|
|
51
|
+
readonly node: MapNode;
|
|
52
|
+
readonly layerName: string;
|
|
53
|
+
readonly laneLabel?: string;
|
|
54
|
+
/** Lower neighbours this node stands on (on sequence pages: the earlier events). */
|
|
55
|
+
readonly uses: readonly NeighborRef[];
|
|
56
|
+
/** Upper neighbours standing on this node (on sequence pages: the later events). */
|
|
57
|
+
readonly usedBy: readonly NeighborRef[];
|
|
58
|
+
}
|
|
59
|
+
/** Everything a detail panel derives for a focused GROUP (the far zoom's boxes). */
|
|
60
|
+
export interface GroupFocus {
|
|
61
|
+
readonly kind: 'group';
|
|
62
|
+
readonly group: MapGroup;
|
|
63
|
+
readonly status: NodeStatus;
|
|
64
|
+
readonly layerName: string;
|
|
65
|
+
readonly members: readonly MapNode[];
|
|
66
|
+
/** Outside-the-group lower neighbours, each shown as its own group when it has one. */
|
|
67
|
+
readonly uses: readonly NeighborRef[];
|
|
68
|
+
/** Outside-the-group upper neighbours, each shown as its own group when it has one. */
|
|
69
|
+
readonly usedBy: readonly NeighborRef[];
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Derive what a detail panel says about one focused id — a node, or a group
|
|
73
|
+
* when the far zoom's aggregated boxes are what the pointer is over. Pure
|
|
74
|
+
* data: every renderer picks its own glyphs, colors, and words (a sequence
|
|
75
|
+
* page reads uses/usedBy as after/before; that is the caller's vocabulary).
|
|
76
|
+
* @param map - the map the focus lives in.
|
|
77
|
+
* @param focusId - node or group id.
|
|
78
|
+
* @returns the focus view, or undefined when the id names neither.
|
|
79
|
+
*/
|
|
80
|
+
export declare function focusInfo(map: MellosMap, focusId: string): NodeFocus | GroupFocus | undefined;
|
|
81
|
+
/**
|
|
82
|
+
* Page slugs some node of any map dives into. A page referenced as a submap
|
|
83
|
+
* is interior detail — it is reached by diving through its linking node,
|
|
84
|
+
* never by sitting beside its parent as a sibling tab.
|
|
85
|
+
* @param maps - every known page's map (undefined entries are skipped).
|
|
86
|
+
* @returns the referenced submap slugs.
|
|
87
|
+
*/
|
|
88
|
+
export declare function submapRefs(maps: Iterable<MellosMap | undefined>): Set<string>;
|
|
89
|
+
/**
|
|
90
|
+
* Where a sub-map page was dived into from: the entry whose map links the
|
|
91
|
+
* page, plus the linking node's label. Derived by scan, so a breadcrumb
|
|
92
|
+
* survives any client restart with an empty dive stack.
|
|
93
|
+
* @param entries - known pages as (key, map) pairs; keys are caller-owned.
|
|
94
|
+
* @param pageId - the sub-map page's slug.
|
|
95
|
+
* @returns the linking entry's key and node label, or undefined for a top-level page.
|
|
96
|
+
*/
|
|
97
|
+
export declare function diveParent<K>(entries: Iterable<readonly [K, MellosMap | undefined]>, pageId: string): {
|
|
98
|
+
parent: K;
|
|
99
|
+
label: string;
|
|
100
|
+
} | undefined;
|
|
101
|
+
/**
|
|
102
|
+
* The page a client shows when nobody asked for one: the most recently
|
|
103
|
+
* WRITTEN page — the ledger last touched is almost always the effort under
|
|
104
|
+
* way. Keys without a readable timestamp lose; an empty set answers the
|
|
105
|
+
* first key (caller-ordered: default page first, then slug order).
|
|
106
|
+
* @param keys - candidate page keys in the caller's fallback order.
|
|
107
|
+
* @param mtimeOf - last-written timestamp of a key, undefined when unknown.
|
|
108
|
+
* @returns the winning key, or undefined for an empty candidate set.
|
|
109
|
+
*/
|
|
110
|
+
export declare function mostRecentKey<K>(keys: readonly K[], mtimeOf: (key: K) => number | undefined): K | undefined;
|
|
111
|
+
/**
|
|
112
|
+
* Sequence pages read like the classic diagram: time flows DOWNWARD, the
|
|
113
|
+
* earliest step right under the participant headers. The stored map keeps
|
|
114
|
+
* rank 0 = earliest with edges pointing later -> earlier ("later stands on
|
|
115
|
+
* earlier"); this derived value inverts the ranks and reverses the edges so
|
|
116
|
+
* unchanged top-down machinery draws top-down time — each wire now runs
|
|
117
|
+
* from the sender's moment down into the receiver's. Derived for rendering
|
|
118
|
+
* only, never persisted (same contract as aggregateMap).
|
|
119
|
+
*/
|
|
120
|
+
export declare function flipForSequence(map: MellosMap): MellosMap;
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layer 1c — medium-neutral VIEW SEMANTICS of a MellosMap.
|
|
3
|
+
*
|
|
4
|
+
* Every renderer (the terminal pane, a web panel, a future editor view) must
|
|
5
|
+
* agree on what a zoom step MEANS, when a map aggregates into its groups,
|
|
6
|
+
* how sequence time is oriented, and which map kinds render neutrally.
|
|
7
|
+
* Those rules live here, pure of any medium: no cells, no glyphs, no DOM,
|
|
8
|
+
* no I/O. Geometry — how a mode maps onto character cells or pixels — stays
|
|
9
|
+
* private to each renderer.
|
|
10
|
+
*/
|
|
11
|
+
import { groupStatus } from '../domain/ops.js';
|
|
12
|
+
export const ZOOM_MIN = -4;
|
|
13
|
+
export const ZOOM_MAX = 2;
|
|
14
|
+
export const ZOOM_DEFAULT = 0;
|
|
15
|
+
export function clampZoom(n) {
|
|
16
|
+
return Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, Math.round(n)));
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* What a zoom step MEANS, before any renderer decides what it looks like:
|
|
20
|
+
* scaling only compresses, the ends of the ladder switch mode. 'detail'
|
|
21
|
+
* unfolds evidence and design notes; 'overview' switches to the aggregated
|
|
22
|
+
* far view (groups become the nodes — see aggregateMap); 'boxes' is every
|
|
23
|
+
* step in between, where boxes stay boxes and only whitespace and label
|
|
24
|
+
* budgets change.
|
|
25
|
+
*/
|
|
26
|
+
export function zoomMode(zoom) {
|
|
27
|
+
if (zoom >= 1)
|
|
28
|
+
return 'detail';
|
|
29
|
+
if (zoom <= -4)
|
|
30
|
+
return 'overview';
|
|
31
|
+
return 'boxes';
|
|
32
|
+
}
|
|
33
|
+
/** What a footer shows: a percentage while scaling, a mode name at the ends. */
|
|
34
|
+
export function zoomLabel(zoom) {
|
|
35
|
+
switch (zoom) {
|
|
36
|
+
case 2:
|
|
37
|
+
return 'detail+';
|
|
38
|
+
case 1:
|
|
39
|
+
return 'detail';
|
|
40
|
+
case 0:
|
|
41
|
+
return '100%';
|
|
42
|
+
case -1:
|
|
43
|
+
return '85%';
|
|
44
|
+
case -2:
|
|
45
|
+
return '70%';
|
|
46
|
+
case -3:
|
|
47
|
+
return '55%';
|
|
48
|
+
case -4:
|
|
49
|
+
return 'overview';
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// kind semantics
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
/** Documentation kinds render neutrally: no status skins, no progress counts. */
|
|
56
|
+
export function isNeutralKind(map) {
|
|
57
|
+
return map.kind !== undefined && map.kind !== 'dev';
|
|
58
|
+
}
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// derived views (never persisted)
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
/**
|
|
63
|
+
* The derived coarse picture the far zoom renders when the map declares
|
|
64
|
+
* groups: each group becomes ONE labeled node (status derived from members,
|
|
65
|
+
* label carrying done/total), ungrouped nodes stay themselves, and edges
|
|
66
|
+
* collapse onto representatives (intra-group wiring disappears into the
|
|
67
|
+
* box). Derived for rendering only — never persisted. A map without groups
|
|
68
|
+
* returns undefined and falls back to whatever anonymous overview the
|
|
69
|
+
* renderer draws.
|
|
70
|
+
*/
|
|
71
|
+
export function aggregateMap(map) {
|
|
72
|
+
if (map.groups.length === 0)
|
|
73
|
+
return undefined;
|
|
74
|
+
// Group ids join the node-id slug space inside this derived value; the
|
|
75
|
+
// brands only guard PERSISTED maps, and this one never leaves rendering.
|
|
76
|
+
const representative = new Map();
|
|
77
|
+
for (const n of map.nodes)
|
|
78
|
+
representative.set(n.id, (n.group ?? n.id));
|
|
79
|
+
const nodes = map.groups.map((g) => {
|
|
80
|
+
const members = map.nodes.filter((n) => n.group === g.id);
|
|
81
|
+
const done = members.filter((n) => n.status === 'done').length;
|
|
82
|
+
return {
|
|
83
|
+
id: g.id,
|
|
84
|
+
// neutral kinds document structure, not progress — no member counts
|
|
85
|
+
label: isNeutralKind(map) ? g.label : `${g.label} ${done}/${members.length}`,
|
|
86
|
+
layer: g.layer,
|
|
87
|
+
status: groupStatus(map, g.id),
|
|
88
|
+
};
|
|
89
|
+
});
|
|
90
|
+
for (const n of map.nodes)
|
|
91
|
+
if (n.group === undefined)
|
|
92
|
+
nodes.push(n);
|
|
93
|
+
const seen = new Set();
|
|
94
|
+
const edges = [];
|
|
95
|
+
for (const e of map.edges) {
|
|
96
|
+
const from = representative.get(e.from);
|
|
97
|
+
const to = representative.get(e.to);
|
|
98
|
+
if (from === to || seen.has(`${from}->${to}`))
|
|
99
|
+
continue;
|
|
100
|
+
seen.add(`${from}->${to}`);
|
|
101
|
+
edges.push({ from: from, to: to });
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
...(map.title !== undefined ? { title: map.title } : {}),
|
|
105
|
+
...(map.kind !== undefined ? { kind: map.kind } : {}),
|
|
106
|
+
layers: map.layers,
|
|
107
|
+
groups: [],
|
|
108
|
+
lanes: map.lanes,
|
|
109
|
+
nodes,
|
|
110
|
+
edges,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Derive what a detail panel says about one focused id — a node, or a group
|
|
115
|
+
* when the far zoom's aggregated boxes are what the pointer is over. Pure
|
|
116
|
+
* data: every renderer picks its own glyphs, colors, and words (a sequence
|
|
117
|
+
* page reads uses/usedBy as after/before; that is the caller's vocabulary).
|
|
118
|
+
* @param map - the map the focus lives in.
|
|
119
|
+
* @param focusId - node or group id.
|
|
120
|
+
* @returns the focus view, or undefined when the id names neither.
|
|
121
|
+
*/
|
|
122
|
+
export function focusInfo(map, focusId) {
|
|
123
|
+
const layerNameOf = (layerId) => map.layers.find((l) => l.id === layerId)?.name ?? layerId;
|
|
124
|
+
const group = map.groups.find((g) => g.id === focusId);
|
|
125
|
+
if (group) {
|
|
126
|
+
const members = map.nodes.filter((n) => n.group === group.id);
|
|
127
|
+
const memberIds = new Set(members.map((n) => n.id));
|
|
128
|
+
// A neighbour is shown as its own group when it has one, else as itself.
|
|
129
|
+
const rep = (id) => {
|
|
130
|
+
const n = map.nodes.find((x) => x.id === id);
|
|
131
|
+
const owner = n.group !== undefined ? map.groups.find((g) => g.id === n.group) : undefined;
|
|
132
|
+
return owner !== undefined
|
|
133
|
+
? { id: owner.id, label: owner.label, status: groupStatus(map, owner.id) }
|
|
134
|
+
: { id: n.id, label: n.label, status: n.status };
|
|
135
|
+
};
|
|
136
|
+
const dedupe = (refs) => {
|
|
137
|
+
const seen = new Set();
|
|
138
|
+
const out = [];
|
|
139
|
+
for (const r of refs) {
|
|
140
|
+
if (seen.has(r.id))
|
|
141
|
+
continue;
|
|
142
|
+
seen.add(r.id);
|
|
143
|
+
out.push(r);
|
|
144
|
+
}
|
|
145
|
+
return out;
|
|
146
|
+
};
|
|
147
|
+
return {
|
|
148
|
+
kind: 'group',
|
|
149
|
+
group,
|
|
150
|
+
status: groupStatus(map, group.id),
|
|
151
|
+
layerName: layerNameOf(group.layer),
|
|
152
|
+
members,
|
|
153
|
+
uses: dedupe(map.edges
|
|
154
|
+
.filter((e) => memberIds.has(e.from) && !memberIds.has(e.to))
|
|
155
|
+
.map((e) => rep(e.to))),
|
|
156
|
+
usedBy: dedupe(map.edges
|
|
157
|
+
.filter((e) => memberIds.has(e.to) && !memberIds.has(e.from))
|
|
158
|
+
.map((e) => rep(e.from))),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
const node = map.nodes.find((n) => n.id === focusId);
|
|
162
|
+
if (!node)
|
|
163
|
+
return undefined;
|
|
164
|
+
const ref = (id, edgeLabel) => {
|
|
165
|
+
const n = map.nodes.find((x) => x.id === id);
|
|
166
|
+
return {
|
|
167
|
+
id,
|
|
168
|
+
label: n?.label ?? id,
|
|
169
|
+
status: n?.status ?? 'planned',
|
|
170
|
+
...(edgeLabel !== undefined ? { edgeLabel } : {}),
|
|
171
|
+
};
|
|
172
|
+
};
|
|
173
|
+
const laneLabel = node.lane !== undefined ? map.lanes.find((l) => l.id === node.lane)?.label : undefined;
|
|
174
|
+
return {
|
|
175
|
+
kind: 'node',
|
|
176
|
+
node,
|
|
177
|
+
layerName: layerNameOf(node.layer),
|
|
178
|
+
...(laneLabel !== undefined ? { laneLabel } : {}),
|
|
179
|
+
uses: map.edges.filter((e) => e.from === node.id).map((e) => ref(e.to, e.label)),
|
|
180
|
+
usedBy: map.edges.filter((e) => e.to === node.id).map((e) => ref(e.from, e.label)),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
// page-set semantics — which pages are siblings, and where a dive came from
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
/**
|
|
187
|
+
* Page slugs some node of any map dives into. A page referenced as a submap
|
|
188
|
+
* is interior detail — it is reached by diving through its linking node,
|
|
189
|
+
* never by sitting beside its parent as a sibling tab.
|
|
190
|
+
* @param maps - every known page's map (undefined entries are skipped).
|
|
191
|
+
* @returns the referenced submap slugs.
|
|
192
|
+
*/
|
|
193
|
+
export function submapRefs(maps) {
|
|
194
|
+
const refs = new Set();
|
|
195
|
+
for (const m of maps) {
|
|
196
|
+
for (const n of m?.nodes ?? [])
|
|
197
|
+
if (n.submap !== undefined)
|
|
198
|
+
refs.add(n.submap);
|
|
199
|
+
}
|
|
200
|
+
return refs;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Where a sub-map page was dived into from: the entry whose map links the
|
|
204
|
+
* page, plus the linking node's label. Derived by scan, so a breadcrumb
|
|
205
|
+
* survives any client restart with an empty dive stack.
|
|
206
|
+
* @param entries - known pages as (key, map) pairs; keys are caller-owned.
|
|
207
|
+
* @param pageId - the sub-map page's slug.
|
|
208
|
+
* @returns the linking entry's key and node label, or undefined for a top-level page.
|
|
209
|
+
*/
|
|
210
|
+
export function diveParent(entries, pageId) {
|
|
211
|
+
for (const [key, m] of entries) {
|
|
212
|
+
const node = m?.nodes.find((n) => n.submap === pageId);
|
|
213
|
+
if (node !== undefined)
|
|
214
|
+
return { parent: key, label: node.label };
|
|
215
|
+
}
|
|
216
|
+
return undefined;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* The page a client shows when nobody asked for one: the most recently
|
|
220
|
+
* WRITTEN page — the ledger last touched is almost always the effort under
|
|
221
|
+
* way. Keys without a readable timestamp lose; an empty set answers the
|
|
222
|
+
* first key (caller-ordered: default page first, then slug order).
|
|
223
|
+
* @param keys - candidate page keys in the caller's fallback order.
|
|
224
|
+
* @param mtimeOf - last-written timestamp of a key, undefined when unknown.
|
|
225
|
+
* @returns the winning key, or undefined for an empty candidate set.
|
|
226
|
+
*/
|
|
227
|
+
export function mostRecentKey(keys, mtimeOf) {
|
|
228
|
+
let best;
|
|
229
|
+
let bestMtime = -Infinity;
|
|
230
|
+
for (const key of keys) {
|
|
231
|
+
const mtime = mtimeOf(key);
|
|
232
|
+
if (mtime !== undefined && mtime > bestMtime) {
|
|
233
|
+
best = key;
|
|
234
|
+
bestMtime = mtime;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return best ?? keys[0];
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Sequence pages read like the classic diagram: time flows DOWNWARD, the
|
|
241
|
+
* earliest step right under the participant headers. The stored map keeps
|
|
242
|
+
* rank 0 = earliest with edges pointing later -> earlier ("later stands on
|
|
243
|
+
* earlier"); this derived value inverts the ranks and reverses the edges so
|
|
244
|
+
* unchanged top-down machinery draws top-down time — each wire now runs
|
|
245
|
+
* from the sender's moment down into the receiver's. Derived for rendering
|
|
246
|
+
* only, never persisted (same contract as aggregateMap).
|
|
247
|
+
*/
|
|
248
|
+
export function flipForSequence(map) {
|
|
249
|
+
if (map.kind !== 'sequence')
|
|
250
|
+
return map;
|
|
251
|
+
return {
|
|
252
|
+
...map,
|
|
253
|
+
layers: map.layers.map((l) => ({ ...l, rank: -l.rank })),
|
|
254
|
+
edges: map.edges.map((e) => ({ from: e.to, to: e.from, ...(e.label !== undefined ? { label: e.label } : {}) })),
|
|
255
|
+
};
|
|
256
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layer 1a — the state-file FORMAT of a MellosMap, pure of any I/O.
|
|
3
|
+
*
|
|
4
|
+
* This module owns the on-disk vocabulary (version, page-id grammar) and the
|
|
5
|
+
* two format promises every consumer relies on:
|
|
6
|
+
*
|
|
7
|
+
* P1. Whatever parseMap accepts satisfies the structural invariants of
|
|
8
|
+
* Layer 0. Parsing is done by REPLAYING the raw data through the domain
|
|
9
|
+
* operations, so a hand-edited or corrupted file can never smuggle an
|
|
10
|
+
* invariant violation into the process (validate at the boundary,
|
|
11
|
+
* trust internal code afterwards).
|
|
12
|
+
* F1. serializeMap is the inverse of parseMap for valid maps.
|
|
13
|
+
*
|
|
14
|
+
* No node:* imports — this module must load in a browser as-is. Filesystem
|
|
15
|
+
* concerns (atomic writes, page file listing, focus requests) live in
|
|
16
|
+
* ./store.ts, the Node-side half.
|
|
17
|
+
*/
|
|
18
|
+
import { type InvalidId, type MapError, type MellosMap, type Result } from '../domain/types.js';
|
|
19
|
+
/** On-disk format version. Bump only with a documented migration. */
|
|
20
|
+
export declare const STATE_FILE_VERSION = 1;
|
|
21
|
+
export type PageId = string & {
|
|
22
|
+
readonly __brand: 'PageId';
|
|
23
|
+
};
|
|
24
|
+
export declare function makePageId(raw: string): Result<PageId, InvalidId>;
|
|
25
|
+
export type StoreError = {
|
|
26
|
+
readonly kind: 'not-found';
|
|
27
|
+
readonly path: string;
|
|
28
|
+
} | {
|
|
29
|
+
readonly kind: 'malformed-json';
|
|
30
|
+
readonly path: string;
|
|
31
|
+
readonly detail: string;
|
|
32
|
+
} | {
|
|
33
|
+
readonly kind: 'bad-shape';
|
|
34
|
+
readonly path: string;
|
|
35
|
+
readonly detail: string;
|
|
36
|
+
} | {
|
|
37
|
+
readonly kind: 'invariant-violation';
|
|
38
|
+
readonly path: string;
|
|
39
|
+
readonly violation: MapError;
|
|
40
|
+
};
|
|
41
|
+
export declare function describeStoreError(e: StoreError): string;
|
|
42
|
+
/**
|
|
43
|
+
* Rebuild a MellosMap from untrusted raw data by replaying it through the
|
|
44
|
+
* Layer 0 operations (P1). Field order in the file does not matter; replay
|
|
45
|
+
* order (layers -> lanes -> groups -> nodes -> edges) supplies the required
|
|
46
|
+
* declaration order.
|
|
47
|
+
*/
|
|
48
|
+
export declare function parseMap(raw: unknown, path: string): Result<MellosMap, StoreError>;
|
|
49
|
+
/** Serialize a map into the on-disk shape. Inverse of parseMap for valid maps (F1). */
|
|
50
|
+
export declare function serializeMap(map: MellosMap): string;
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layer 1a — the state-file FORMAT of a MellosMap, pure of any I/O.
|
|
3
|
+
*
|
|
4
|
+
* This module owns the on-disk vocabulary (version, page-id grammar) and the
|
|
5
|
+
* two format promises every consumer relies on:
|
|
6
|
+
*
|
|
7
|
+
* P1. Whatever parseMap accepts satisfies the structural invariants of
|
|
8
|
+
* Layer 0. Parsing is done by REPLAYING the raw data through the domain
|
|
9
|
+
* operations, so a hand-edited or corrupted file can never smuggle an
|
|
10
|
+
* invariant violation into the process (validate at the boundary,
|
|
11
|
+
* trust internal code afterwards).
|
|
12
|
+
* F1. serializeMap is the inverse of parseMap for valid maps.
|
|
13
|
+
*
|
|
14
|
+
* No node:* imports — this module must load in a browser as-is. Filesystem
|
|
15
|
+
* concerns (atomic writes, page file listing, focus requests) live in
|
|
16
|
+
* ./store.ts, the Node-side half.
|
|
17
|
+
*/
|
|
18
|
+
import { declareGroup, declareLane, declareLayer, declareNode, linkNodes, setKind, setTitle, updateNode } from '../domain/ops.js';
|
|
19
|
+
import { EMPTY_MAP, ID_RULE, ID_RULE_TEXT, describeMapError, err, makeGroupId, makeLaneId, makeLayerId, makeMapKind, makeNodeId, makeNodeKind, makeNodeStatus, makeSubmapRef, ok, } from '../domain/types.js';
|
|
20
|
+
/** On-disk format version. Bump only with a documented migration. */
|
|
21
|
+
export const STATE_FILE_VERSION = 1;
|
|
22
|
+
export function makePageId(raw) {
|
|
23
|
+
return ID_RULE.test(raw) ? ok(raw) : err({ kind: 'invalid-id', raw, rule: ID_RULE_TEXT });
|
|
24
|
+
}
|
|
25
|
+
export function describeStoreError(e) {
|
|
26
|
+
switch (e.kind) {
|
|
27
|
+
case 'not-found':
|
|
28
|
+
return `no map file at ${e.path}`;
|
|
29
|
+
case 'malformed-json':
|
|
30
|
+
return `map file ${e.path} is not valid JSON: ${e.detail}`;
|
|
31
|
+
case 'bad-shape':
|
|
32
|
+
return `map file ${e.path} has an unexpected shape: ${e.detail}`;
|
|
33
|
+
case 'invariant-violation':
|
|
34
|
+
return `map file ${e.path} violates a structural invariant: ${describeMapError(e.violation)}`;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function isRecord(v) {
|
|
38
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
39
|
+
}
|
|
40
|
+
function asArray(v) {
|
|
41
|
+
return Array.isArray(v) ? v : [];
|
|
42
|
+
}
|
|
43
|
+
function optionalString(v) {
|
|
44
|
+
return typeof v === 'string' ? v : undefined;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Rebuild a MellosMap from untrusted raw data by replaying it through the
|
|
48
|
+
* Layer 0 operations (P1). Field order in the file does not matter; replay
|
|
49
|
+
* order (layers -> lanes -> groups -> nodes -> edges) supplies the required
|
|
50
|
+
* declaration order.
|
|
51
|
+
*/
|
|
52
|
+
export function parseMap(raw, path) {
|
|
53
|
+
if (!isRecord(raw))
|
|
54
|
+
return err({ kind: 'bad-shape', path, detail: 'root is not an object' });
|
|
55
|
+
if (raw['version'] !== STATE_FILE_VERSION) {
|
|
56
|
+
return err({ kind: 'bad-shape', path, detail: `version is ${String(raw['version'])}, expected ${STATE_FILE_VERSION}` });
|
|
57
|
+
}
|
|
58
|
+
let map = EMPTY_MAP;
|
|
59
|
+
const title = optionalString(raw['title']);
|
|
60
|
+
if (title !== undefined)
|
|
61
|
+
map = setTitle(map, title);
|
|
62
|
+
const rawKind = optionalString(raw['kind']);
|
|
63
|
+
if (rawKind !== undefined) {
|
|
64
|
+
const kind = makeMapKind(rawKind);
|
|
65
|
+
if (!kind.ok)
|
|
66
|
+
return err({ kind: 'invariant-violation', path, violation: kind.error });
|
|
67
|
+
map = setKind(map, kind.value);
|
|
68
|
+
}
|
|
69
|
+
for (const [i, rawLayer] of asArray(raw['layers']).entries()) {
|
|
70
|
+
if (!isRecord(rawLayer))
|
|
71
|
+
return err({ kind: 'bad-shape', path, detail: `layers[${i}] is not an object` });
|
|
72
|
+
const id = makeLayerId(String(rawLayer['id'] ?? ''));
|
|
73
|
+
if (!id.ok)
|
|
74
|
+
return err({ kind: 'invariant-violation', path, violation: id.error });
|
|
75
|
+
const name = optionalString(rawLayer['name']);
|
|
76
|
+
const rank = rawLayer['rank'];
|
|
77
|
+
if (name === undefined || typeof rank !== 'number' || !Number.isInteger(rank)) {
|
|
78
|
+
return err({ kind: 'bad-shape', path, detail: `layers[${i}] needs a string name and an integer rank` });
|
|
79
|
+
}
|
|
80
|
+
const next = declareLayer(map, { id: id.value, name, rank });
|
|
81
|
+
if (!next.ok)
|
|
82
|
+
return err({ kind: 'invariant-violation', path, violation: next.error });
|
|
83
|
+
map = next.value;
|
|
84
|
+
}
|
|
85
|
+
for (const [i, rawLane] of asArray(raw['lanes']).entries()) {
|
|
86
|
+
if (!isRecord(rawLane))
|
|
87
|
+
return err({ kind: 'bad-shape', path, detail: `lanes[${i}] is not an object` });
|
|
88
|
+
const id = makeLaneId(String(rawLane['id'] ?? ''));
|
|
89
|
+
if (!id.ok)
|
|
90
|
+
return err({ kind: 'invariant-violation', path, violation: id.error });
|
|
91
|
+
const label = optionalString(rawLane['label']);
|
|
92
|
+
if (label === undefined)
|
|
93
|
+
return err({ kind: 'bad-shape', path, detail: `lanes[${i}] needs a string label` });
|
|
94
|
+
const declared = declareLane(map, { id: id.value, label });
|
|
95
|
+
if (!declared.ok)
|
|
96
|
+
return err({ kind: 'invariant-violation', path, violation: declared.error });
|
|
97
|
+
map = declared.value;
|
|
98
|
+
}
|
|
99
|
+
for (const [i, rawGroup] of asArray(raw['groups']).entries()) {
|
|
100
|
+
if (!isRecord(rawGroup))
|
|
101
|
+
return err({ kind: 'bad-shape', path, detail: `groups[${i}] is not an object` });
|
|
102
|
+
const id = makeGroupId(String(rawGroup['id'] ?? ''));
|
|
103
|
+
if (!id.ok)
|
|
104
|
+
return err({ kind: 'invariant-violation', path, violation: id.error });
|
|
105
|
+
const layer = makeLayerId(String(rawGroup['layer'] ?? ''));
|
|
106
|
+
if (!layer.ok)
|
|
107
|
+
return err({ kind: 'invariant-violation', path, violation: layer.error });
|
|
108
|
+
const label = optionalString(rawGroup['label']);
|
|
109
|
+
if (label === undefined)
|
|
110
|
+
return err({ kind: 'bad-shape', path, detail: `groups[${i}] needs a string label` });
|
|
111
|
+
const declared = declareGroup(map, { id: id.value, label, layer: layer.value });
|
|
112
|
+
if (!declared.ok)
|
|
113
|
+
return err({ kind: 'invariant-violation', path, violation: declared.error });
|
|
114
|
+
map = declared.value;
|
|
115
|
+
}
|
|
116
|
+
for (const [i, rawNode] of asArray(raw['nodes']).entries()) {
|
|
117
|
+
if (!isRecord(rawNode))
|
|
118
|
+
return err({ kind: 'bad-shape', path, detail: `nodes[${i}] is not an object` });
|
|
119
|
+
const id = makeNodeId(String(rawNode['id'] ?? ''));
|
|
120
|
+
if (!id.ok)
|
|
121
|
+
return err({ kind: 'invariant-violation', path, violation: id.error });
|
|
122
|
+
const layer = makeLayerId(String(rawNode['layer'] ?? ''));
|
|
123
|
+
if (!layer.ok)
|
|
124
|
+
return err({ kind: 'invariant-violation', path, violation: layer.error });
|
|
125
|
+
const status = makeNodeStatus(String(rawNode['status'] ?? ''));
|
|
126
|
+
if (!status.ok)
|
|
127
|
+
return err({ kind: 'invariant-violation', path, violation: status.error });
|
|
128
|
+
const label = optionalString(rawNode['label']);
|
|
129
|
+
if (label === undefined)
|
|
130
|
+
return err({ kind: 'bad-shape', path, detail: `nodes[${i}] needs a string label` });
|
|
131
|
+
const detail = optionalString(rawNode['detail']);
|
|
132
|
+
const rawGroup = optionalString(rawNode['group']);
|
|
133
|
+
let group;
|
|
134
|
+
if (rawGroup !== undefined) {
|
|
135
|
+
const made = makeGroupId(rawGroup);
|
|
136
|
+
if (!made.ok)
|
|
137
|
+
return err({ kind: 'invariant-violation', path, violation: made.error });
|
|
138
|
+
group = made.value;
|
|
139
|
+
}
|
|
140
|
+
const rawNodeKind = optionalString(rawNode['kind']);
|
|
141
|
+
let nodeKind;
|
|
142
|
+
if (rawNodeKind !== undefined) {
|
|
143
|
+
const made = makeNodeKind(rawNodeKind);
|
|
144
|
+
if (!made.ok)
|
|
145
|
+
return err({ kind: 'invariant-violation', path, violation: made.error });
|
|
146
|
+
nodeKind = made.value;
|
|
147
|
+
}
|
|
148
|
+
const rawLane = optionalString(rawNode['lane']);
|
|
149
|
+
let lane;
|
|
150
|
+
if (rawLane !== undefined) {
|
|
151
|
+
const made = makeLaneId(rawLane);
|
|
152
|
+
if (!made.ok)
|
|
153
|
+
return err({ kind: 'invariant-violation', path, violation: made.error });
|
|
154
|
+
lane = made.value;
|
|
155
|
+
}
|
|
156
|
+
const rawSubmap = optionalString(rawNode['submap']);
|
|
157
|
+
let submap;
|
|
158
|
+
if (rawSubmap !== undefined) {
|
|
159
|
+
const made = makeSubmapRef(rawSubmap);
|
|
160
|
+
if (!made.ok)
|
|
161
|
+
return err({ kind: 'invariant-violation', path, violation: made.error });
|
|
162
|
+
submap = made.value;
|
|
163
|
+
}
|
|
164
|
+
const declared = declareNode(map, {
|
|
165
|
+
id: id.value,
|
|
166
|
+
label,
|
|
167
|
+
layer: layer.value,
|
|
168
|
+
status: status.value,
|
|
169
|
+
...(detail !== undefined ? { detail } : {}),
|
|
170
|
+
...(group !== undefined ? { group } : {}),
|
|
171
|
+
...(nodeKind !== undefined ? { kind: nodeKind } : {}),
|
|
172
|
+
...(lane !== undefined ? { lane } : {}),
|
|
173
|
+
...(submap !== undefined ? { submap } : {}),
|
|
174
|
+
});
|
|
175
|
+
if (!declared.ok)
|
|
176
|
+
return err({ kind: 'invariant-violation', path, violation: declared.error });
|
|
177
|
+
map = declared.value;
|
|
178
|
+
const evidence = optionalString(rawNode['evidence']);
|
|
179
|
+
if (evidence !== undefined) {
|
|
180
|
+
const updated = updateNode(map, { id: id.value, evidence });
|
|
181
|
+
if (!updated.ok)
|
|
182
|
+
return err({ kind: 'invariant-violation', path, violation: updated.error });
|
|
183
|
+
map = updated.value;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
for (const [i, rawEdge] of asArray(raw['edges']).entries()) {
|
|
187
|
+
if (!isRecord(rawEdge))
|
|
188
|
+
return err({ kind: 'bad-shape', path, detail: `edges[${i}] is not an object` });
|
|
189
|
+
const from = makeNodeId(String(rawEdge['from'] ?? ''));
|
|
190
|
+
if (!from.ok)
|
|
191
|
+
return err({ kind: 'invariant-violation', path, violation: from.error });
|
|
192
|
+
const to = makeNodeId(String(rawEdge['to'] ?? ''));
|
|
193
|
+
if (!to.ok)
|
|
194
|
+
return err({ kind: 'invariant-violation', path, violation: to.error });
|
|
195
|
+
const linked = linkNodes(map, from.value, to.value, optionalString(rawEdge['label']));
|
|
196
|
+
if (!linked.ok)
|
|
197
|
+
return err({ kind: 'invariant-violation', path, violation: linked.error });
|
|
198
|
+
map = linked.value;
|
|
199
|
+
}
|
|
200
|
+
return ok(map);
|
|
201
|
+
}
|
|
202
|
+
/** Serialize a map into the on-disk shape. Inverse of parseMap for valid maps (F1). */
|
|
203
|
+
export function serializeMap(map) {
|
|
204
|
+
const body = {
|
|
205
|
+
version: STATE_FILE_VERSION,
|
|
206
|
+
...(map.title !== undefined ? { title: map.title } : {}),
|
|
207
|
+
...(map.kind !== undefined ? { kind: map.kind } : {}),
|
|
208
|
+
layers: map.layers,
|
|
209
|
+
...(map.lanes.length > 0 ? { lanes: map.lanes } : {}),
|
|
210
|
+
...(map.groups.length > 0 ? { groups: map.groups } : {}),
|
|
211
|
+
nodes: map.nodes,
|
|
212
|
+
edges: map.edges,
|
|
213
|
+
};
|
|
214
|
+
return JSON.stringify(body, null, 2) + '\n';
|
|
215
|
+
}
|