mellos-mapping 0.19.0 → 0.20.2
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 +366 -56
- package/README.zh-CN.md +319 -47
- package/dist/hook-session-start.mjs +239 -0
- package/dist/mmap.mjs +338 -0
- package/dist/server.mjs +1737 -897
- package/dist/store-paths.mjs +107 -0
- package/dist/watch.mjs +1612 -902
- package/lib/domain/ops.d.ts +171 -0
- package/lib/domain/ops.js +384 -0
- package/lib/domain/types.d.ts +283 -0
- package/lib/domain/types.js +153 -0
- package/lib/render/canvas.d.ts +50 -0
- package/lib/render/canvas.js +210 -0
- package/lib/render/draw.d.ts +37 -0
- package/lib/render/draw.js +111 -0
- package/lib/render/layout.d.ts +89 -0
- package/lib/render/layout.js +200 -0
- package/lib/render/options.d.ts +39 -0
- package/lib/render/options.js +10 -0
- package/lib/render/render.d.ts +88 -0
- package/lib/render/render.js +128 -0
- package/lib/render/routing.d.ts +56 -0
- package/lib/render/routing.js +244 -0
- package/lib/render/skins.d.ts +54 -0
- package/lib/render/skins.js +99 -0
- package/lib/render/width.d.ts +24 -0
- package/lib/render/width.js +139 -0
- package/lib/render/zoom-geometry.d.ts +52 -0
- package/lib/render/zoom-geometry.js +56 -0
- package/lib/semantics/semantics.d.ts +169 -0
- package/lib/semantics/semantics.js +380 -0
- package/lib/semantics/vocabulary.d.ts +79 -0
- package/lib/semantics/vocabulary.js +112 -0
- package/lib/store/format.d.ts +67 -0
- package/lib/store/format.js +334 -0
- package/lib/store/store.d.ts +296 -0
- package/lib/store/store.js +734 -0
- package/package.json +41 -5
- package/scripts/codex-register.mjs +89 -20
- package/scripts/install-mmap-command.mjs +293 -0
- package/scripts/mmap.mjs +213 -0
- package/scripts/open-pane.mjs +115 -254
- package/scripts/pane-core.mjs +418 -0
|
@@ -0,0 +1,334 @@
|
|
|
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
|
+
* The shape is read STRICTLY: a wrong type is refused, never coerced
|
|
13
|
+
* and never quietly dropped. The reason is that the store round-trips —
|
|
14
|
+
* a lenient read of `{"nodes": {...}}` as "no nodes" would be written
|
|
15
|
+
* back by the next save, so leniency here does not tolerate a damaged
|
|
16
|
+
* file, it destroys one.
|
|
17
|
+
* F1. serializeMap is the inverse of parseMap for valid maps.
|
|
18
|
+
*
|
|
19
|
+
* No node:* imports — this module must load in a browser as-is. Filesystem
|
|
20
|
+
* concerns (atomic writes, page file listing, focus requests) live in
|
|
21
|
+
* ./store.ts, the Node-side half.
|
|
22
|
+
*/
|
|
23
|
+
import { declareGroup, declareLane, declareLayer, declareNode, linkNodes, setKind, setTitle, updateNode } from '../domain/ops.js';
|
|
24
|
+
import { EMPTY_MAP, ID_RULE, ID_RULE_TEXT, describeMapError, err, makeGroupId, makeLaneId, makeLayerId, makeMapKind, makeNodeId, makeNodeKind, makeNodeStatus, makeRank, makeSubmapRef, ok, } from '../domain/types.js';
|
|
25
|
+
/** On-disk format version. Bump only with a documented migration. */
|
|
26
|
+
export const STATE_FILE_VERSION = 1;
|
|
27
|
+
export function makePageId(raw) {
|
|
28
|
+
return ID_RULE.test(raw) ? ok(raw) : err({ kind: 'invalid-id', raw, rule: ID_RULE_TEXT });
|
|
29
|
+
}
|
|
30
|
+
export function describeStoreError(e) {
|
|
31
|
+
switch (e.kind) {
|
|
32
|
+
case 'not-found':
|
|
33
|
+
return `no map file at ${e.path}`;
|
|
34
|
+
case 'malformed-json':
|
|
35
|
+
return `map file ${e.path} is not valid JSON: ${e.detail}`;
|
|
36
|
+
case 'bad-shape':
|
|
37
|
+
return `map file ${e.path} has an unexpected shape: ${e.detail}`;
|
|
38
|
+
case 'invariant-violation':
|
|
39
|
+
return `map file ${e.path} violates a structural invariant: ${describeMapError(e.violation)}`;
|
|
40
|
+
case 'save-failed':
|
|
41
|
+
return `could not write ${e.path}: ${e.detail}`;
|
|
42
|
+
case 'delete-failed':
|
|
43
|
+
return `could not delete ${e.path}: ${e.detail}`;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function isRecord(v) {
|
|
47
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
48
|
+
}
|
|
49
|
+
/** How a refused value is named in a bad-shape detail. */
|
|
50
|
+
function describeValue(v) {
|
|
51
|
+
if (v === undefined)
|
|
52
|
+
return 'missing';
|
|
53
|
+
if (v === null)
|
|
54
|
+
return 'null';
|
|
55
|
+
if (Array.isArray(v))
|
|
56
|
+
return 'an array';
|
|
57
|
+
return `a ${typeof v}`;
|
|
58
|
+
}
|
|
59
|
+
function badShape(path, where, expected, got) {
|
|
60
|
+
return err({ kind: 'bad-shape', path, detail: `${where} is ${describeValue(got)}, expected ${expected}` });
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* A list field of the file.
|
|
64
|
+
* @param presence - 'optional' lets the KEY be absent (an unused feature
|
|
65
|
+
* writes no key at all); a present non-array is refused either way.
|
|
66
|
+
*
|
|
67
|
+
* Coercing a non-array to an empty list is the one shape mistake this
|
|
68
|
+
* boundary must never make: `{"nodes": {...}}` would parse as a map with no
|
|
69
|
+
* nodes, and the next save would write that empty interpretation over the
|
|
70
|
+
* file — the boundary erasing the data it exists to protect.
|
|
71
|
+
*/
|
|
72
|
+
function arrayField(raw, key, path, presence) {
|
|
73
|
+
const v = raw[key];
|
|
74
|
+
if (Array.isArray(v))
|
|
75
|
+
return ok(v);
|
|
76
|
+
if (v === undefined && presence === 'optional')
|
|
77
|
+
return ok([]);
|
|
78
|
+
return badShape(path, `"${key}"`, 'an array', v);
|
|
79
|
+
}
|
|
80
|
+
/** A field that must be a string. Numbers and booleans are refused, never coerced. */
|
|
81
|
+
function requiredString(rec, key, where, path) {
|
|
82
|
+
const v = rec[key];
|
|
83
|
+
return typeof v === 'string' ? ok(v) : badShape(path, `${where}.${key}`, 'a string', v);
|
|
84
|
+
}
|
|
85
|
+
/** A field that may be absent, but must be a string when present — never dropped for being the wrong type. */
|
|
86
|
+
function optionalString(rec, key, where, path) {
|
|
87
|
+
const v = rec[key];
|
|
88
|
+
if (v === undefined)
|
|
89
|
+
return ok(undefined);
|
|
90
|
+
return typeof v === 'string' ? ok(v) : badShape(path, `${where}.${key}`, 'a string', v);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Rebuild a MellosMap from untrusted raw data by replaying it through the
|
|
94
|
+
* Layer 0 operations (P1). Field order in the file does not matter; replay
|
|
95
|
+
* order (layers -> lanes -> groups -> nodes -> edges) supplies the required
|
|
96
|
+
* declaration order.
|
|
97
|
+
*/
|
|
98
|
+
export function parseMap(raw, path) {
|
|
99
|
+
if (!isRecord(raw))
|
|
100
|
+
return err({ kind: 'bad-shape', path, detail: 'root is not an object' });
|
|
101
|
+
if (raw['version'] !== STATE_FILE_VERSION) {
|
|
102
|
+
return err({ kind: 'bad-shape', path, detail: `version is ${String(raw['version'])}, expected ${STATE_FILE_VERSION}` });
|
|
103
|
+
}
|
|
104
|
+
const layers = arrayField(raw, 'layers', path, 'required');
|
|
105
|
+
if (!layers.ok)
|
|
106
|
+
return layers;
|
|
107
|
+
const nodes = arrayField(raw, 'nodes', path, 'required');
|
|
108
|
+
if (!nodes.ok)
|
|
109
|
+
return nodes;
|
|
110
|
+
const edges = arrayField(raw, 'edges', path, 'required');
|
|
111
|
+
if (!edges.ok)
|
|
112
|
+
return edges;
|
|
113
|
+
const lanes = arrayField(raw, 'lanes', path, 'optional');
|
|
114
|
+
if (!lanes.ok)
|
|
115
|
+
return lanes;
|
|
116
|
+
const groups = arrayField(raw, 'groups', path, 'optional');
|
|
117
|
+
if (!groups.ok)
|
|
118
|
+
return groups;
|
|
119
|
+
let map = EMPTY_MAP;
|
|
120
|
+
const title = optionalString(raw, 'title', 'map', path);
|
|
121
|
+
if (!title.ok)
|
|
122
|
+
return title;
|
|
123
|
+
if (title.value !== undefined)
|
|
124
|
+
map = setTitle(map, title.value);
|
|
125
|
+
const rawKind = optionalString(raw, 'kind', 'map', path);
|
|
126
|
+
if (!rawKind.ok)
|
|
127
|
+
return rawKind;
|
|
128
|
+
if (rawKind.value !== undefined) {
|
|
129
|
+
const kind = makeMapKind(rawKind.value);
|
|
130
|
+
if (!kind.ok)
|
|
131
|
+
return err({ kind: 'invariant-violation', path, violation: kind.error });
|
|
132
|
+
map = setKind(map, kind.value);
|
|
133
|
+
}
|
|
134
|
+
for (const [i, rawLayer] of layers.value.entries()) {
|
|
135
|
+
const where = `layers[${i}]`;
|
|
136
|
+
if (!isRecord(rawLayer))
|
|
137
|
+
return badShape(path, where, 'an object', rawLayer);
|
|
138
|
+
const rawId = requiredString(rawLayer, 'id', where, path);
|
|
139
|
+
if (!rawId.ok)
|
|
140
|
+
return rawId;
|
|
141
|
+
const id = makeLayerId(rawId.value);
|
|
142
|
+
if (!id.ok)
|
|
143
|
+
return err({ kind: 'invariant-violation', path, violation: id.error });
|
|
144
|
+
const name = requiredString(rawLayer, 'name', where, path);
|
|
145
|
+
if (!name.ok)
|
|
146
|
+
return name;
|
|
147
|
+
const rawRank = rawLayer['rank'];
|
|
148
|
+
if (typeof rawRank !== 'number')
|
|
149
|
+
return badShape(path, `${where}.rank`, 'a number', rawRank);
|
|
150
|
+
// The RANGE and integrality of a rank are the domain's rule (makeRank),
|
|
151
|
+
// never restated here: a file the format accepted but the domain refuses
|
|
152
|
+
// is exactly the drift this boundary exists to prevent.
|
|
153
|
+
const rank = makeRank(rawRank);
|
|
154
|
+
if (!rank.ok)
|
|
155
|
+
return err({ kind: 'invariant-violation', path, violation: rank.error });
|
|
156
|
+
const next = declareLayer(map, { id: id.value, name: name.value, rank: rank.value });
|
|
157
|
+
if (!next.ok)
|
|
158
|
+
return err({ kind: 'invariant-violation', path, violation: next.error });
|
|
159
|
+
map = next.value;
|
|
160
|
+
}
|
|
161
|
+
for (const [i, rawLane] of lanes.value.entries()) {
|
|
162
|
+
const where = `lanes[${i}]`;
|
|
163
|
+
if (!isRecord(rawLane))
|
|
164
|
+
return badShape(path, where, 'an object', rawLane);
|
|
165
|
+
const rawId = requiredString(rawLane, 'id', where, path);
|
|
166
|
+
if (!rawId.ok)
|
|
167
|
+
return rawId;
|
|
168
|
+
const id = makeLaneId(rawId.value);
|
|
169
|
+
if (!id.ok)
|
|
170
|
+
return err({ kind: 'invariant-violation', path, violation: id.error });
|
|
171
|
+
const label = requiredString(rawLane, 'label', where, path);
|
|
172
|
+
if (!label.ok)
|
|
173
|
+
return label;
|
|
174
|
+
const declared = declareLane(map, { id: id.value, label: label.value });
|
|
175
|
+
if (!declared.ok)
|
|
176
|
+
return err({ kind: 'invariant-violation', path, violation: declared.error });
|
|
177
|
+
map = declared.value;
|
|
178
|
+
}
|
|
179
|
+
for (const [i, rawGroup] of groups.value.entries()) {
|
|
180
|
+
const where = `groups[${i}]`;
|
|
181
|
+
if (!isRecord(rawGroup))
|
|
182
|
+
return badShape(path, where, 'an object', rawGroup);
|
|
183
|
+
const rawId = requiredString(rawGroup, 'id', where, path);
|
|
184
|
+
if (!rawId.ok)
|
|
185
|
+
return rawId;
|
|
186
|
+
const id = makeGroupId(rawId.value);
|
|
187
|
+
if (!id.ok)
|
|
188
|
+
return err({ kind: 'invariant-violation', path, violation: id.error });
|
|
189
|
+
const rawLayer = requiredString(rawGroup, 'layer', where, path);
|
|
190
|
+
if (!rawLayer.ok)
|
|
191
|
+
return rawLayer;
|
|
192
|
+
const layer = makeLayerId(rawLayer.value);
|
|
193
|
+
if (!layer.ok)
|
|
194
|
+
return err({ kind: 'invariant-violation', path, violation: layer.error });
|
|
195
|
+
const label = requiredString(rawGroup, 'label', where, path);
|
|
196
|
+
if (!label.ok)
|
|
197
|
+
return label;
|
|
198
|
+
const declared = declareGroup(map, { id: id.value, label: label.value, layer: layer.value });
|
|
199
|
+
if (!declared.ok)
|
|
200
|
+
return err({ kind: 'invariant-violation', path, violation: declared.error });
|
|
201
|
+
map = declared.value;
|
|
202
|
+
}
|
|
203
|
+
for (const [i, rawNode] of nodes.value.entries()) {
|
|
204
|
+
const where = `nodes[${i}]`;
|
|
205
|
+
if (!isRecord(rawNode))
|
|
206
|
+
return badShape(path, where, 'an object', rawNode);
|
|
207
|
+
const rawId = requiredString(rawNode, 'id', where, path);
|
|
208
|
+
if (!rawId.ok)
|
|
209
|
+
return rawId;
|
|
210
|
+
const id = makeNodeId(rawId.value);
|
|
211
|
+
if (!id.ok)
|
|
212
|
+
return err({ kind: 'invariant-violation', path, violation: id.error });
|
|
213
|
+
const rawLayer = requiredString(rawNode, 'layer', where, path);
|
|
214
|
+
if (!rawLayer.ok)
|
|
215
|
+
return rawLayer;
|
|
216
|
+
const layer = makeLayerId(rawLayer.value);
|
|
217
|
+
if (!layer.ok)
|
|
218
|
+
return err({ kind: 'invariant-violation', path, violation: layer.error });
|
|
219
|
+
const rawStatus = requiredString(rawNode, 'status', where, path);
|
|
220
|
+
if (!rawStatus.ok)
|
|
221
|
+
return rawStatus;
|
|
222
|
+
const status = makeNodeStatus(rawStatus.value);
|
|
223
|
+
if (!status.ok)
|
|
224
|
+
return err({ kind: 'invariant-violation', path, violation: status.error });
|
|
225
|
+
const label = requiredString(rawNode, 'label', where, path);
|
|
226
|
+
if (!label.ok)
|
|
227
|
+
return label;
|
|
228
|
+
const detail = optionalString(rawNode, 'detail', where, path);
|
|
229
|
+
if (!detail.ok)
|
|
230
|
+
return detail;
|
|
231
|
+
const rawGroup = optionalString(rawNode, 'group', where, path);
|
|
232
|
+
if (!rawGroup.ok)
|
|
233
|
+
return rawGroup;
|
|
234
|
+
let group;
|
|
235
|
+
if (rawGroup.value !== undefined) {
|
|
236
|
+
const made = makeGroupId(rawGroup.value);
|
|
237
|
+
if (!made.ok)
|
|
238
|
+
return err({ kind: 'invariant-violation', path, violation: made.error });
|
|
239
|
+
group = made.value;
|
|
240
|
+
}
|
|
241
|
+
const rawNodeKind = optionalString(rawNode, 'kind', where, path);
|
|
242
|
+
if (!rawNodeKind.ok)
|
|
243
|
+
return rawNodeKind;
|
|
244
|
+
let nodeKind;
|
|
245
|
+
if (rawNodeKind.value !== undefined) {
|
|
246
|
+
const made = makeNodeKind(rawNodeKind.value);
|
|
247
|
+
if (!made.ok)
|
|
248
|
+
return err({ kind: 'invariant-violation', path, violation: made.error });
|
|
249
|
+
nodeKind = made.value;
|
|
250
|
+
}
|
|
251
|
+
const rawLane = optionalString(rawNode, 'lane', where, path);
|
|
252
|
+
if (!rawLane.ok)
|
|
253
|
+
return rawLane;
|
|
254
|
+
let lane;
|
|
255
|
+
if (rawLane.value !== undefined) {
|
|
256
|
+
const made = makeLaneId(rawLane.value);
|
|
257
|
+
if (!made.ok)
|
|
258
|
+
return err({ kind: 'invariant-violation', path, violation: made.error });
|
|
259
|
+
lane = made.value;
|
|
260
|
+
}
|
|
261
|
+
const rawSubmap = optionalString(rawNode, 'submap', where, path);
|
|
262
|
+
if (!rawSubmap.ok)
|
|
263
|
+
return rawSubmap;
|
|
264
|
+
let submap;
|
|
265
|
+
if (rawSubmap.value !== undefined) {
|
|
266
|
+
const made = makeSubmapRef(rawSubmap.value);
|
|
267
|
+
if (!made.ok)
|
|
268
|
+
return err({ kind: 'invariant-violation', path, violation: made.error });
|
|
269
|
+
submap = made.value;
|
|
270
|
+
}
|
|
271
|
+
const declared = declareNode(map, {
|
|
272
|
+
id: id.value,
|
|
273
|
+
label: label.value,
|
|
274
|
+
layer: layer.value,
|
|
275
|
+
status: status.value,
|
|
276
|
+
...(detail.value !== undefined ? { detail: detail.value } : {}),
|
|
277
|
+
...(group !== undefined ? { group } : {}),
|
|
278
|
+
...(nodeKind !== undefined ? { kind: nodeKind } : {}),
|
|
279
|
+
...(lane !== undefined ? { lane } : {}),
|
|
280
|
+
...(submap !== undefined ? { submap } : {}),
|
|
281
|
+
});
|
|
282
|
+
if (!declared.ok)
|
|
283
|
+
return err({ kind: 'invariant-violation', path, violation: declared.error });
|
|
284
|
+
map = declared.value;
|
|
285
|
+
const evidence = optionalString(rawNode, 'evidence', where, path);
|
|
286
|
+
if (!evidence.ok)
|
|
287
|
+
return evidence;
|
|
288
|
+
if (evidence.value !== undefined) {
|
|
289
|
+
const updated = updateNode(map, { id: id.value, evidence: evidence.value });
|
|
290
|
+
if (!updated.ok)
|
|
291
|
+
return err({ kind: 'invariant-violation', path, violation: updated.error });
|
|
292
|
+
map = updated.value;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
for (const [i, rawEdge] of edges.value.entries()) {
|
|
296
|
+
const where = `edges[${i}]`;
|
|
297
|
+
if (!isRecord(rawEdge))
|
|
298
|
+
return badShape(path, where, 'an object', rawEdge);
|
|
299
|
+
const rawFrom = requiredString(rawEdge, 'from', where, path);
|
|
300
|
+
if (!rawFrom.ok)
|
|
301
|
+
return rawFrom;
|
|
302
|
+
const from = makeNodeId(rawFrom.value);
|
|
303
|
+
if (!from.ok)
|
|
304
|
+
return err({ kind: 'invariant-violation', path, violation: from.error });
|
|
305
|
+
const rawTo = requiredString(rawEdge, 'to', where, path);
|
|
306
|
+
if (!rawTo.ok)
|
|
307
|
+
return rawTo;
|
|
308
|
+
const to = makeNodeId(rawTo.value);
|
|
309
|
+
if (!to.ok)
|
|
310
|
+
return err({ kind: 'invariant-violation', path, violation: to.error });
|
|
311
|
+
const label = optionalString(rawEdge, 'label', where, path);
|
|
312
|
+
if (!label.ok)
|
|
313
|
+
return label;
|
|
314
|
+
const linked = linkNodes(map, from.value, to.value, label.value);
|
|
315
|
+
if (!linked.ok)
|
|
316
|
+
return err({ kind: 'invariant-violation', path, violation: linked.error });
|
|
317
|
+
map = linked.value;
|
|
318
|
+
}
|
|
319
|
+
return ok(map);
|
|
320
|
+
}
|
|
321
|
+
/** Serialize a map into the on-disk shape. Inverse of parseMap for valid maps (F1). */
|
|
322
|
+
export function serializeMap(map) {
|
|
323
|
+
const body = {
|
|
324
|
+
version: STATE_FILE_VERSION,
|
|
325
|
+
...(map.title !== undefined ? { title: map.title } : {}),
|
|
326
|
+
...(map.kind !== undefined ? { kind: map.kind } : {}),
|
|
327
|
+
layers: map.layers,
|
|
328
|
+
...(map.lanes.length > 0 ? { lanes: map.lanes } : {}),
|
|
329
|
+
...(map.groups.length > 0 ? { groups: map.groups } : {}),
|
|
330
|
+
nodes: map.nodes,
|
|
331
|
+
edges: map.edges,
|
|
332
|
+
};
|
|
333
|
+
return JSON.stringify(body, null, 2) + '\n';
|
|
334
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layer 1b — Node-side persistence for a MellosMap.
|
|
3
|
+
*
|
|
4
|
+
* The state file IS the event bus of the whole plugin: the MCP server writes
|
|
5
|
+
* it, the terminal watcher polls it — and the watcher reports back what it is
|
|
6
|
+
* showing (see viewers below), which is how a writer can tell whether anybody
|
|
7
|
+
* is actually SEEING the map it is updating. The file FORMAT (version, page-id
|
|
8
|
+
* grammar, parse/serialize with boundary validation) lives in ./format.ts,
|
|
9
|
+
* pure of I/O so browsers can consume it; this module owns everything that
|
|
10
|
+
* touches the filesystem, and one promise:
|
|
11
|
+
*
|
|
12
|
+
* P2. Writes are atomic: a reader polling the file either sees the previous
|
|
13
|
+
* complete map or the new complete map, never a torn write. Achieved by
|
|
14
|
+
* writing a PRIVATE sibling temp file and renaming it over the target.
|
|
15
|
+
*
|
|
16
|
+
* The concurrency model P2 buys, stated plainly:
|
|
17
|
+
* - Several writers may target one project at once. Each save is atomic and
|
|
18
|
+
* lands whole, so a reader never sees half a map — but there is NO
|
|
19
|
+
* lost-update protection: two saves of the same page race, and the last
|
|
20
|
+
* rename wins, silently discarding what the other writer computed from an
|
|
21
|
+
* older read. Pages are the isolation unit (one effort = one page); two
|
|
22
|
+
* sessions that must not clobber each other belong on two pages.
|
|
23
|
+
* - The temp file carries the writer's pid and a random suffix, so
|
|
24
|
+
* concurrent writers never share one and never install each other's
|
|
25
|
+
* half-written content.
|
|
26
|
+
* - A rename can transiently fail while a reader holds the target open
|
|
27
|
+
* (EPERM/EBUSY on Windows), so it is retried with a short backoff before
|
|
28
|
+
* the save is reported as failed.
|
|
29
|
+
* - A page can also be DELETED (deletePageFile). Deletion races a writer
|
|
30
|
+
* the same way a save does, and the WRITER WINS: a save landing after it
|
|
31
|
+
* recreates the page. Stated at the function, not defended against.
|
|
32
|
+
*
|
|
33
|
+
* Expected failures (missing file, malformed JSON, invariant violations, a
|
|
34
|
+
* write that would not land) are Result values. A failed save changed
|
|
35
|
+
* nothing: the previous file content is intact and the caller may retry.
|
|
36
|
+
* Only truly unexpected I/O faults on the READ path are allowed to propagate
|
|
37
|
+
* as exceptions.
|
|
38
|
+
*
|
|
39
|
+
* Node consumers import everything from here; the format surface is
|
|
40
|
+
* re-exported so persistence has one import site per runtime.
|
|
41
|
+
*/
|
|
42
|
+
import { type MellosMap, type Result } from '../domain/types.js';
|
|
43
|
+
import { type PageId, type StoreError } from './format.js';
|
|
44
|
+
export { STATE_FILE_VERSION, type PageId, makePageId, type StoreError, describeStoreError, parseMap, serializeMap, } from './format.js';
|
|
45
|
+
/**
|
|
46
|
+
* The directory a store lives in, under a project root or under a user's home.
|
|
47
|
+
* It is tool-owned: the map belongs to mellos-mapping, not to whichever host
|
|
48
|
+
* (Claude Code, Codex, a harness) happens to drive the server, so no host
|
|
49
|
+
* brand appears in the path. Pre-0.20 stores under `.claude/` are moved once
|
|
50
|
+
* by {@link migrateLegacyStore}.
|
|
51
|
+
*/
|
|
52
|
+
export declare const STORE_DIR_NAME = ".mellos";
|
|
53
|
+
/** Project-relative location of the DEFAULT page's state file. */
|
|
54
|
+
export declare const STATE_FILE_RELATIVE_PATH: string;
|
|
55
|
+
/** Directory (next to the default file) holding the named pages. */
|
|
56
|
+
export declare const PAGES_DIR_NAME = "pages";
|
|
57
|
+
/** Where a page's map file lives, given the default page's file path. */
|
|
58
|
+
export declare function pageFilePath(defaultFile: string, page?: PageId): string;
|
|
59
|
+
/** The page id a file path denotes; undefined = the default page. */
|
|
60
|
+
export declare function pageIdOfFile(defaultFile: string, path: string): PageId | undefined;
|
|
61
|
+
/** Existing page files: the default page first (when present), then named pages sorted by slug. */
|
|
62
|
+
export declare function listPageFiles(defaultFile: string): string[];
|
|
63
|
+
/**
|
|
64
|
+
* Delete one page's file — a named page, or the DEFAULT page (whose file is
|
|
65
|
+
* optional by design, so removing it is a legal state, not a mutilation).
|
|
66
|
+
*
|
|
67
|
+
* Preconditions: none. Postcondition on ok: no file at `path` — an already
|
|
68
|
+
* absent one is ok too, because the goal state is what is promised, not the
|
|
69
|
+
* act. Postcondition on error: the file is still there and the caller may
|
|
70
|
+
* retry or report; the errno is carried in the detail.
|
|
71
|
+
*
|
|
72
|
+
* Concurrency, stated plainly: deletion races a concurrent writer and THE
|
|
73
|
+
* WRITER WINS. A server saving that page while this runs simply recreates the
|
|
74
|
+
* file (its rename is atomic and needs no existing target), so the page comes
|
|
75
|
+
* back. That is accepted rather than defended against — the store has no
|
|
76
|
+
* lost-update protection anywhere (see the module header), and locking one
|
|
77
|
+
* operation would only make the race rarer, never absent, while claiming
|
|
78
|
+
* otherwise. Pages are the isolation unit: nobody deletes a page another
|
|
79
|
+
* session is writing.
|
|
80
|
+
*
|
|
81
|
+
* What it deliberately does NOT do: sweep `<path>.<pid>.<random>.tmp`
|
|
82
|
+
* siblings. Those temps are private to a save IN FLIGHT, and a live writer
|
|
83
|
+
* whose temp vanished would fail its rename — turning a harmless leftover
|
|
84
|
+
* into a broken save. A stray temp only exists when a write failed AND its
|
|
85
|
+
* own cleanup failed; it is inert, and the README documents it.
|
|
86
|
+
*/
|
|
87
|
+
export declare function deletePageFile(path: string): Result<void, StoreError>;
|
|
88
|
+
/** Sibling of the default file carrying a one-shot "show this page" request. */
|
|
89
|
+
export declare const FOCUS_FILE_NAME = "focus";
|
|
90
|
+
export declare function focusFilePath(defaultFile: string): string;
|
|
91
|
+
/** A consumed focus request: the page to show (undefined = the default page). */
|
|
92
|
+
export interface FocusRequest {
|
|
93
|
+
readonly page: PageId | undefined;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Consume a pending focus request: read it, delete the file, return it.
|
|
97
|
+
* Absent file — the overwhelmingly common case — or junk content means no
|
|
98
|
+
* request; the channel is best-effort and junk is swept by the same delete.
|
|
99
|
+
*/
|
|
100
|
+
export declare function takeFocusRequest(defaultFile: string): FocusRequest | undefined;
|
|
101
|
+
/** Sibling of the default file carrying a one-shot "close the pane" request. */
|
|
102
|
+
export declare const QUIT_FILE_NAME = "quit";
|
|
103
|
+
export declare function quitFilePath(defaultFile: string): string;
|
|
104
|
+
/**
|
|
105
|
+
* Consume a pending quit request: read it, delete the file, say whether there
|
|
106
|
+
* was one. Absent file — the overwhelmingly common case — or content that is
|
|
107
|
+
* not a JSON object means NO request; the channel is best-effort and junk is
|
|
108
|
+
* swept by the same delete.
|
|
109
|
+
*
|
|
110
|
+
* The empty JSON object is the whole grammar. It exists so that a stray file
|
|
111
|
+
* of this name — an editor backup, a half-written write from a foreign tool —
|
|
112
|
+
* cannot take a live pane down by accident; a pane closing is the one thing
|
|
113
|
+
* in this channel a user cannot undo by waiting.
|
|
114
|
+
*/
|
|
115
|
+
export declare function takeQuitRequest(defaultFile: string): boolean;
|
|
116
|
+
/**
|
|
117
|
+
* Delete a quit request WITHOUT acting on it — the same file, read as a
|
|
118
|
+
* leftover rather than as a message.
|
|
119
|
+
*
|
|
120
|
+
* A toggle that wrote the request and then lost its watcher (a crash, a
|
|
121
|
+
* closed window, a `taskkill`) leaves the file behind, and the next pane to
|
|
122
|
+
* open would consume it on its first tick and close instantly. The watcher
|
|
123
|
+
* sweeps at STARTUP for exactly that: a request that predates the pane cannot
|
|
124
|
+
* have been addressed to it. Best-effort, like every delete in this channel.
|
|
125
|
+
*/
|
|
126
|
+
export declare function sweepQuitRequest(defaultFile: string): void;
|
|
127
|
+
/** Directory (next to the default file) holding one report per live pane. */
|
|
128
|
+
export declare const VIEWERS_DIR_NAME = "viewers";
|
|
129
|
+
/** On-disk report format version. Bump only with a documented migration. */
|
|
130
|
+
export declare const VIEWER_FILE_VERSION = 1;
|
|
131
|
+
/**
|
|
132
|
+
* How often a pane refreshes its report — the pane's timer, and the unit the
|
|
133
|
+
* two thresholds below are counted in.
|
|
134
|
+
*/
|
|
135
|
+
export declare const VIEWER_HEARTBEAT_MS = 1000;
|
|
136
|
+
/**
|
|
137
|
+
* A report older than this is not a pane, it is a pane's remains. Five missed
|
|
138
|
+
* heartbeats: long enough to survive a garbage collection, a slow repaint or
|
|
139
|
+
* a busy disk, short enough that a closed pane is known closed before anyone
|
|
140
|
+
* asks twice.
|
|
141
|
+
*/
|
|
142
|
+
export declare const VIEWER_STALE_MS = 5000;
|
|
143
|
+
/**
|
|
144
|
+
* A report this old is deleted on sight by whoever reads it. A pane that was
|
|
145
|
+
* killed leaves its file behind forever, and a file that outlives every pane
|
|
146
|
+
* would keep answering for one. The gap to VIEWER_STALE_MS is deliberate
|
|
147
|
+
* slack: a machine that just woke from sleep has stale reports whose panes
|
|
148
|
+
* are alive and about to beat again, and there is no reason to make them pay
|
|
149
|
+
* for the sleep with a deleted file.
|
|
150
|
+
*/
|
|
151
|
+
export declare const VIEWER_SWEEP_MS = 60000;
|
|
152
|
+
/** Where a store's viewer reports live, given the default page's file path. */
|
|
153
|
+
export declare function viewersDirPath(defaultFile: string): string;
|
|
154
|
+
/** Where ONE pane's report lives. The pid in the name is the pane's identity. */
|
|
155
|
+
export declare function viewerFilePath(defaultFile: string, pid: number): string;
|
|
156
|
+
/** What a pane says about itself while it is up. */
|
|
157
|
+
export interface ViewerReport {
|
|
158
|
+
/** The page on screen right now; undefined = the default page. */
|
|
159
|
+
readonly page: PageId | undefined;
|
|
160
|
+
/** Auto-follow: whether the pane will switch to whatever page is written next. */
|
|
161
|
+
readonly follow: boolean;
|
|
162
|
+
}
|
|
163
|
+
/** A report fresh enough to be a pane, with whose it is and how old. */
|
|
164
|
+
export interface LiveViewer extends ViewerReport {
|
|
165
|
+
readonly pid: number;
|
|
166
|
+
/** Age of the report in ms — how long ago that pane last said anything. */
|
|
167
|
+
readonly ageMs: number;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Publish this pane's report, atomically (P2) — a reader polling the file
|
|
171
|
+
* sees the previous whole report or the new one, never half of one.
|
|
172
|
+
*
|
|
173
|
+
* Preconditions: none; the viewers directory is created if missing.
|
|
174
|
+
* Postcondition on ok: the pane's file holds `report` and its mtime is now,
|
|
175
|
+
* which is what says the pane is alive. On error nothing about the pane is
|
|
176
|
+
* claimed, and the next heartbeat is the whole recovery — so a caller reports
|
|
177
|
+
* a failed beat at most once and keeps beating.
|
|
178
|
+
*/
|
|
179
|
+
export declare function publishViewer(defaultFile: string, pid: number, report: ViewerReport): Result<void, StoreError>;
|
|
180
|
+
/**
|
|
181
|
+
* Take this pane's report back — the pane is going away and says so, rather
|
|
182
|
+
* than leaving readers to wait out VIEWER_STALE_MS for the same conclusion.
|
|
183
|
+
*
|
|
184
|
+
* Best-effort by contract: an exit path is the worst place to raise, and a
|
|
185
|
+
* report nobody could delete goes stale on its own within seconds.
|
|
186
|
+
*/
|
|
187
|
+
export declare function retireViewer(defaultFile: string, pid: number): void;
|
|
188
|
+
/**
|
|
189
|
+
* Every pane currently showing this store, youngest report first.
|
|
190
|
+
*
|
|
191
|
+
* @param nowMs - the caller's clock (Date.now()), passed in so the ageing
|
|
192
|
+
* rules can be tested without waiting for real seconds to pass.
|
|
193
|
+
* @returns the live reports; an EMPTY array means nobody is seeing this map.
|
|
194
|
+
*
|
|
195
|
+
* Reading sweeps: a report past VIEWER_SWEEP_MS is deleted here, because the
|
|
196
|
+
* pane that would have refreshed it is provably gone and no other code runs
|
|
197
|
+
* often enough to notice. A report between stale and sweep is ignored but
|
|
198
|
+
* kept — see VIEWER_SWEEP_MS. Nothing here has an opinion about WHOSE pane a
|
|
199
|
+
* report is: a viewer started by hand counts exactly like one the launcher
|
|
200
|
+
* opened, because the user can see both.
|
|
201
|
+
*/
|
|
202
|
+
export declare function readLiveViewers(defaultFile: string, nowMs: number): readonly LiveViewer[];
|
|
203
|
+
/** Name of the file holding a mapping-policy configuration, in either scope. */
|
|
204
|
+
export declare const CONFIG_FILE_NAME = "config.json";
|
|
205
|
+
/** On-disk config format version. Bump only with a documented migration. */
|
|
206
|
+
export declare const CONFIG_FILE_VERSION = 1;
|
|
207
|
+
/** The PROJECT-scope configuration file: sibling of the default page. */
|
|
208
|
+
export declare function configFilePath(defaultFile: string): string;
|
|
209
|
+
/**
|
|
210
|
+
* The USER-scope configuration file: the same store directory name, under the
|
|
211
|
+
* user's own base directory.
|
|
212
|
+
*
|
|
213
|
+
* @param userBase - the user's home directory. Always passed in, never read
|
|
214
|
+
* from the environment down here: a function that reached for os.homedir()
|
|
215
|
+
* itself would make every spec a gamble on the developer's real
|
|
216
|
+
* configuration, and one of them would eventually write it. Entry points
|
|
217
|
+
* resolve the home once and hand it down.
|
|
218
|
+
*/
|
|
219
|
+
export declare function userConfigFilePath(userBase: string): string;
|
|
220
|
+
export declare const MAPPING_POLICIES: readonly ["always", "complex", "on-request"];
|
|
221
|
+
/** How eagerly maps are opened; 'complex' is the behavior of an unconfigured project. */
|
|
222
|
+
export type MappingPolicy = (typeof MAPPING_POLICIES)[number];
|
|
223
|
+
export interface InvalidPolicy {
|
|
224
|
+
readonly kind: 'invalid-policy';
|
|
225
|
+
readonly raw: string;
|
|
226
|
+
readonly allowed: readonly string[];
|
|
227
|
+
}
|
|
228
|
+
export declare function makeMappingPolicy(raw: string): Result<MappingPolicy, InvalidPolicy>;
|
|
229
|
+
/** One line of meaning per policy — the wording every surface repeats. */
|
|
230
|
+
export declare function describeMappingPolicy(policy: MappingPolicy): string;
|
|
231
|
+
/**
|
|
232
|
+
* The policy recorded in ONE configuration file, or ok(undefined) when nobody
|
|
233
|
+
* has chosen there (missing file or missing key — both mean the same thing).
|
|
234
|
+
* A file that exists but does not parse is an error, never silently ignored.
|
|
235
|
+
*
|
|
236
|
+
* @param path - the configuration file itself: {@link configFilePath} for a
|
|
237
|
+
* project, {@link userConfigFilePath} for the user. One loader, two scopes.
|
|
238
|
+
*/
|
|
239
|
+
export declare function loadMappingPolicy(path: string): Result<MappingPolicy | undefined, StoreError>;
|
|
240
|
+
/**
|
|
241
|
+
* Persist the policy atomically (P2), same write as the map files.
|
|
242
|
+
* @param path - the configuration file to write; see {@link loadMappingPolicy}.
|
|
243
|
+
* @returns ok when the file holds the policy; save-failed leaves the previous
|
|
244
|
+
* configuration in place.
|
|
245
|
+
*/
|
|
246
|
+
export declare function saveMappingPolicy(path: string, policy: MappingPolicy): Result<void, StoreError>;
|
|
247
|
+
/** The two places a mapping policy can be recorded, in override order. */
|
|
248
|
+
export declare const POLICY_SCOPES: readonly ["user", "project"];
|
|
249
|
+
export type PolicyScope = (typeof POLICY_SCOPES)[number];
|
|
250
|
+
/** What both scopes say, and which of them actually governs. */
|
|
251
|
+
export interface MappingPolicyScopes {
|
|
252
|
+
readonly project: MappingPolicy | undefined;
|
|
253
|
+
readonly user: MappingPolicy | undefined;
|
|
254
|
+
/** What to act on. undefined = nobody has chosen yet, anywhere. */
|
|
255
|
+
readonly effective: MappingPolicy | undefined;
|
|
256
|
+
/** Where `effective` came from; undefined exactly when `effective` is. */
|
|
257
|
+
readonly source: PolicyScope | undefined;
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Resolve the policy that governs this project: the PROJECT file if it names
|
|
261
|
+
* one, otherwise the USER file, otherwise nothing.
|
|
262
|
+
*
|
|
263
|
+
* Both scopes are reported, not just the winner — a surface that says "always"
|
|
264
|
+
* without saying where it came from cannot tell a user why changing their
|
|
265
|
+
* user-level choice did nothing here.
|
|
266
|
+
*
|
|
267
|
+
* @param projectConfigFile - see {@link configFilePath}.
|
|
268
|
+
* @param userConfigFile - see {@link userConfigFilePath}.
|
|
269
|
+
* @returns err as soon as EITHER file exists and is broken, project first: a
|
|
270
|
+
* configuration nobody can read is not the same as a configuration nobody
|
|
271
|
+
* wrote, and silently falling through to the other scope would act on a
|
|
272
|
+
* choice the user did not make.
|
|
273
|
+
*/
|
|
274
|
+
export declare function effectiveMappingPolicy(projectConfigFile: string, userConfigFile: string): Result<MappingPolicyScopes, StoreError>;
|
|
275
|
+
/** Project-relative location of the pre-0.20 default page file. */
|
|
276
|
+
export declare const LEGACY_STATE_FILE_RELATIVE_PATH: string;
|
|
277
|
+
/**
|
|
278
|
+
* Move a legacy `.claude` store — map, pages, and mapping-policy config —
|
|
279
|
+
* into the tool-owned `.mellos` location. Never merges: a project whose new
|
|
280
|
+
* store already holds anything keeps it untouched, whatever the legacy
|
|
281
|
+
* directory still contains.
|
|
282
|
+
* @param defaultFile - the NEW default page path (`<root>/.mellos/map.json`);
|
|
283
|
+
* the legacy store is looked up relative to `<root>`.
|
|
284
|
+
* @returns whether a legacy store was moved.
|
|
285
|
+
*/
|
|
286
|
+
export declare function migrateLegacyStore(defaultFile: string): boolean;
|
|
287
|
+
/** Load and validate the map file at `path`. */
|
|
288
|
+
export declare function loadMapFile(path: string): Result<MellosMap, StoreError>;
|
|
289
|
+
/**
|
|
290
|
+
* Write the map to `path` atomically (P2): serialize to a private sibling
|
|
291
|
+
* temp file, then rename it over the target, retrying a rename the OS
|
|
292
|
+
* refuses transiently. Creates the parent directory if missing.
|
|
293
|
+
* @returns ok when the file holds the new map; save-failed when it does not,
|
|
294
|
+
* in which case the previous content is intact and the call may be retried.
|
|
295
|
+
*/
|
|
296
|
+
export declare function saveMapFile(path: string, map: MellosMap): Result<void, StoreError>;
|